#!/usr/bin/env node import { spawnSync } from 'node:child_process'; import { createHash } from 'node:crypto'; import { createReadStream, existsSync, readFileSync, statSync, writeFileSync, } from 'node:fs'; import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path'; import { fileURLToPath } from 'node:url'; const root = resolve(dirname(fileURLToPath(import.meta.url)), '..'); const packageJson = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8')); const args = process.argv.slice(2); const valueOptions = new Set([ '--app-exe', '--installer', '--expected-electron', '--expected-node', '--expected-commit', '--expected-build-id', '--manifest', ]); const flagOptions = new Set(['--allow-dirty']); const seenOptions = new Set(); for (let index = 0; index < args.length; index += 1) { const arg = args[index]; if (arg === '--') continue; if (!valueOptions.has(arg) && !flagOptions.has(arg)) { throw new Error(`Unknown option: ${arg}`); } if (seenOptions.has(arg)) { throw new Error(`Duplicate option: ${arg}`); } seenOptions.add(arg); if (valueOptions.has(arg)) { const value = args[index + 1]; if (!value || value.startsWith('--')) throw new Error(`${arg} requires a value`); index += 1; } } function option(name, fallback) { const index = args.indexOf(name); if (index === -1) return fallback; if (!args[index + 1]) throw new Error(`${name} requires a value`); return args[index + 1]; } function run(command, commandArgs, options = {}) { const result = spawnSync(command, commandArgs, { cwd: root, encoding: 'utf8', ...options, }); if (result.error) throw result.error; if (result.signal) throw new Error(`${command} ended by signal ${result.signal}`); if (result.status !== 0) { throw new Error(`${command} exited ${result.status}: ${result.stderr || result.stdout}`); } return result.stdout.trim(); } function sha256(filePath) { return new Promise((resolveHash, reject) => { const hash = createHash('sha256'); const stream = createReadStream(filePath); stream.on('error', reject); stream.on('data', (chunk) => hash.update(chunk)); stream.on('end', () => resolveHash(hash.digest('hex').toUpperCase())); }); } function assertEqual(actual, expected, label) { if (JSON.stringify(actual) !== JSON.stringify(expected)) { throw new Error(`${label}: expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`); } } function isPathInside(parentPath, candidatePath) { const relativePath = relative(resolve(parentPath), resolve(candidatePath)); return relativePath !== '' && relativePath !== '..' && !relativePath.startsWith(`..${sep}`) && !isAbsolute(relativePath); } if (process.platform !== 'win32') { throw new Error('Packaged Electron verification must run on Windows'); } const appExecutable = resolve(option( '--app-exe', join(root, 'release', 'win-unpacked', 'Makelore.exe'), )); const installerPath = resolve(option( '--installer', join(root, 'release', `Makelore-${packageJson.version}-win-x64.exe`), )); const expectedElectron = option('--expected-electron', '40.10.6'); const expectedNode = option('--expected-node', '24.15.0'); const expectedCommit = option('--expected-commit', null); const expectedBuildId = option('--expected-build-id', null); const manifestPath = option('--manifest', null); const appAsarPath = join(dirname(appExecutable), 'resources', 'app.asar'); const appAsarUnpackedPath = join(dirname(appExecutable), 'resources', 'app.asar.unpacked'); for (const filePath of [appExecutable, installerPath]) { if (!existsSync(filePath)) throw new Error(`Missing artifact: ${filePath}`); } const verificationHead = run('git', ['rev-parse', 'HEAD']); const gitStatus = run('git', ['status', '--porcelain']); if (gitStatus && !args.includes('--allow-dirty')) { throw new Error(`Tracked worktree is not clean:\n${gitStatus}`); } const probeCode = String.raw` const { cpSync, existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } = require('node:fs'); const { createRequire } = require('node:module'); const { tmpdir } = require('node:os'); const { dirname, join, sep } = require('node:path'); const appRequire = createRequire(join(dirname(process.execPath), 'resources', 'app.asar', 'package.json')); const packagedPackage = appRequire('./package.json'); const msgpackResolved = appRequire.resolve('msgpackr'); const canvasResolved = appRequire.resolve('@napi-rs/canvas'); const { pack, unpack } = appRequire('msgpackr'); const { createCanvas } = appRequire('@napi-rs/canvas'); const packedValue = unpack(pack({ ok: true, text: '\u5362\u6b22' })); const canvas = createCanvas(1, 1); const nativeModules = Object.keys(require.cache) .filter((modulePath) => modulePath.endsWith('.node')) .map((modulePath) => { const physicalPath = modulePath.replace( sep + 'app.asar' + sep, sep + 'app.asar.unpacked' + sep, ); return { modulePath, physicalPath, exists: existsSync(physicalPath) }; }); const copyRoot = mkdtempSync(join(tmpdir(), 'niancode-packaged-copy-')); let copyEvidence; try { const source = join(copyRoot, 'source'); const destination = join(copyRoot, '\u5362\u6b22', 'AppData', 'Roaming', 'niancode', 'nested'); mkdirSync(join(source, 'child'), { recursive: true }); writeFileSync(join(source, 'child', 'proof.txt'), 'unicode-copy-proof', 'utf8'); cpSync(source, destination, { recursive: true, force: true, filter: () => true }); copyEvidence = { content: readFileSync(join(destination, 'child', 'proof.txt'), 'utf8'), rootEntries: readdirSync(copyRoot).sort(), }; } finally { rmSync(copyRoot, { recursive: true, force: true, maxRetries: 3, retryDelay: 50 }); } console.log('NIANCODE_ARTIFACT_PROBE=' + JSON.stringify({ electron: process.versions.electron, node: process.versions.node, packagedMetadata: { buildCommit: packagedPackage.buildCommit, buildId: packagedPackage.buildId, }, msgpackResolved, canvasResolved, nativeModules, packedValue, canvas: [canvas.width, canvas.height], copyEvidence, })); `; const probeResult = spawnSync(appExecutable, ['-e', probeCode], { cwd: dirname(appExecutable), encoding: 'utf8', env: { ...process.env, ELECTRON_RUN_AS_NODE: '1' }, }); if (probeResult.error) throw probeResult.error; if (probeResult.signal) throw new Error(`Makelore.exe ended by signal ${probeResult.signal}`); if (probeResult.status !== 0) { throw new Error(`Makelore.exe exited ${probeResult.status}: ${probeResult.stderr || probeResult.stdout}`); } const probeLine = probeResult.stdout .split(/\r?\n/) .find((line) => line.startsWith('NIANCODE_ARTIFACT_PROBE=')); if (!probeLine) throw new Error(`Missing packaged probe output: ${probeResult.stdout}`); const probe = JSON.parse(probeLine.slice('NIANCODE_ARTIFACT_PROBE='.length)); assertEqual(probe.electron, expectedElectron, 'Electron version'); assertEqual(probe.node, expectedNode, 'Node.js version'); if (expectedCommit) { assertEqual(probe.packagedMetadata.buildCommit, expectedCommit, 'packaged build commit'); } if (expectedBuildId) { assertEqual(probe.packagedMetadata.buildId, expectedBuildId, 'packaged build id'); } assertEqual(probe.packedValue, { ok: true, text: '\u5362\u6b22' }, 'msgpackr round trip'); assertEqual(probe.canvas, [1, 1], 'canvas dimensions'); assertEqual(probe.copyEvidence, { content: 'unicode-copy-proof', rootEntries: ['source', '\u5362\u6b22'], }, 'packaged Unicode copy'); if (!isPathInside(appAsarPath, probe.msgpackResolved)) { throw new Error(`msgpackr was not resolved from app.asar: ${probe.msgpackResolved}`); } if (!isPathInside(appAsarPath, probe.canvasResolved)) { throw new Error(`@napi-rs/canvas was not resolved from app.asar: ${probe.canvasResolved}`); } const msgpackNative = probe.nativeModules.find(({ modulePath, physicalPath, exists }) => ( exists && isPathInside(appAsarPath, modulePath) && isPathInside(appAsarUnpackedPath, physicalPath) && modulePath.includes('@msgpackr-extract') && modulePath.endsWith('node.napi.node') )); if (!msgpackNative) { throw new Error(`Packaged msgpackr N-API addon was not loaded: ${JSON.stringify(probe.nativeModules)}`); } const canvasNative = probe.nativeModules.find(({ modulePath, physicalPath, exists }) => ( exists && isPathInside(appAsarPath, modulePath) && isPathInside(appAsarUnpackedPath, physicalPath) && modulePath.includes('@napi-rs') && modulePath.includes('canvas-win32-x64-msvc') && modulePath.endsWith('skia.win32-x64-msvc.node') )); if (!canvasNative) { throw new Error(`Packaged x64 canvas addon was not loaded: ${JSON.stringify(probe.nativeModules)}`); } const installerBytes = statSync(installerPath).size; if (installerBytes <= 0) throw new Error(`Installer is empty: ${installerPath}`); const installerSha256 = await sha256(installerPath); if (!/^[A-F0-9]{64}$/.test(installerSha256)) { throw new Error(`Invalid installer SHA-256: ${installerSha256}`); } const evidence = { gitCommit: probe.packagedMetadata.buildCommit ?? verificationHead, verificationHead, packagedMetadata: probe.packagedMetadata, appExecutable, installerPath, installerBytes, installerSha256, runtime: { electron: probe.electron, node: probe.node }, nativeModules: { msgpackr: probe.msgpackResolved, canvas: probe.canvasResolved, msgpackAddon: msgpackNative, canvasAddon: canvasNative, }, unicodeCopy: probe.copyEvidence, }; if (manifestPath) { writeFileSync(resolve(manifestPath), `${JSON.stringify(evidence, null, 2)}\n`, 'utf8'); } console.log(JSON.stringify(evidence, null, 2));