'use strict'; const config = require('./level-resources-config'), sha256 = require('./level-resources-sha256'); const v = require('./validation'), { create } = require('./core'); const fs = wx.getFileSystemManager(); const release = wx.getAccountInfoSync().miniProgram.envVersion === 'release'; let perfOverride; try { perfOverride = wx.getStorageSync('level-resources-perf'); } catch (_) {} const perf = require('./perf').create({ enabled: perfOverride === true || (!release && perfOverride !== false), watch: true, output: line => console.log(line) }); window.__levelPerf = perf; const environment = release ? 'production' : 'test'; const baseUrl = (release ? config.productionUrl : config.testUrl).replace(/\/+$/, '') + '/'; const builtin = perf.measure('startup.builtinManifest', () => v.builtin(JSON.parse(fs.readFileSync('levels-local/manifest.json', 'utf8')))); v.check(!release || !builtin.simulatedB, '正式版本禁止使用模拟B资源'); const namespace = sha256(new Uint8Array(encodeURIComponent(baseUrl).split('').map(c => c.charCodeAt(0)))); const parent = wx.env.USER_DATA_PATH + '/level-resources', root = parent + '/' + namespace; const call = (method, args) => perf.measureAsync('fs.' + method, () => new Promise((resolve, reject) => fs[method](Object.assign({}, args, { success: resolve, fail: reject })))); const madeDirectories = new Set(); function mkdir(dir) { if (madeDirectories.has(dir)) return; try { fs.mkdirSync(dir, true); } catch (e) { if (!fs.statSync(dir).isDirectory()) throw e; } // Only persistent directories are reused. Staging directories are deleted after every job. if (!dir.includes('/staging/')) madeDirectories.add(dir); } function decode(bytes) { let text = ''; const array = new Uint8Array(bytes); for (let i = 0; i < array.length; i++) text += '%' + ('0' + array[i].toString(16)).slice(-2); return decodeURIComponent(text); } async function read(filePath) { return (await call('readFile', { filePath })).data; } async function remove(filePath) { try { await call('unlink', { filePath }); } catch (_) {} } async function discard(dir) { if (!dir.startsWith(root + '/staging/') || !/^[a-z0-9-]+$/.test(dir.slice((root + '/staging/').length))) return; try { await call('rmdir', { dirPath: dir, recursive: true }); } catch (_) {} } // Clean only this app's retired namespace and abandoned staging folders at cold startup. try { const old = wx.getStorageSync('level-resources-active:' + environment); if (old && old !== namespace && /^[a-f0-9]{64}$/.test(old)) fs.rmdirSync(parent + '/' + old, true); wx.setStorageSync('level-resources-active:' + environment, namespace); } catch (error) { console.warn('[LevelResources] 缓存清理稍后重试', error); } try { for (const name of fs.readdirSync(root + '/staging')) if (/^[a-z0-9-]+$/.test(name)) fs.rmdirSync(root + '/staging/' + name, true); } catch (_) {} let sequence = 0, networkCount = 0; const installing = new Map(), inventory = new Map(), units = new Map(); let installs = 0, maintenanceIO = Promise.resolve(); const memberName = /^[1-9][0-9]*\.[a-f0-9]{64}\.json$/; function absent(error) { return error && (error.code === 'ENOENT' || /no such file|not exist/i.test(error.errMsg || error.message || '')); } function directoryNames(path) { try { return perf.measure('cache.listDirectory', () => fs.readdirSync(path)); } catch (error) { if (!absent(error)) throw error; return []; } } function addUnit(unit) { units.set(unit.path, unit); const names = inventory.get(unit.group); for (const name of Object.keys(unit.files)) { if (!memberName.test(name)) continue; if (!names.has(name)) names.set(name, new Set()); names.get(name).add(unit); } } function forgetUnit(unit) { units.delete(unit.path); const names = inventory.get(unit.group); for (const name of Object.keys(unit.files)) { const owners = names.get(name); if (owners) { owners.delete(unit); if (!owners.size) names.delete(name); } } } function cacheNames(group) { if (inventory.has(group)) return inventory.get(group); const result = new Map(); inventory.set(group, result); for (const name of directoryNames(root + '/files/' + group)) if (memberName.test(name) || /^[1-9][0-9]*\.[a-f0-9]{64}\.json\.[0-9]+\.tmp$/.test(name)) addUnit({ group, path: root + '/files/' + group + '/' + name, files: { [name]: null }, used: 0, packed: false }); for (const name of directoryNames(root + '/packs/' + group)) { if (!/^[a-f0-9]{64}-[a-z0-9-]+$/.test(name)) continue; const path = root + '/packs/' + group + '/' + name; try { // One small index per installed directory, never a scan/read of every level JSON. const meta = JSON.parse(fs.readFileSync(path + '/cache-index.json', 'utf8')); v.check(meta && meta.files && Object.keys(meta.files).length > 0 && Object.keys(meta.files).every(n => memberName.test(n) && v.integer(meta.files[n], 1)), '缓存包索引无效'); addUnit({ group, path, files: meta.files, used: Number(meta.used) || 0, packed: true }); } catch (error) { console.warn('[LevelResources] 缓存包索引不可用', error.message || error); // Unusable catalogs never become readable entries; reclaim the orphan directory later. addUnit({ group, path, files: {}, used: 0, packed: true, orphan: true }); } } return result; } function cachedPath(unit, name) { return unit.packed ? unit.path + '/levels/' + name : unit.path; } function preferredUnit(group, name) { const owners = cacheNames(group).get(name); if (!owners) return null; return Array.from(owners).find(u => !u.packed) || Array.from(owners).sort((a, b) => b.used - a.used)[0]; } async function installTask(task) { // Stop new cleanup calls immediately; only an already-issued FS operation may finish. installs++; try { await maintenanceIO; return await task(); } finally { installs--; } } const networkQueue = []; async function network(task) { const queuedAt = Date.now(); if (networkCount >= 3) await new Promise(resolve => networkQueue.push(resolve)); else networkCount++; perf.metric('network.queueWait', Date.now() - queuedAt); try { return await task(); } finally { const next = networkQueue.shift(); if (next) next(); else networkCount--; } } function checkBytes(data, e) { v.check(data.byteLength === e.size && perf.measure('download.sha256', () => sha256(new Uint8Array(data)), data.byteLength) === e.hash, '下载文件hash不匹配'); } async function download(e, target) { const started = Date.now(); let status = 'error'; try { await network(() => perf.measureAsync('network.download', () => new Promise((resolve, reject) => wx.downloadFile({ url: baseUrl + e.file, filePath: target, timeout: 15000, success: r => r.statusCode === 200 ? resolve(r) : reject(Error('关卡下载HTTP ' + r.statusCode)), fail: reject })))); const bytes = await read(target); checkBytes(bytes, e); status = 'ok'; return bytes; } finally { perf.metric('download.total', Date.now() - started, e.size, status !== 'ok'); if (e.file.endsWith('.zip') || status !== 'ok' || Date.now() - started >= 1000) perf.emit('download', { file: e.file, bytes: e.size, ms: Date.now() - started, status, active: networkCount, queued: networkQueue.length }); } } function archiveNames(data) { const bytes = new Uint8Array(data), view = new DataView(data), end = bytes.length - 22, names = new Set(); v.check(end >= 0 && view.getUint32(end, true) === 0x06054b50 && view.getUint16(end + 20, true) === 0, 'ZIP格式无效'); const count = view.getUint16(end + 10, true); let pos = view.getUint32(end + 16, true), localEnd = 0; const directory = pos; for (let i = 0; i < count; i++) { v.check(pos + 46 <= end && view.getUint32(pos, true) === 0x02014b50, 'ZIP索引无效'); const n = view.getUint16(pos + 28, true), extra = view.getUint16(pos + 30, true), comment = view.getUint16(pos + 32, true); const name = decode(bytes.slice(pos + 46, pos + 46 + n).buffer), local = view.getUint32(pos + 42, true); v.check((name === 'pack-manifest.json' || /^levels\/[1-9][0-9]*\.[a-f0-9]{64}\.json$/.test(name)) && !names.has(name), 'ZIP路径无效'); v.check(local === localEnd && local + 30 <= directory && view.getUint32(local, true) === 0x04034b50 && view.getUint16(local + 26, true) === n && view.getUint16(local + 6, true) === 0x800 && view.getUint16(local + 8, true) === 8 && decode(bytes.slice(local + 30, local + 30 + n).buffer) === name, 'ZIP成员头无效'); localEnd = local + 30 + n + view.getUint16(local + 28, true) + view.getUint32(local + 18, true); v.check(localEnd <= directory, 'ZIP成员越界'); names.add(name); pos += 46 + n + extra + comment; } v.check(pos === end && localEnd === directory, 'ZIP长度无效'); return names; } const file = (g, id, h) => root + '/files/' + g + '/' + id + '.' + h + '.json'; const state = key => root + '/state/' + encodeURIComponent(key) + '.json'; const io = { perf, now: () => Date.now(), decode, warn: error => console.warn('[LevelResources]', error.message || error), readLocal: read, hasCache: (g, id, h) => cacheNames(g).has(id + '.' + h + '.json'), listCache: () => ['A', 'B'].reduce((files, g) => files.concat(Array.from(cacheNames(g).keys(), name => g + '/' + name)), []), async readCache(g, id, h) { const name = id + '.' + h + '.json', unit = preferredUnit(g, name); if (!unit) throw Error('缓存文件不存在'); try { const data = await read(cachedPath(unit, name)); unit.used = Date.now(); return data; } catch (error) { if (absent(error)) { const owners = cacheNames(g).get(name); if (owners) { owners.delete(unit); if (!owners.size) cacheNames(g).delete(name); } if (preferredUnit(g, name)) return io.readCache(g, id, h); } throw error; } }, readState(key) { try { return JSON.parse(fs.readFileSync(state(key), 'utf8')); } catch (_) { return null; } }, writeState(key, value) { mkdir(root + '/state'); const target = state(key), temp = target + '.tmp'; const text = perf.measure('state.stringify', () => JSON.stringify(value)); perf.measure('fs.writeStateSync', () => fs.writeFileSync(temp, text, 'utf8'), text.length); perf.measure('fs.renameStateSync', () => fs.renameSync(temp, target)); }, writeCache(g, id, h, bytes) { const target = file(g, id, h); if (installing.has(target)) return installing.get(target); const task = installTask(async () => { const names = cacheNames(g), name = id + '.' + h + '.json'; if (names.has(name)) { try { const previous = await io.readCache(g, id, h); if (perf.measure('cache.existingSHA', () => sha256(new Uint8Array(previous)), previous.byteLength) === h) return; } catch (_) { /* A missing/broken package member is repaired by an independent override. */ } } mkdir(root + '/files/' + g); const temp = target + '.' + (++sequence) + '.tmp'; let installed = false; try { await call('writeFile', { filePath: temp, data: bytes }); await call('rename', { oldPath: temp, newPath: target }); installed = true; if (units.has(target)) forgetUnit(units.get(target)); addUnit({ group: g, path: target, files: { [name]: bytes.byteLength }, used: Date.now(), packed: false }); } finally { if (!installed) await remove(temp); } }).finally(() => installing.delete(target)); installing.set(target, task); return task; }, fetchManifest() { // Manifest has its own request slot: bulk prefetch must not consume the login budget. return perf.measureAsync('network.manifest', () => new Promise((resolve, reject) => wx.request({ url: baseUrl + 'manifest.json?_t=' + Date.now(), timeout: 8000, dataType: 'text', responseType: 'text', header: { 'Cache-Control': 'no-cache' }, success: r => { try { v.check(r.statusCode === 200, '关卡清单HTTP ' + r.statusCode); // Parse in the game context; do not repeatedly traverse a bridge-owned response object. const text = typeof r.data === 'string' ? r.data : JSON.stringify(r.data); resolve(perf.measure('manifest.parseLocal', () => JSON.parse(text), text.length)); } catch (e) { reject(e); } }, fail: reject }))); }, async fetchFile(e) { const dir = root + '/staging/' + Date.now().toString(36) + '-' + (++sequence); mkdir(dir); try { return await download(e, dir + '/level.json'); } finally { await discard(dir); } }, async fetchPack(m, g, e, checkpoint = () => Promise.resolve()) { const finish = perf.begin('zip.fetchVerifyExtract', { group: g, start: e.start, end: e.end, bytes: e.size }); let status = 'error', handedOff = false, disposed = false, committed = false; const dir = root + '/staging/' + Date.now().toString(36) + '-' + (++sequence); installs++; try { await maintenanceIO; mkdir(dir + '/unpacked'); const bytes = await download(e, dir + '/pack.zip'); await checkpoint(); const names = perf.measure('zip.headers', () => archiveNames(bytes), bytes.byteLength); const expected = new Set(['pack-manifest.json']); for (let id = e.start; id <= e.end; id++) expected.add('levels/' + id + '.' + m.groups[g].levels[id].hash + '.json'); v.check(names.size === expected.size && Array.from(names).every(name => expected.has(name)), 'ZIP文件集合不符'); await call('unzip', { zipFilePath: dir + '/pack.zip', targetPath: dir + '/unpacked' }); await checkpoint(); // A single directory listing catches incomplete native extraction without reading members. const files = {}; for (let id = e.start; id <= e.end; id++) { const entry = m.groups[g].levels[id]; files[id + '.' + entry.hash + '.json'] = entry.size; } const extracted = directoryNames(dir + '/unpacked/levels'); v.check(extracted.length === Object.keys(files).length && extracted.every(n => Object.prototype.hasOwnProperty.call(files, n)), 'ZIP解压文件集合不符'); const used = Date.now(); await call('writeFile', { filePath: dir + '/unpacked/cache-index.json', data: JSON.stringify({ files, used }), encoding: 'utf8' }); handedOff = true; status = 'ok'; return { async install() { v.check(!disposed, 'ZIP安装已取消'); if (committed) return; return installTask(async () => { cacheNames(g); mkdir(root + '/packs/' + g); const target = root + '/packs/' + g + '/' + e.hash + '-' + dir.slice(dir.lastIndexOf('/') + 1); await call('rename', { oldPath: dir + '/unpacked', newPath: target }); // Index lives inside the directory, so restart recovery also sees a complete commit. addUnit({ group: g, path: target, files, used, packed: true }); committed = true; perf.emit('zip.directoryInstalled', { group: g, members: Object.keys(files).length, moves: 1 }); }); }, async dispose() { if (disposed) return; disposed = true; try { await discard(dir); } finally { installs--; } } }; } finally { if (!handedOff) { try { await discard(dir); } finally { installs--; } } finish({ status }); } }, async collect(keep, protect, budget, control = {}) { const started = Date.now(); let deleted = 0; const checkpoint = control.checkpoint || (() => Promise.resolve()); const protectedNow = key => protect.has(key) || (control.protect && control.protect(key)); const keptNow = key => keep.has(key) || (control.keep && control.keep(key)); const skip = () => { perf.emit('cache.maintenanceSkipped', { reason: 'installing' }); return false; }; if (installs) return skip(); ['A', 'B'].forEach(cacheNames); const entries = Array.from(units.values()); // Sizes come from the verified manifest/catalog. Old flat files can use the old logical index. let unknown = false; for (const unit of entries) { if (unit.orphan) unknown = true; for (const name of Object.keys(unit.files)) { if (unit.files[name] == null && control.size) unit.files[name] = control.size(unit.group + '/' + name); if (unit.files[name] == null) unknown = true; } } const unitSize = unit => Object.values(unit.files).reduce((sum, size) => sum + (size || 0), 0); let size = entries.reduce((sum, unit) => sum + unitSize(unit), 0); if (!unknown && size < budget * 0.8) { perf.emit('cache.maintenanceSkipped', { reason: 'belowBudget', retainedBytes: size, budget, units: entries.length }); return true; } for (const unit of entries) { await checkpoint(); if (installs) return skip(); if (!unit.packed && (size >= budget * 0.8 || Object.values(unit.files).some(n => n == null))) { // Only near-budget maintenance (or unknown legacy sizes) needs physical file stats. try { const pending = call('stat', { path: unit.path }); maintenanceIO = pending.catch(() => {}); const s = (await pending).stats; const name = Object.keys(unit.files)[0]; size += s.size - unitSize(unit); unit.files[name] = s.size; unit.used = unit.used || Number(s.lastModifiedTime) || 0; } catch (error) { if (absent(error)) { size -= unitSize(unit); forgetUnit(unit); } } } } entries.sort((a, b) => a.used - b.used); for (const unit of entries) { await checkpoint(); if (installs) return skip(); if (!units.has(unit.path)) continue; const keys = Object.keys(unit.files).map(name => unit.group + '/' + name); // Whole-pool and hundred-level packages may overlap. Retain at least one owner // of every live key, while independent repair overrides always keep priority. const redundant = unit.packed && keys.every(key => { if (!keptNow(key) && !protectedNow(key)) return true; const owners = cacheNames(unit.group).get(key.slice(2)); return owners && Array.from(owners).some(owner => owner !== unit); }); if (!redundant && keys.some(protectedNow)) continue; if (!redundant && size <= budget && keys.some(keptNow)) continue; try { const pending = unit.packed ? call('rmdir', { dirPath: unit.path, recursive: true }) : call('unlink', { filePath: unit.path }); maintenanceIO = pending.catch(() => {}); await pending; size -= unitSize(unit); forgetUnit(unit); deleted++; } catch (error) { if (absent(error)) { size -= unitSize(unit); forgetUnit(unit); } } } perf.emit('cache.collect', { ms: Date.now() - started, units: entries.length, deleted, retainedBytes: size, budget, protected: protect.size }); // Protected complete pools may exceed this soft budget. Real ENOSPC is retried, never a smaller random pool. } }; const runtime = create(builtin, io, sha256, Object.assign({ environment }, config)); wx.onHide(() => { perf.setActive(false); runtime.setActive(false); }); wx.onShow(() => { perf.setActive(true); runtime.setActive(true); }); if (wx.onNetworkStatusChange) wx.onNetworkStatusChange(r => runtime.setOnline(r.isConnected)); if (wx.getNetworkType) wx.getNetworkType({ success: r => runtime.setOnline(r.networkType !== 'none') }); window.__levelResources = runtime;