Files
forma-engine/native/build.mjs
T

394 lines
12 KiB
JavaScript

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