303 lines
18 KiB
JavaScript
303 lines
18 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(), retiredDirs = new Map();
|
|
const groups = new Map(), membership = new Map();
|
|
for (const id of Object.keys(config.groups || {})) {
|
|
const group = { id, names: config.groups[id], count: 0, cycle: -1, task: null, signature: null };
|
|
groups.set(id, group);
|
|
group.names.forEach(name => membership.set(name, group));
|
|
}
|
|
let cycle = 0, manifestTask = null, sequence = 0;
|
|
// Snapshot diagnostics so the developer tools console preserves the failure context.
|
|
function trace(event, detail, error) {
|
|
const record = Object.assign({ cycle }, detail);
|
|
if (error) record.error = { message: error.message || String(error), errMsg: error.errMsg, stack: error.stack };
|
|
console[error ? 'error' : 'log']('[ActivityBundles] ' + event + ' ' + JSON.stringify(record));
|
|
}
|
|
trace('init', { diagnostics: 'load-debug-v1', baseUrl, bundles: config.bundles, groups: config.groups });
|
|
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) => {
|
|
const url = baseUrl + 'manifest.json?_t=' + Date.now() + '-' + cycle;
|
|
trace('manifest.request', { url });
|
|
wx.request({ url, timeout: 8000,
|
|
header: { 'Cache-Control': 'no-cache' },
|
|
success: response => {
|
|
try {
|
|
if (response.statusCode !== 200) throw Error('活动资源清单请求失败:' + response.statusCode);
|
|
const data = validate(typeof response.data === 'string' ? JSON.parse(response.data) : response.data);
|
|
trace('manifest.ready', { url, statusCode: response.statusCode, bundles: Object.keys(data.bundles) });
|
|
resolve(data);
|
|
} catch (error) { trace('manifest.failed', { url, statusCode: response.statusCode }, error); reject(error); }
|
|
}, fail: error => { trace('manifest.failed', { url }, error); reject(error); } });
|
|
});
|
|
return manifestTask;
|
|
}
|
|
function retainGroup(id) {
|
|
const group = groups.get(id);
|
|
if (!group) throw Error('活动资源组未配置:' + id);
|
|
if (!group.count && group.cycle !== cycle) {
|
|
group.task = null;
|
|
group.names.forEach(name => tasks.delete(name));
|
|
}
|
|
group.count++;
|
|
let released = false;
|
|
return () => { if (!released) { released = true; group.count--; } };
|
|
}
|
|
function selectGroup(group) {
|
|
if (!group.task) {
|
|
group.cycle = cycle;
|
|
const storageKey = 'activity-group:v1:' + baseUrl + ':' + group.id;
|
|
group.task = manifest().then(data => ({ data, offline: false }), () => {
|
|
// An offline group must match one published snapshot, never a mixture of cached releases.
|
|
const data = validate(wx.getStorageSync(storageKey));
|
|
return { data, offline: true };
|
|
}).then(selection => {
|
|
group.names.forEach(name => {
|
|
if (!selection.data.bundles[name]) throw Error('公共清单未配置活动资源:' + name);
|
|
});
|
|
if (!selection.offline) wx.setStorageSync(storageKey, selection.data);
|
|
return selection;
|
|
});
|
|
}
|
|
return group.task;
|
|
}
|
|
function switchGroup(group, data) {
|
|
const signature = group.names.map(name => data.bundles[name].hash).join(':');
|
|
if (group.signature !== null && group.signature !== signature) {
|
|
// Configured order releases dependent pages before shared art. A retained group
|
|
// keeps its selected manifest, so this is only reached after every old user exits.
|
|
group.names.forEach(name => {
|
|
const previous = loaded.get(name);
|
|
if (previous) {
|
|
previous.bundle.releaseAll(); manager.bundles.remove(name); loaded.delete(name);
|
|
retiredDirs.set(name, previous.dir);
|
|
}
|
|
});
|
|
}
|
|
group.signature = signature;
|
|
}
|
|
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, cacheOnly = false) {
|
|
const previous = cached(name);
|
|
trace('cache.check', { name, cacheOnly, hasCache: !!previous, cachedHash: previous && previous.hash, expectedHash: entry && entry.hash });
|
|
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);
|
|
trace('cache.hit', { name, dir: previous.dir });
|
|
return { dir: previous.dir, metadata };
|
|
} catch (error) {
|
|
trace('cache.invalid', { name, dir: previous.dir, willRepair: !!entry && !cacheOnly }, error);
|
|
if (!entry || cacheOnly) 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 || cacheOnly) throw Error('活动资源清单不可用且无有效缓存:' + name);
|
|
const stage = root + '/' + name + '/' + entry.hash + '/' + Date.now().toString(36) + '-' + (++sequence);
|
|
const dir = stage + '/' + name, zipFile = stage + '/archive.zip';
|
|
const url = baseUrl + entry.file;
|
|
let phase = 'mkdir';
|
|
try {
|
|
await call('mkdir', { dirPath: dir, recursive: true });
|
|
phase = 'download';
|
|
trace('zip.download', { name, url, expectedSize: entry.size, expectedHash: entry.hash });
|
|
await new Promise((resolve, reject) => wx.downloadFile({ url, filePath: zipFile, timeout: 60000,
|
|
success: response => {
|
|
trace('zip.response', { name, url, statusCode: response.statusCode });
|
|
return response.statusCode === 200 ? resolve(response) : reject(Error('活动分包下载失败:' + response.statusCode));
|
|
}, fail: reject }));
|
|
phase = 'read-zip';
|
|
const { data } = await call('readFile', { filePath: zipFile });
|
|
phase = 'verify-zip';
|
|
const actualHash = sha256(data);
|
|
trace('zip.verify', { name, actualSize: data.byteLength, expectedSize: entry.size, actualHash, expectedHash: entry.hash });
|
|
if (data.byteLength !== entry.size || actualHash !== entry.hash) throw Error('活动分包 SHA-256 校验失败');
|
|
phase = 'archive-index';
|
|
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));
|
|
}
|
|
phase = 'mkdir-entries';
|
|
for (const relative of directories)
|
|
await call('mkdir', { dirPath: dir + '/' + relative, recursive: true });
|
|
phase = 'unzip';
|
|
trace('zip.unzip', { name, zipFile, dir });
|
|
await call('unzip', { zipFilePath: zipFile, targetPath: dir });
|
|
phase = 'metadata';
|
|
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 文件与内部清单不符');
|
|
phase = 'verify-files';
|
|
await complete(dir, name, metadata);
|
|
// The old pointer remains usable until all verification has succeeded.
|
|
phase = 'save-cache';
|
|
wx.setStorageSync(key(name), { hash: entry.hash, dir, metadataHash });
|
|
try { await call('unlink', { filePath: zipFile }); } catch (_) {}
|
|
trace('directory.ready', { name, dir, files: metadata.files.length, version: metadata.version });
|
|
return { dir, metadata };
|
|
} catch (error) { trace('directory.failed', { name, phase, url, dir }, error); await discard(stage); throw error; }
|
|
}
|
|
async function activate(name, entry, dir) {
|
|
const previous = loaded.get(name);
|
|
const previousDir = previous ? previous.dir : retiredDirs.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;
|
|
trace('engine.load', { name, dir, version: entry.version });
|
|
try { bundle = await new Promise((resolve, reject) => engineLoad.call(manager, dir, { version: entry.version }, (error, result) => error ? reject(error) : resolve(result))); }
|
|
catch (error) { trace('engine.failed', { name, dir, version: entry.version }, error); if (old) manager.bundles.add(name, old); throw error; }
|
|
trace('engine.ready', { name, dir });
|
|
if (old && old !== bundle) old.releaseAll();
|
|
loaded.set(name, { bundle, dir });
|
|
retiredDirs.delete(name);
|
|
if (previousDir && previousDir !== dir && previousDir.startsWith(root + '/'))
|
|
await discard(previousDir.slice(0, previousDir.lastIndexOf('/')));
|
|
return bundle;
|
|
}
|
|
function ensureBundle(name) {
|
|
trace('bundle.request', { name, baseUrl, configured: config.bundles.includes(name), reusedTask: tasks.has(name) });
|
|
if (!config.bundles.includes(name)) {
|
|
const error = Error('活动尚未接入公共分包:' + name);
|
|
trace('bundle.failed', { name, configuredBundles: config.bundles }, error);
|
|
return Promise.reject(error);
|
|
}
|
|
const group = membership.get(name), releaseGroup = group && retainGroup(group.id);
|
|
if (tasks.has(name)) {
|
|
const task = tasks.get(name);
|
|
return releaseGroup ? task.finally(releaseGroup) : task;
|
|
}
|
|
const selectedCycle = cycle, selectedManifest = group ? selectGroup(group) : manifest().then(data => ({ data, offline: false }), () => ({ data: null, offline: true }));
|
|
const 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, offline }] = await Promise.all([selectedManifest, preceding && preceding.catch(() => {})]);
|
|
if (!group && selectedCycle !== cycle) throw Error('活动资源检查周期已更新');
|
|
const entry = data && data.bundles[name];
|
|
trace('bundle.selection', { name, selectedCycle, offline, availableBundles: data && Object.keys(data.bundles), entry });
|
|
if (data && !entry) throw Error('公共清单未配置活动资源:' + name);
|
|
const { dir, metadata } = await directory(name, entry, offline);
|
|
if (!group && selectedCycle !== cycle) throw Error('活动资源检查周期已更新');
|
|
if (group) switchGroup(group, data);
|
|
return activate(name, metadata, dir);
|
|
})().finally(() => { if (releaseGroup) releaseGroup(); });
|
|
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(error => trace('bundle.failed', { name, selectedCycle, baseUrl }, error));
|
|
return task;
|
|
}
|
|
window.__activityBundles = {
|
|
beginCycle() { cycle++; manifestTask = null; tasks.clear(); trace('cycle.begin', {}); },
|
|
ensureBundle,
|
|
retainGroup,
|
|
async prepareGroup(id) {
|
|
const release = retainGroup(id);
|
|
try { await selectGroup(groups.get(id)); } finally { release(); }
|
|
},
|
|
async acquireBundle(name) {
|
|
const group = membership.get(name), releaseGroup = group && retainGroup(group.id);
|
|
let bundle;
|
|
try { bundle = await ensureBundle(name); }
|
|
catch (error) { if (releaseGroup) releaseGroup(); throw error; }
|
|
holds.set(name, (holds.get(name) || 0) + 1);
|
|
let released = false;
|
|
return { bundle, release() {
|
|
if (released) return; released = true;
|
|
if (releaseGroup) releaseGroup();
|
|
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));
|
|
};
|