Files

143 lines
11 KiB
JavaScript

'use strict';
const fs = require('fs'), path = require('path'), crypto = require('crypto'), vm = require('vm');
const zip = require('../activity-bundles/zip'), v = require('./validation');
const project = path.resolve(__dirname, '../..');
const json = data => Buffer.from(JSON.stringify(data));
const hash = bytes => crypto.createHash('sha256').update(bytes).digest('hex');
function write(file, bytes) {
fs.mkdirSync(path.dirname(file), { recursive: true });
fs.writeFileSync(file + '.tmp', bytes); fs.renameSync(file + '.tmp', file);
}
function immutable(file, bytes) {
if (fs.existsSync(file)) v.check(fs.readFileSync(file).equals(bytes), '不可变资源发生冲突:' + file);
else write(file, bytes);
}
function policy() { return JSON.parse(fs.readFileSync(path.join(__dirname, 'publication.json'), 'utf8')); }
function reconcileSubpackages(root, settings, game) {
// A project game.json template can omit bundles added in Creator later.
// Preserve platform-only entries and custom roots, but declare every engine subpackage.
const entries = (game.subpackages || []).filter(p => p.name !== 'custom');
for (const name of settings.subpackages || []) {
if (name === 'custom' || entries.some(p => p.name === name)) continue;
v.check(/^[a-zA-Z0-9_-]+$/.test(name), '分包名称无效:' + name);
const folder = 'subpackages/' + name;
v.check(fs.existsSync(path.join(root, folder, 'game.js')), '缺少微信分包入口:' + folder + '/game.js');
entries.push({ name, root: folder });
}
game.subpackages = entries;
}
function publish(source, destination, config = policy(), options = {}) {
const environment = options.test ? 'test' : 'production';
const countB = options.simulateB === undefined ? config.bResourceCount : options.simulateB;
v.check(v.integer(config.contentVersion, 1) && v.integer(config.aResourceCount, 100)
&& config.aResourceCount <= 60000 && v.integer(countB) && countB <= config.aResourceCount
&& v.integer(config.builtinBCount), '发布配置无效');
v.check(options.simulateB === undefined || options.test, '模拟B只允许用于测试产物');
let previous = null;
try { previous = v.manifest(JSON.parse(fs.readFileSync(path.join(destination, 'manifest.json'), 'utf8')), config.contentVersion, environment); } catch (_) {}
const m = { schemaVersion: 1, contentVersion: config.contentVersion, environment, release: 'pending', simulatedB: options.simulateB !== undefined,
groups: { A: { resourceCount: config.aResourceCount, levels: {}, packs: {} }, B: { resourceCount: countB, levels: {}, packs: {} } } };
const b = { schemaVersion: 1, contentVersion: config.contentVersion, simulatedB: m.simulatedB, groups: { A: {}, B: {} } };
const contents = {}, report = { environment, changedLevels: [], changedPacks: [] };
// Validate every source before writing the mutable manifest. Never fill missing real B with A.
for (const g of ['A', 'B']) for (let id = 1; id <= m.groups[g].resourceCount; id++) {
const dir = g === 'B' && !m.simulatedB ? 'JsonB' : 'Json';
const data = v.level(JSON.parse(fs.readFileSync(path.join(source, dir, 'level' + id + '.json'), 'utf8')));
const bytes = json(data), e = { hash: hash(bytes), size: bytes.length };
m.groups[g].levels[id] = e; contents[g + '/' + id] = bytes;
if (!previous || !previous.groups[g].levels[id] || previous.groups[g].levels[id].hash !== e.hash) report.changedLevels.push(g + '/' + id);
if (id <= (g === 'A' ? 100 : config.builtinBCount)) b.groups[g][id] = Object.assign({ file: 'levels-local/' + g + '/' + id + '.' + e.hash + '.json' }, e);
}
for (const g of ['A', 'B']) {
for (const id of Object.keys(m.groups[g].levels)) immutable(path.join(destination, v.levelFile(g, id, m.groups[g].levels[id])), contents[g + '/' + id]);
const ranges = [];
for (let start = 1; start <= m.groups[g].resourceCount; start += 100) ranges.push([String(start), start, Math.min(start + 99, m.groups[g].resourceCount)]);
if (g === 'A') ranges.push(['all', 1, m.groups.A.resourceCount]);
for (const [key, start, end] of ranges) {
const index = { group: g, start, end, levels: {} }, files = [];
for (let id = start; id <= end; id++) {
const e = m.groups[g].levels[id]; index.levels[id] = e;
files.push({ name: 'levels/' + id + '.' + e.hash + '.json', data: contents[g + '/' + id] });
}
const indexBytes = json(index); files.push({ name: 'pack-manifest.json', data: indexBytes });
const bytes = zip(files), e = { hash: hash(bytes), size: bytes.length, start, end, indexHash: hash(indexBytes) };
m.groups[g].packs[key] = e; immutable(path.join(destination, v.packFile(g, key, e)), bytes);
if (!previous || !previous.groups[g].packs[key] || previous.groups[g].packs[key].hash !== e.hash) report.changedPacks.push(g + '/' + key);
}
}
m.release = 'r' + hash(json(m)).slice(0, 32); b.release = m.release;
v.manifest(m, config.contentVersion, environment); v.builtin(b);
immutable(path.join(destination, 'manifests/' + m.release + '.json'), json(m));
report.release = m.release;
report.order = ['上传不可变JSON及ZIP并校验可访问', '最后更新manifest.json', '再调整login按版本返回的maxLevel/bLevelCount'];
write(path.join(destination, 'publication-report.json'), JSON.stringify(report, null, 2));
write(path.join(destination, 'manifest.json'), json(m));
return { manifest: m, builtin: b, contents, report };
}
function pack(destination, options = {}) {
const root = path.resolve(destination), ctx = { window: {} }, config = policy();
vm.runInNewContext(fs.readFileSync(path.join(root, 'src/settings.js'), 'utf8'), ctx);
const settings = ctx.window._CCSettings, server = String(settings.server || '').replace(/\/+$/, '');
v.check(/^https:\/\/[^\s?#]+$/.test(server), '微信构建须配置HTTPS资源服务器');
v.check((settings.remoteBundles || []).includes('custom'), '请将custom设为微信远程Bundle后完整构建');
const namespace = options.test ? 'levels-test' : 'levels';
const publishDir = path.join(path.dirname(root), path.basename(root) + '-levels', namespace, 'v' + config.contentVersion);
const result = publish(path.join(project, 'assets/custom'), publishDir, config, options);
const localDir = path.resolve(root, 'levels-local');
v.check(localDir.startsWith(root + path.sep), '内置产物路径越界');
fs.rmSync(localDir, { recursive: true, force: true });
for (const g of ['A', 'B']) for (const id of Object.keys(result.builtin.groups[g])) write(path.join(root, result.builtin.groups[g][id].file), result.contents[g + '/' + id]);
write(path.join(localDir, 'manifest.json'), json(result.builtin));
write(path.join(root, 'level-resources-config.js'), 'module.exports=' + JSON.stringify({
productionUrl: server + '/levels/v' + config.contentVersion + '/', testUrl: server + '/levels-test/v' + config.contentVersion + '/',
defaultMaxLevel: config.defaultMaxLevel, cacheBudgetBytes: config.cacheBudgetBytes
}) + ';\n');
for (const name of ['core', 'validation', 'runtime', 'perf']) {
let text = fs.readFileSync(path.join(__dirname, name + '.js'), 'utf8');
text = text.replace(/require\('\.\/(core|validation|perf)'\)/g, "require('./level-resources-$1')");
write(path.join(root, 'level-resources-' + name + '.js'), text);
}
write(path.join(root, 'level-resources-sha256.js'), fs.readFileSync(path.join(__dirname, '../activity-bundles/sha256.js')));
const gamePath = path.join(root, 'game.js'); let boot = fs.readFileSync(gamePath, 'utf8');
if (!boot.includes("require('./level-resources-runtime');")) {
v.check(boot.includes('window.boot();'), '缺少运行时注入位置');
boot = boot.replace('window.boot();', "require('./level-resources-runtime');\nwindow.boot();");
}
write(gamePath, boot);
settings.subpackages = (settings.subpackages || []).filter(name => name !== 'custom');
write(path.join(root, 'src/settings.js'), 'window._CCSettings=' + JSON.stringify(settings) + ';\n');
const gameFile = path.join(root, 'game.json'), game = JSON.parse(fs.readFileSync(gameFile, 'utf8'));
reconcileSubpackages(root, settings, game);
write(gameFile, JSON.stringify(game, null, 2));
const projectFile = path.join(root, 'project.config.json'), projectConfig = JSON.parse(fs.readFileSync(projectFile, 'utf8'));
projectConfig.packOptions = projectConfig.packOptions || {};
const ignore = projectConfig.packOptions.ignore = projectConfig.packOptions.ignore || [];
for (const value of ['remote', 'subpackages/custom']) if (!ignore.some(e => e.type === 'folder' && e.value === value)) ignore.push({ type: 'folder', value });
write(projectFile, JSON.stringify(projectConfig, null, 2));
return { publishDir, report: result.report };
}
function rollback(destination, release) {
v.check(/^[a-zA-Z0-9_-]+$/.test(release), '回滚版本号无效');
const m = JSON.parse(fs.readFileSync(path.join(destination, 'manifests/' + release + '.json'), 'utf8'));
v.manifest(m, m.contentVersion, m.environment);
for (const g of ['A', 'B']) for (const [kind, file] of [['levels', v.levelFile], ['packs', v.packFile]]) for (const id of Object.keys(m.groups[g][kind])) {
const e = m.groups[g][kind][id], bytes = fs.readFileSync(path.join(destination, file(g, id, e)));
v.check(bytes.length === e.size && hash(bytes) === e.hash, '回滚资源缺失或损坏');
}
// Generate an entry point for review/upload; never overwrite the current local publication.
write(path.join(destination, 'rollback-' + release + '.json'), json(m));
return m;
}
module.exports = { publish, pack, rollback, policy, reconcileSubpackages };
if (require.main === module) {
const args = process.argv.slice(2), test = args.includes('--test'), simulation = args.find(a => a.startsWith('--simulate-b='));
const options = { test }; if (simulation) options.simulateB = Number(simulation.split('=')[1]);
const paths = args.filter(a => !a.startsWith('--'));
if (args.includes('--rollback')) console.log(JSON.stringify({ release: rollback(path.resolve(paths[0]), paths[1]).release }));
else if (args.includes('--publish-only')) {
const config = policy(), dest = path.resolve(paths[0] || ('build/wechatgame-levels/' + (test ? 'levels-test' : 'levels') + '/v' + config.contentVersion));
const report = publish(path.join(project, 'assets/custom'), dest, config, options).report;
console.log(JSON.stringify({ destination: dest, release: report.release, changedLevels: report.changedLevels.length, changedPacks: report.changedPacks, report: path.join(dest, 'publication-report.json') }));
} else console.log(JSON.stringify(pack(paths[0] || 'build/wechatgame', options)));
}