import { promises as fs } from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; import { spawn } from "node:child_process"; import { createHash } from "node:crypto"; import { normalizeOptions, artifactStem } from "./options.mjs"; const root = path.dirname(fileURLToPath(import.meta.url)); const exists = (p) => fs.access(p).then( () => true, () => false, ); const exe = process.platform === "win32" ? ".exe" : ""; export function toolchain() { const javaHome = process.env.JAVA_HOME; return { sdk: process.env.ANDROID_HOME || process.env.ANDROID_SDK_ROOT || path.join(root, ".toolchains", "android-sdk"), java: javaHome ? path.join(javaHome, "bin", "java" + exe) : "java", javac: javaHome ? path.join(javaHome, "bin", "javac" + exe) : "javac", gradle: process.env.FORMA_GRADLE || path.join( root, ".toolchains", "gradle-8.13", "bin", process.platform === "win32" ? "gradle.bat" : "gradle", ), }; } async function commandWorks(command, args) { return new Promise((resolve) => { const p = spawn(command, args, { stdio: "ignore", windowsHide: true }); const timer = setTimeout(() => { p.kill(); resolve(false); }, 12000); p.on("error", () => { clearTimeout(timer); resolve(false); }); p.on("exit", (code) => { clearTimeout(timer); resolve(code === 0); }); }); } export async function doctor() { const t = toolchain(); const [desktop, java, javac, gradle, sdk] = await Promise.all([ exists(path.join(root, "node_modules", "electron-builder", "cli.js")), commandWorks(t.java, ["-version"]), commandWorks(t.javac, ["-version"]), exists(t.gradle), exists(path.join(t.sdk, "platforms", "android-36", "android.jar")), ]); const buildTools = await exists( path.join( t.sdk, "build-tools", "35.0.0", "apksigner" + (process.platform === "win32" ? ".bat" : ""), ), ); const signing = [ "FORMA_KEYSTORE", "FORMA_KEYSTORE_PASSWORD", "FORMA_KEY_ALIAS", "FORMA_KEY_PASSWORD", ].every((n) => Boolean(process.env[n])); return { host: process.platform, arch: process.arch, targets: { linux: { ready: desktop && process.platform === "linux", missing: [ ...(!desktop ? ["Run npm ci --prefix native"] : []), ...(process.platform !== "linux" ? ["AppImage needs a Linux build host"] : []), ], }, windows: { ready: desktop, missing: desktop ? [] : ["Run npm ci --prefix native"], }, android: { ready: process.platform !== "win32" && java && javac && gradle && sdk && buildTools, missing: [ ...(process.platform === "win32" ? ["Android builds currently require a Linux or macOS build host"] : []), ...(!java || !javac ? ["Install JDK 17 and set JAVA_HOME"] : []), ...(!gradle || !sdk || !buildTools ? [ "Run node native/setup-android.mjs; configure Android SDK licenses", ] : []), ], releaseSigningConfigured: signing, }, }, }; } function run(command, args, options = {}) { return new Promise((resolve, reject) => { const p = spawn(command, args, { stdio: "inherit", windowsHide: true, ...options, }); p.on("error", reject); p.on("exit", (code, signal) => code === 0 ? resolve() : reject( Error( "Build command failed: " + path.basename(command) + " (" + (signal || code) + ")", ), ), ); }); } async function copyTree(from, to) { await fs.mkdir(to, { recursive: true }); for (const entry of await fs.readdir(from, { withFileTypes: true })) { if (entry.isSymbolicLink()) throw Error("Symlinks are not allowed in game assets"); const dst = path.join(to, entry.name), src = path.join(from, entry.name); if (entry.isDirectory()) await copyTree(src, dst); else if (entry.isFile()) await fs.copyFile(src, dst); } } export async function buildGame({ game, out, options }) { const o = normalizeOptions(options), info = await doctor(); if (!info.targets[o.target].ready) throw Error(info.targets[o.target].missing.join("; ")); if ( o.target === "android" && o.mode === "release" && !info.targets.android.releaseSigningConfigured ) throw Error( "Release APK needs FORMA_KEYSTORE, FORMA_KEYSTORE_PASSWORD, FORMA_KEY_ALIAS and FORMA_KEY_PASSWORD. Use debug for device testing.", ); game = await fs.realpath(path.resolve(game)); out = path.resolve(out); if ( out === game || out.startsWith(game + path.sep) || game.startsWith(out + path.sep) ) throw Error("Game and output directories must not contain each other"); for (const n of ["index.html", "player.js", "project.forma.json"]) if (!(await exists(path.join(game, n)))) throw Error("Missing game file: " + n); await fs.mkdir(out, { recursive: true }); out = await fs.realpath(out); if ( out === game || out.startsWith(game + path.sep) || game.startsWith(out + path.sep) ) throw Error("Game and output directories must not contain each other"); // An output directory is immutable: never overwrite a previous signed build. if ((await fs.readdir(out)).length) throw Error("Output directory must be empty; choose a new --out"); const work = path.join(out, "work"); const artifacts = path.join(out, "artifacts"); await fs.mkdir(artifacts, { recursive: true }); if (o.target === "android") { const t = toolchain(); await copyTree(path.join(root, "android"), work); await copyTree( game, path.join(work, "app", "src", "main", "assets", "game"), ); await fs.writeFile( path.join(work, "game-config.json"), JSON.stringify(o, null, 2), ); // SDK environment is inherited by Gradle; signing secrets never enter project files. const proxy = process.env.HTTPS_PROXY || process.env.https_proxy; const proxyArgs = proxy ? (() => { const u = new URL(proxy); return [ "-Dhttps.proxyHost=" + u.hostname, "-Dhttps.proxyPort=" + (u.port || "80"), "-Dhttp.proxyHost=" + u.hostname, "-Dhttp.proxyPort=" + (u.port || "80"), ]; })() : []; await run( t.gradle, [ "--no-daemon", "--console=plain", ...proxyArgs, ":app:assemble" + (o.mode === "release" ? "Release" : "Debug"), ], { cwd: work, env: { ...process.env, ANDROID_HOME: t.sdk, ANDROID_SDK_ROOT: t.sdk, ...(process.env.FORMA_KEYSTORE ? { FORMA_KEYSTORE: path.resolve(process.env.FORMA_KEYSTORE) } : {}), }, }, ); const apk = path.join( work, "app", "build", "outputs", "apk", o.mode, "app-" + o.mode + ".apk", ); const signer = path.join( t.sdk, "build-tools", "35.0.0", "apksigner" + (process.platform === "win32" ? ".bat" : ""), ); await run(signer, ["verify", "--verbose", apk]); await fs.copyFile( apk, path.join(artifacts, artifactStem(o) + "-android-" + o.mode + ".apk"), ); } else { const appDir = path.join(work, "app"); await copyTree(path.join(root, "desktop"), appDir); await copyTree(game, path.join(appDir, "game")); await fs.writeFile( path.join(appDir, "game-config.json"), JSON.stringify(o, null, 2), ); await fs.writeFile( path.join(appDir, "package.json"), JSON.stringify({ name: o.appId.replaceAll(".", "-"), productName: o.name, version: o.version, description: o.name + " — built with Forma Engine", author: "Forma game creator", desktopName: o.appId + ".desktop", license: "UNLICENSED", main: "main.cjs", private: true, }), ); const config = { appId: o.appId, productName: o.name, electronVersion: "44.3.0", asar: true, npmRebuild: false, forceCodeSigning: false, afterSign: path.join(root, "cleanup.cjs"), directories: { app: appDir, output: artifacts }, files: ["**/*"], artifactName: artifactStem(o) + "-${os}-${arch}.${ext}", toolsets: { appimage: "1.0.3" }, linux: { target: ["AppImage"], category: "Game", syncDesktopName: true, icon: path.join(root, "desktop", "icon.png"), executableName: o.appId.split(".").at(-1), }, win: { target: ["portable"], icon: path.join(root, "desktop", "icon.ico"), signExecutable: false, requestedExecutionLevel: "asInvoker", }, portable: { requestExecutionLevel: "user" }, }; const configFile = path.join(work, "builder.json"); await fs.writeFile(configFile, JSON.stringify(config, null, 2)); await run( process.execPath, [ path.join(root, "node_modules", "electron-builder", "cli.js"), "--config", configFile, o.target === "linux" ? "--linux" : "--win", o.target === "linux" ? "AppImage" : "portable", "--x64", "--publish", "never", ], { cwd: root, env: { ...process.env, CSC_IDENTITY_AUTO_DISCOVERY: "false", ELECTRON_BUILDER_COMPRESSION_LEVEL: process.env.ELECTRON_BUILDER_COMPRESSION_LEVEL || "3", }, }, ); } const files = []; for (const n of await fs.readdir(artifacts)) if (/\.(AppImage|exe|apk)$/.test(n)) { const bytes = await fs.readFile(path.join(artifacts, n)); files.push({ name: n, bytes: bytes.length, sha256: createHash("sha256").update(bytes).digest("hex"), }); } if (!files.length) throw Error("Builder exited without producing an application"); const manifest = { format: "forma-build", formatVersion: 1, engineVersion: "0.3.0", createdAt: new Date().toISOString(), options: o, host: { platform: process.platform, arch: process.arch }, signing: o.target === "android" ? o.mode === "debug" ? "android-debug" : "android-release" : o.target === "windows" ? "unsigned" : "not-applicable", files, }; await fs.writeFile( path.join(out, "build-manifest.json"), JSON.stringify(manifest, null, 2), ); // Keep only the final package and manifest. Temporary app trees duplicate hundreds of MB. await fs.rm(work, { recursive: true, force: true }); console.log( JSON.stringify({ result: "succeeded", manifest: path.join(out, "build-manifest.json"), files, }), ); return manifest; } if ( process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url) ) { const arg = (n) => { const i = process.argv.indexOf(n); return i < 0 ? undefined : process.argv[i + 1]; }; try { if (process.argv.includes("--doctor")) console.log(JSON.stringify(await doctor(), null, 2)); else { const config = arg("--config") || path.join(root, "build-config.json"); const options = JSON.parse(await fs.readFile(config, "utf8")); await buildGame({ game: arg("--game") || path.join(root, "game"), out: arg("--out") || path.join(root, "output", Date.now().toString()), options, }); } } catch (e) { console.error(String(e)); process.exitCode = 1; } }