394 lines
12 KiB
JavaScript
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;
|
|
}
|
|
}
|