Files
VioletTides--KnockThru/scripts/package.mjs
T
Robin Anderson 28c22a1b93 Number the first public release 1.0.0
The version defaults in CMakeLists.txt, build-arm64.sh, and package.mjs
say 1.0.0, and so do the README's manual install and build examples.
The README's update GIF goes from 1.0.0 to 1.1.0, not 0.1.0 to 0.2.0.
2026-10-16 13:59:30 -04:00

204 lines
10 KiB
JavaScript

#!/usr/bin/env node
// Builds the release ZIP and FrameDrop manifest. Never runs the binary.
// Adapted from KominoVR/frame-passthrough-shortcuts (MIT).
import fs from 'node:fs';
import path from 'node:path';
import { createHash } from 'node:crypto';
import { deflateRawSync } from 'node:zlib';
import { fileURLToPath, pathToFileURL } from 'node:url';
const projectRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const appName = 'knockthru';
const controlName = 'knockthru-ctl';
const displayName = 'KnockThru';
function crc32(bytes) {
let crc = 0xffffffff;
for (const byte of bytes) {
crc ^= byte;
for (let bit = 0; bit < 8; bit++) crc = (crc >>> 1) ^ (0xedb88320 & -(crc & 1));
}
return (crc ^ 0xffffffff) >>> 0;
}
// Small ZIP32 writer with explicit Unix modes, so Linux executable bits survive
// cross-platform packaging. All file names are relative, UTF-8, and use '/'.
export function createZip(entries) {
if (entries.length > 65535) throw new Error('Too many ZIP entries');
const local = [], central = [];
let offset = 0;
for (const entry of entries) {
if (entry.name.startsWith('/') || entry.name.includes('\\') || entry.name.split('/').includes('..')) {
throw new Error(`Unsafe ZIP path: ${entry.name}`);
}
const name = Buffer.from(entry.name, 'utf8');
const data = Buffer.from(entry.data);
const compressed = deflateRawSync(data, { level: 9 });
if (data.length > 0xffffffff || offset > 0xffffffff) throw new Error('ZIP64 is not supported');
const checksum = crc32(data);
const header = Buffer.alloc(30);
header.writeUInt32LE(0x04034b50, 0);
header.writeUInt16LE(20, 4);
header.writeUInt16LE(0x800, 6);
header.writeUInt16LE(8, 8);
header.writeUInt16LE(33, 12); // 1980-01-01, for reproducible archives.
header.writeUInt32LE(checksum, 14);
header.writeUInt32LE(compressed.length, 18);
header.writeUInt32LE(data.length, 22);
header.writeUInt16LE(name.length, 26);
local.push(header, name, compressed);
const directory = Buffer.alloc(46);
directory.writeUInt32LE(0x02014b50, 0);
directory.writeUInt16LE(0x314, 4); // Unix, ZIP 2.0.
directory.writeUInt16LE(20, 6);
directory.writeUInt16LE(0x800, 8);
directory.writeUInt16LE(8, 10);
directory.writeUInt16LE(33, 14);
directory.writeUInt32LE(checksum, 16);
directory.writeUInt32LE(compressed.length, 20);
directory.writeUInt32LE(data.length, 24);
directory.writeUInt16LE(name.length, 28);
directory.writeUInt32LE(((0o100000 | entry.mode) << 16) >>> 0, 38);
directory.writeUInt32LE(offset, 42);
central.push(directory, name);
offset += header.length + name.length + compressed.length;
}
const directory = Buffer.concat(central);
const end = Buffer.alloc(22);
end.writeUInt32LE(0x06054b50, 0);
end.writeUInt16LE(entries.length, 8);
end.writeUInt16LE(entries.length, 10);
end.writeUInt32LE(directory.length, 12);
end.writeUInt32LE(offset, 16);
return Buffer.concat([...local, directory, end]);
}
export function verifyBinary(bytes, platform) {
if (platform === 'linux-arm64') {
if (bytes.length < 64 || bytes.subarray(0, 4).toString('hex') !== '7f454c46' ||
bytes[4] !== 2 || bytes[5] !== 1 || bytes.readUInt16LE(18) !== 183) {
throw new Error('linux-arm64 requires a 64-bit little-endian AArch64 ELF executable');
}
if (![2, 3].includes(bytes.readUInt16LE(16))) throw new Error('ELF file is not executable');
} else throw new Error(`Unknown platform: ${platform}`);
}
export function publicBaseUrl(value) {
const url = new URL(value);
if (url.protocol !== 'https:' || url.username || url.password || url.search || url.hash) {
throw new Error('--base-url must be an HTTPS directory URL without credentials, a query, or a fragment');
}
// FrameDrop disallows LAN addresses and credentials. Reject literal IPs and
// local host names rather than emitting a link its public installer rejects.
if (url.hostname === 'localhost' || url.hostname.endsWith('.localhost') ||
!url.hostname.includes('.') || /^\d+(\.\d+){3}$/.test(url.hostname) || url.hostname.includes(':') ||
url.hostname.endsWith('.local')) throw new Error('--base-url must use a public host name');
url.pathname = url.pathname.replace(/\/?$/, '/');
return url;
}
function collectFiles(directory, prefix, entries) {
for (const item of fs.readdirSync(directory, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
if (item.isSymbolicLink()) throw new Error(`Symlinks are not allowed in the bundle: ${item.name}`);
const source = path.join(directory, item.name);
const name = prefix ? `${prefix}/${item.name}` : item.name;
if (item.isDirectory()) collectFiles(source, name, entries);
else if (item.isFile()) entries.push({ name, data: fs.readFileSync(source), mode: 0o644 });
}
}
export function packageRelease(options) {
const root = path.resolve(options.root ?? projectRoot);
const version = options.version ?? '1.0.0';
if (!/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(version)) throw new Error('Version must be semver, e.g. 1.0.0');
const platform = options.platform ?? 'linux-arm64';
const binaryName = appName;
const binaryPath = path.resolve(options.buildDir ?? path.join(root, 'build-arm64'), 'bin', binaryName);
const binary = fs.readFileSync(binaryPath);
verifyBinary(binary, platform);
const entries = [{ name: binaryName, data: binary, mode: 0o755 }];
if (platform === 'linux-arm64') {
const controlPath = path.join(path.dirname(binaryPath), controlName);
if (!fs.existsSync(controlPath) || !fs.lstatSync(controlPath).isFile()) {
throw new Error(`Linux release requires a regular management companion: ${controlName}`);
}
const control = fs.readFileSync(controlPath);
verifyBinary(control, platform);
if (!control.equals(binary)) {
throw new Error(`${controlName} must match the main executable exactly; rebuild with scripts/build-arm64.sh`);
}
entries.push({ name: controlName, data: control, mode: 0o755 });
}
for (const filename of ['config.json', 'actions.json']) {
JSON.parse(fs.readFileSync(path.join(root, 'assets', filename), 'utf8'));
}
collectFiles(path.join(root, 'assets'), '', entries);
for (const filename of ['README.md', 'LICENSE', 'third_party/openvr/LICENSE', 'third_party/json/LICENSE.MIT', 'third_party/vulkan/MIT.txt']) {
entries.push({ name: filename, data: fs.readFileSync(path.join(root, filename)), mode: 0o644 });
}
for (const filename of ['THIRD_PARTY_NOTICES.md', 'images/framedrop-button.svg']) {
const source = path.join(root, filename);
if (fs.existsSync(source)) entries.push({ name: filename, data: fs.readFileSync(source), mode: 0o644 });
}
const compilerNotices = path.join(root, 'third_party/toolchain-licenses');
if (fs.existsSync(compilerNotices)) collectFiles(compilerNotices, 'third_party/toolchain-licenses', entries);
const names = new Set();
for (const entry of entries) {
if (names.has(entry.name)) throw new Error(`Duplicate archive entry: ${entry.name}`);
names.add(entry.name);
}
const zipName = `${appName}-${version}-${platform}.zip`;
const outputDir = path.resolve(options.outputDir ?? path.join(root, 'dist'));
const base = options.baseUrl ? publicBaseUrl(options.baseUrl) : undefined;
if (options.fileUrl && !base) throw new Error('--file-url requires --base-url');
const zip = createZip(entries);
const sha256 = createHash('sha256').update(zip).digest('hex');
fs.mkdirSync(outputDir, { recursive: true });
fs.writeFileSync(path.join(outputDir, zipName), zip);
fs.writeFileSync(path.join(outputDir, `${zipName}.sha256`), `${sha256} ${zipName}\n`);
const result = { archive: path.join(outputDir, zipName), sha256 };
if (base && platform === 'linux-arm64') {
const manifestName = `${appName}.framedrop.json`;
const fileUrl = options.fileUrl ? new URL(options.fileUrl) : new URL(zipName, base);
if (fileUrl.protocol !== 'https:' || fileUrl.username || fileUrl.password || fileUrl.search || fileUrl.hash ||
!fileUrl.pathname.endsWith(`/${zipName}`)) {
throw new Error('--file-url must be an HTTPS URL ending in the release ZIP filename');
}
const manifest = { schema: 'framedrop.install/v1', name: displayName, files: [{ url: fileUrl.href, sha256 }] };
fs.writeFileSync(path.join(outputDir, manifestName), JSON.stringify(manifest, null, 2) + '\n');
const manifestUrl = options.installManifestUrl ?? latestManifestUrl(base, manifestName);
const installUrl = 'https://framedropvr.com/install?manifest=' + encodeURIComponent(manifestUrl);
result.manifest = path.join(outputDir, manifestName);
result.installUrl = installUrl;
}
return result;
}
function latestManifestUrl(base, manifestName) {
const match = base.pathname.match(/^\/([^/]+)\/([^/]+)\/releases\/download\/[^/]+\/$/);
if (base.hostname === 'github.com' && match) {
return new URL(`/${match[1]}/${match[2]}/releases/latest/download/${manifestName}`, base.origin).href;
}
return new URL(manifestName, base).href;
}
function main(args) {
if (args.includes('--help')) {
console.log('node scripts/package.mjs --build-dir build-arm64 --version 1.0.0 --platform linux-arm64 [--output-dir dist] [--base-url https://host/releases/v1.0.0/] [--file-url https://host/download/knockthru-1.0.0-linux-arm64.zip] [--install-manifest-url https://host/releases/latest/download/knockthru.framedrop.json]');
return;
}
const mapping = { '--build-dir': 'buildDir', '--version': 'version', '--platform': 'platform', '--output-dir': 'outputDir', '--base-url': 'baseUrl', '--file-url': 'fileUrl', '--install-manifest-url': 'installManifestUrl' };
const options = {};
for (let i = 0; i < args.length; i += 2) {
if (!mapping[args[i]] || !args[i + 1] || args[i + 1].startsWith('--')) throw new Error(`Unknown option or missing value: ${args[i]}`);
options[mapping[args[i]]] = args[i + 1];
}
console.log(JSON.stringify(packageRelease(options), null, 2));
}
if (process.argv[1] && import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href) {
try { main(process.argv.slice(2)); }
catch (error) { console.error(`Packaging failed: ${error.message}`); process.exitCode = 1; }
}