324 lines
20 KiB
JavaScript
324 lines
20 KiB
JavaScript
'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;
|