'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))); }