Files
MatchMaster/packages/activity-bundles/runtime.js
T
2026-10-13 12:55:12 +08:00

191 lines
12 KiB
JavaScript

'use strict';
const config = require('./activity-bundles-config'), sha256 = require('./activity-bundles-sha256');
const fs = wx.getFileSystemManager(), manager = cc.assetManager, engineLoad = manager.loadBundle;
const baseUrl = config.baseUrl.replace(/\/+$/, '') + '/';
const serverKey = sha256(new Uint8Array(encodeURIComponent(baseUrl).split('').map(c => c.charCodeAt(0))));
const root = wx.env.USER_DATA_PATH + '/activity-bundles/' + serverKey;
const tasks = new Map(), serial = new Map(), loaded = new Map(), holds = new Map();
let cycle = 0, manifestTask = null, sequence = 0;
const digest = value => typeof value === 'string' && /^[0-9a-f]{64}$/.test(value);
const safePath = value => typeof value === 'string' && /^[a-zA-Z0-9_./+@=-]+$/.test(value)
&& !value.split('/').some(p => !p || p === '.' || p === '..');
const call = (method, options) => new Promise((resolve, reject) => fs[method](Object.assign({}, options, { success: resolve, fail: reject })));
const key = name => 'activity-bundle:v1:' + baseUrl + ':' + name;
function cached(name) { try { return wx.getStorageSync(key(name)) || null; } catch (_) { return null; } }
function owned(dir, name, hash) {
const prefix = root + '/' + name + '/' + hash + '/';
return typeof dir === 'string' && dir.startsWith(prefix) && /^[a-z0-9-]+\/[a-zA-Z0-9_]+$/.test(dir.slice(prefix.length)) && dir.endsWith('/' + name);
}
async function discard(dir) {
// Only our unique staging directory, never a whole resource server or another bundle.
if (!dir.startsWith(root + '/') || dir.slice(root.length + 1).split('/').length !== 3 || dir.includes('..')) return;
try { await call('rmdir', { dirPath: dir, recursive: true }); } catch (_) {}
}
function validate(data) {
if (!data || data.schemaVersion !== 1 || !data.bundles || typeof data.bundles !== 'object' || Array.isArray(data.bundles)) throw Error('活动资源清单无效');
for (const name of Object.keys(data.bundles)) {
const entry = data.bundles[name];
if (!/^[a-z][a-zA-Z0-9_]*$/.test(name) || !entry || !digest(entry.hash)
|| entry.file !== name + '/' + name + '.' + entry.hash + '.zip' || !Number.isSafeInteger(entry.size) || entry.size <= 0)
throw Error('活动资源清单条目无效:' + name);
}
return data;
}
function validateMetadata(entry, name) {
if (!entry || entry.schemaVersion !== 1 || entry.name !== name || typeof entry.version !== 'string'
|| !/^[a-zA-Z0-9_-]*$/.test(entry.version) || entry.config !== 'config.' + (entry.version ? entry.version + '.' : '') + 'json'
|| !Array.isArray(entry.files) || !entry.files.length) throw Error('活动 ZIP 内部清单无效:' + name);
const paths = new Set();
for (const file of entry.files) {
if (!file || !safePath(file.path) || file.path === 'bundle-manifest.json' || /\.(?:js|jsc)$/i.test(file.path) || paths.has(file.path)
|| !Number.isSafeInteger(file.size) || file.size < 0 || !digest(file.sha256)) throw Error('活动资源文件清单无效');
paths.add(file.path);
}
if (!paths.has(entry.config)) throw Error('活动资源清单缺少 Bundle 配置');
return entry;
}
function manifest() {
if (!manifestTask) manifestTask = new Promise((resolve, reject) => {
wx.request({ url: baseUrl + 'manifest.json?_t=' + Date.now() + '-' + cycle, timeout: 8000,
header: { 'Cache-Control': 'no-cache' },
success: response => {
try {
if (response.statusCode !== 200) throw Error('活动资源清单请求失败:' + response.statusCode);
resolve(validate(typeof response.data === 'string' ? JSON.parse(response.data) : response.data));
} catch (error) { reject(error); }
}, fail: reject });
});
return manifestTask;
}
async function complete(dir, name, entry) {
// Hash every declared file as well as checking presence/size, including same-size corruption.
for (let i = 0; i < entry.files.length; i += 8) await Promise.all(entry.files.slice(i, i + 8).map(async file => {
const { data } = await call('readFile', { filePath: dir + '/' + file.path });
if (data.byteLength !== file.size || sha256(data) !== file.sha256) throw Error('活动资源文件损坏:' + file.path);
}));
const { data } = await call('readFile', { filePath: dir + '/' + entry.config, encoding: 'utf8' });
const bundle = JSON.parse(data);
if (!bundle || bundle.name !== name || bundle.isZip || bundle.importBase !== 'import' || bundle.nativeBase !== 'native')
throw Error('活动 Bundle 配置无效');
}
async function readMetadata(dir, name, expectedHash) {
const filePath = dir + '/bundle-manifest.json';
const { data: bytes } = await call('readFile', { filePath });
const metadataHash = sha256(bytes);
if (expectedHash && metadataHash !== expectedHash) throw Error('活动 ZIP 内部清单损坏');
const { data } = await call('readFile', { filePath, encoding: 'utf8' });
return { metadata: validateMetadata(JSON.parse(data), name), metadataHash };
}
// Validate archive names before native extraction; the manifest cannot redirect file writes.
function checkArchive(bytes) {
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength), end = bytes.length - 22;
if (end < 0 || view.getUint32(end, true) !== 0x06054b50 || view.getUint16(end + 20, true) !== 0) throw Error('活动 ZIP 格式无效');
const count = view.getUint16(end + 10, true), seen = new Set(); let pos = view.getUint32(end + 16, true);
for (let i = 0; i < count; i++) {
if (pos + 46 > end || view.getUint32(pos, true) !== 0x02014b50) throw Error('活动 ZIP 索引无效');
const size = view.getUint16(pos + 28, true), extra = view.getUint16(pos + 30, true), comment = view.getUint16(pos + 32, true);
const name = String.fromCharCode.apply(null, bytes.subarray(pos + 46, pos + 46 + size));
if (!safePath(name) || seen.has(name) || /\.(?:js|jsc)$/i.test(name)) throw Error('活动 ZIP 路径无效');
const local = view.getUint32(pos + 42, true);
if (local + 30 > pos || view.getUint32(local, true) !== 0x04034b50 || view.getUint16(local + 26, true) !== size
|| String.fromCharCode.apply(null, bytes.subarray(local + 30, local + 30 + size)) !== name) throw Error('活动 ZIP 文件头无效');
seen.add(name); pos += 46 + size + extra + comment;
}
if (pos !== end) throw Error('活动 ZIP 索引长度无效');
if (!seen.has('bundle-manifest.json')) throw Error('活动 ZIP 缺少内部清单');
return seen;
}
async function directory(name, entry) {
const previous = cached(name);
if (previous && digest(previous.hash) && (!entry || previous.hash === entry.hash)
&& digest(previous.metadataHash) && owned(previous.dir, name, previous.hash)) {
try {
const { metadata } = await readMetadata(previous.dir, name, previous.metadataHash);
await complete(previous.dir, name, metadata); return { dir: previous.dir, metadata };
} catch (error) { if (!entry) throw error; /* Repair only this bundle when the manifest is available. */ }
}
// Without a manifest, only verified disk cache is usable; never guess a download URL.
if (!entry) throw Error('活动资源清单不可用且无有效缓存:' + name);
const stage = root + '/' + name + '/' + entry.hash + '/' + Date.now().toString(36) + '-' + (++sequence);
const dir = stage + '/' + name, zipFile = stage + '/archive.zip';
try {
await call('mkdir', { dirPath: dir, recursive: true });
await new Promise((resolve, reject) => wx.downloadFile({ url: baseUrl + entry.file, filePath: zipFile, timeout: 60000,
success: response => response.statusCode === 200 ? resolve(response) : reject(Error('活动分包下载失败:' + response.statusCode)), fail: reject }));
const { data } = await call('readFile', { filePath: zipFile });
if (data.byteLength !== entry.size || sha256(data) !== entry.hash) throw Error('活动分包 SHA-256 校验失败');
const archivePaths = checkArchive(new Uint8Array(data));
// Create parents explicitly instead of relying on native unzip to create them.
const directories = new Set();
for (const file of archivePaths) {
const index = file.lastIndexOf('/');
if (index > 0) directories.add(file.slice(0, index));
}
for (const relative of directories)
await call('mkdir', { dirPath: dir + '/' + relative, recursive: true });
await call('unzip', { zipFilePath: zipFile, targetPath: dir });
const { metadata, metadataHash } = await readMetadata(dir, name);
if (archivePaths.size !== metadata.files.length + 1 || metadata.files.some(file => !archivePaths.has(file.path)))
throw Error('活动 ZIP 文件与内部清单不符');
await complete(dir, name, metadata);
// The old pointer remains usable until all verification has succeeded.
wx.setStorageSync(key(name), { hash: entry.hash, dir, metadataHash });
try { await call('unlink', { filePath: zipFile }); } catch (_) {}
return { dir, metadata };
} catch (error) { await discard(stage); throw error; }
}
async function activate(name, entry, dir) {
const previous = loaded.get(name);
if (previous && (previous.dir === dir || holds.get(name))) return previous.bundle;
const old = manager.getBundle(name);
// Preserve the previous Bundle object until the replacement config actually loads.
if (old) manager.bundles.remove(name);
let bundle;
try { bundle = await new Promise((resolve, reject) => engineLoad.call(manager, dir, { version: entry.version }, (error, result) => error ? reject(error) : resolve(result))); }
catch (error) { if (old) manager.bundles.add(name, old); throw error; }
if (old && old !== bundle) old.releaseAll();
loaded.set(name, { bundle, dir });
if (previous && previous.dir !== dir && previous.dir.startsWith(root + '/'))
await discard(previous.dir.slice(0, previous.dir.lastIndexOf('/')));
return bundle;
}
function ensureBundle(name) {
if (!config.bundles.includes(name)) return Promise.reject(Error('活动尚未接入公共分包:' + name));
if (tasks.has(name)) return tasks.get(name);
const selectedCycle = cycle, selectedManifest = manifest().catch(() => null), preceding = serial.get(name);
const task = (async () => {
// Different lifecycle cycles for the same bundle cannot replace each other's pointer out of order.
const [data] = await Promise.all([selectedManifest, preceding && preceding.catch(() => {})]);
if (selectedCycle !== cycle) throw Error('活动资源检查周期已更新');
const entry = data && data.bundles[name];
if (data && !entry) throw Error('公共清单未配置活动资源:' + name);
const { dir, metadata } = await directory(name, entry);
if (selectedCycle !== cycle) throw Error('活动资源检查周期已更新');
return activate(name, metadata, dir);
})();
tasks.set(name, task); serial.set(name, task);
// Keep failures for this lifecycle too: repeated render/preload calls must not create a download loop.
task.catch(() => {});
return task;
}
window.__activityBundles = {
beginCycle() { cycle++; manifestTask = null; tasks.clear(); },
ensureBundle,
async acquireBundle(name) {
const bundle = await ensureBundle(name); holds.set(name, (holds.get(name) || 0) + 1);
let released = false;
return { bundle, release() {
if (released) return; released = true;
const count = (holds.get(name) || 1) - 1; holds.set(name, count);
const disk = cached(name), active = loaded.get(name);
if (!count && (!disk || !active || disk.dir !== active.dir)) tasks.delete(name);
} };
}
};
// Preserve loadBundle(name, [options], callback) for existing activities and dependencies.
manager.loadBundle = function (name, options, done) {
if (!config.bundles.includes(name)) return engineLoad.apply(this, arguments);
if (typeof options === 'function') done = options;
ensureBundle(name).then(bundle => done && done(null, bundle), error => done && done(error));
};