Publish Forma Engine 0.3.0 source with documentation and CI

This commit is contained in:
emil28092005
2026-09-09 15:49:08 +03:00
commit e52bc0e33b
70 changed files with 19610 additions and 0 deletions
+33
View File
@@ -0,0 +1,33 @@
plugins { id 'com.android.application' }
def game = new groovy.json.JsonSlurper().parse(rootProject.file('game-config.json'))
def keyPath = System.getenv('FORMA_KEYSTORE')
android {
namespace 'com.forma.shell'
compileSdk 36
buildToolsVersion '35.0.0'
defaultConfig {
applicationId game.appId
minSdk 26
targetSdk 36
versionCode game.versionCode
versionName game.version
resValue 'string', 'app_name', '"' + game.name + '"'
manifestPlaceholders = [gameOrientation: game.orientation == 'landscape' ? 'sensorLandscape' : game.orientation == 'portrait' ? 'sensorPortrait' : 'fullSensor']
}
signingConfigs {
release {
if (keyPath) {
storeFile file(keyPath)
storePassword System.getenv('FORMA_KEYSTORE_PASSWORD')
keyAlias System.getenv('FORMA_KEY_ALIAS')
keyPassword System.getenv('FORMA_KEY_PASSWORD')
}
}
}
buildTypes {
debug { debuggable true }
release { debuggable false; minifyEnabled false; signingConfig signingConfigs.release }
}
compileOptions { sourceCompatibility JavaVersion.VERSION_17; targetCompatibility JavaVersion.VERSION_17 }
}
dependencies { implementation 'androidx.webkit:webkit:1.14.0' }
@@ -0,0 +1,8 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-feature android:glEsVersion="0x00030000" android:required="true" />
<application android:icon="@drawable/forma_icon" android:label="@string/app_name" android:theme="@android:style/Theme.Material.NoActionBar" android:hardwareAccelerated="true" android:allowBackup="false" android:usesCleartextTraffic="false" android:appCategory="game">
<activity android:name=".MainActivity" android:exported="true" android:screenOrientation="${gameOrientation}" android:configChanges="orientation|screenSize|keyboardHidden|smallestScreenSize|screenLayout|uiMode" >
<intent-filter><action android:name="android.intent.action.MAIN" /><category android:name="android.intent.category.LAUNCHER" /></intent-filter>
</activity>
</application>
</manifest>
@@ -0,0 +1,75 @@
package com.forma.shell;
import android.app.Activity;
import android.app.AlertDialog;
import android.os.Build;
import android.os.Bundle;
import android.view.View;
import android.view.WindowInsets;
import android.view.WindowInsetsController;
import android.view.WindowManager;
import android.webkit.*;
import android.window.OnBackInvokedDispatcher;
import androidx.webkit.WebViewAssetLoader;
import java.io.ByteArrayInputStream;
import java.util.HashMap;
public final class MainActivity extends Activity {
private WebView game;
private static final String ENTRY = "https://appassets.androidplatform.net/assets/game/index.html";
private static final String CSP = "default-src 'none'; script-src 'self' 'unsafe-eval'; worker-src 'self' blob:; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; connect-src 'self' data: blob:; font-src 'self' data:; media-src 'self' blob:; object-src 'none'; base-uri 'none'; frame-src 'none'";
@Override public void onCreate(Bundle saved) {
super.onCreate(saved);
getWindow().addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON);
createGame();
if (Build.VERSION.SDK_INT >= 33) getOnBackInvokedDispatcher().registerOnBackInvokedCallback(OnBackInvokedDispatcher.PRIORITY_DEFAULT, this::confirmExit);
}
@SuppressWarnings("SetJavaScriptEnabled") private void createGame() {
game = new WebView(this);
game.setBackgroundColor(0xff151719);
WebSettings s = game.getSettings();
s.setJavaScriptEnabled(true);
s.setDomStorageEnabled(true);
s.setAllowFileAccess(false);
s.setAllowContentAccess(false);
s.setMixedContentMode(WebSettings.MIXED_CONTENT_NEVER_ALLOW);
s.setMediaPlaybackRequiresUserGesture(true);
s.setSupportMultipleWindows(false);
WebView.setWebContentsDebuggingEnabled((getApplicationInfo().flags & android.content.pm.ApplicationInfo.FLAG_DEBUGGABLE) != 0);
final WebViewAssetLoader loader = new WebViewAssetLoader.Builder().addPathHandler("/assets/", new WebViewAssetLoader.AssetsPathHandler(this)).build();
game.setWebViewClient(new WebViewClient() {
@Override public WebResourceResponse shouldInterceptRequest(WebView view, WebResourceRequest request) {
WebResourceResponse response = null;
if ("GET".equals(request.getMethod()) && "https".equals(request.getUrl().getScheme()) && "appassets.androidplatform.net".equals(request.getUrl().getHost()) && request.getUrl().getPath().startsWith("/assets/game/")) response = loader.shouldInterceptRequest(request.getUrl());
if (response == null) return new WebResourceResponse("text/plain", "UTF-8", 404, "Not found", new HashMap<>(), new ByteArrayInputStream(new byte[0]));
HashMap<String,String> headers = new HashMap<>();
headers.put("Content-Security-Policy", CSP);
headers.put("X-Content-Type-Options", "nosniff");
response.setResponseHeaders(headers);
return response;
}
@Override public boolean shouldOverrideUrlLoading(WebView view, WebResourceRequest request) { return !ENTRY.equals(request.getUrl().toString()); }
@Override public boolean onRenderProcessGone(WebView view, RenderProcessGoneDetail detail) {
view.destroy(); game = null;
new AlertDialog.Builder(MainActivity.this).setTitle("Игра остановлена").setMessage("Обновите Android System WebView или уменьшите качество графики в проекте.").setPositiveButton("Перезапустить", (d,w) -> createGame()).setNegativeButton("Выйти", (d,w) -> finish()).setCancelable(false).show();
return true;
}
});
game.setWebChromeClient(new WebChromeClient());
setContentView(game);
game.loadUrl(ENTRY);
immersive();
}
private void immersive() {
if (Build.VERSION.SDK_INT >= 30) {
WindowInsetsController c = getWindow().getInsetsController();
if (c != null) { c.hide(WindowInsets.Type.systemBars()); c.setSystemBarsBehavior(WindowInsetsController.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE); }
} else getWindow().getDecorView().setSystemUiVisibility(View.SYSTEM_UI_FLAG_FULLSCREEN | View.SYSTEM_UI_FLAG_HIDE_NAVIGATION | View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY | View.SYSTEM_UI_FLAG_LAYOUT_STABLE | View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION | View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN);
}
@Override public void onWindowFocusChanged(boolean focused) { super.onWindowFocusChanged(focused); if (focused) immersive(); }
@Override protected void onPause() { if (game != null) {game.onPause();game.pauseTimers();} super.onPause(); }
@Override protected void onResume() { super.onResume(); if (game != null) {game.onResume();game.resumeTimers();} }
@Override public void onBackPressed() { confirmExit(); }
private void confirmExit() { new AlertDialog.Builder(this).setTitle("Выйти из игры?").setPositiveButton("Выйти", (d,w) -> finish()).setNegativeButton("Продолжить", null).show(); }
@Override protected void onDestroy() { if (game != null) game.destroy(); super.onDestroy(); }
}
@@ -0,0 +1,4 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="108dp" android:height="108dp" android:viewportWidth="40" android:viewportHeight="40">
<path android:fillColor="#b77c60" android:pathData="M0,0h40v40h-40z" />
<path android:fillColor="@android:color/transparent" android:strokeColor="#fff5df" android:strokeWidth="1.8" android:strokeLineJoin="round" android:pathData="M20,8 L31,14.5 L31,26.5 L20,33 L9,26.5 L9,14.5 Z M9,14.5 L20,20.5 L31,14.5 M20,20.5 L20,33 M20,8 L20,20" />
</vector>
+1
View File
@@ -0,0 +1 @@
plugins { id 'com.android.application' version '8.13.2' apply false }
+3
View File
@@ -0,0 +1,3 @@
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
android.useAndroidX=true
org.gradle.daemon=false
+4
View File
@@ -0,0 +1,4 @@
pluginManagement { repositories { google(); mavenCentral(); gradlePluginPortal() } }
dependencyResolutionManagement { repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS); repositories { google(); mavenCentral() } }
rootProject.name = 'FormaGame'
include ':app'
+393
View File
@@ -0,0 +1,393 @@
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;
}
}
+19
View File
@@ -0,0 +1,19 @@
const fs = require("node:fs/promises");
const path = require("node:path");
// Resource editing can leave an abandoned atomic-write file next to the EXE.
// Run after resource editing/signing and before NSIS compresses the app directory.
module.exports = async function cleanup(context) {
if (context.electronPlatformName !== "win32") return;
const executable = context.packager.appInfo.productFilename + ".exe";
for (const entry of await fs.readdir(context.appOutDir, { withFileTypes: true })) {
if (
entry.isFile() &&
entry.name !== executable &&
/^\.electron\.exe\.[A-Za-z0-9]{6}$/.test(entry.name)
) {
await fs.unlink(path.join(context.appOutDir, entry.name));
console.log("Removed abandoned Electron packaging file: " + entry.name);
}
}
};
Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

+103
View File
@@ -0,0 +1,103 @@
const {
app,
BrowserWindow,
protocol,
session,
Menu,
dialog,
} = require("electron");
const path = require("node:path");
const { readGame } = require("./protocol.cjs");
const settings = require("./game-config.json");
protocol.registerSchemesAsPrivileged([
{
scheme: "forma",
privileges: {
standard: true,
secure: true,
supportFetchAPI: true,
corsEnabled: true,
stream: true,
},
},
]);
app.setName(settings.name);
let win;
async function createWindow() {
win = new BrowserWindow({
title: settings.name,
width: settings.width,
height: settings.height,
minWidth: 320,
minHeight: 320,
fullscreen: settings.fullscreen,
backgroundColor: "#151719",
show: false,
autoHideMenuBar: true,
webPreferences: {
sandbox: true,
contextIsolation: true,
nodeIntegration: false,
nodeIntegrationInWorker: false,
webSecurity: true,
devTools: settings.mode === "debug",
},
});
win.webContents.setWindowOpenHandler(() => ({ action: "deny" }));
win.webContents.on("will-navigate", (e, url) => {
if (url !== "forma://game/index.html") e.preventDefault();
});
win.webContents.on("will-attach-webview", (e) => e.preventDefault());
win.webContents.on("before-input-event", (e, input) => {
if (input.type === "keyDown" && input.key === "F11") {
win.setFullScreen(!win.isFullScreen());
e.preventDefault();
}
if (
input.type === "keyDown" &&
input.key === "Escape" &&
win.isFullScreen()
)
win.setFullScreen(false);
});
win.once("ready-to-show", () => win.show());
await win.loadURL("forma://game/index.html");
}
app
.whenReady()
.then(async () => {
if (app.commandLine.hasSwitch("no-sandbox")) {
dialog.showErrorBox(
"Sandbox unavailable",
"This game requires Chromium sandbox support. Enable unprivileged user namespaces on Linux, then restart without --no-sandbox.",
);
app.exit(1);
return;
}
Menu.setApplicationMenu(null);
session.defaultSession.setPermissionRequestHandler((_wc, _p, cb) =>
cb(false),
);
session.defaultSession.setPermissionCheckHandler(() => false);
session.defaultSession.webRequest.onBeforeRequest((details, cb) =>
cb({
cancel:
!details.url.startsWith("forma://game/") &&
!details.url.startsWith("blob:forma://game/"),
}),
);
protocol.handle("forma", async (request) => {
const r = await readGame(
path.join(__dirname, "game"),
request.url,
request.method,
);
return new Response(r.body, r);
});
await createWindow();
})
.catch((e) => {
console.error(e);
app.exit(1);
});
app.on("window-all-closed", () => app.quit());
+60
View File
@@ -0,0 +1,60 @@
const path = require("node:path");
const fs = require("node:fs/promises");
const mime = {
".html": "text/html; charset=utf-8",
".js": "text/javascript; charset=utf-8",
".css": "text/css; charset=utf-8",
".json": "application/json",
".wasm": "application/wasm",
".glb": "model/gltf-binary",
".gltf": "model/gltf+json",
".png": "image/png",
".jpg": "image/jpeg",
".svg": "image/svg+xml",
};
const csp =
"default-src 'none'; script-src 'self' 'unsafe-eval'; worker-src 'self' blob:; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; connect-src 'self' data: blob:; font-src 'self' data:; media-src 'self' blob:; object-src 'none'; base-uri 'none'; frame-src 'none'";
async function readGame(root, url, method = "GET") {
if (!["GET", "HEAD"].includes(method))
return { status: 405, body: "Method not allowed" };
let u, name;
try {
u = new URL(url);
name = decodeURIComponent(u.pathname);
} catch {
return { status: 400, body: "Bad URL" };
}
if (
u.protocol !== "forma:" ||
u.hostname !== "game" ||
u.port ||
u.username ||
u.password ||
name.includes("\\") ||
name.includes("\0")
)
return { status: 403, body: "Forbidden" };
const file = path.resolve(root, "." + (name === "/" ? "/index.html" : name));
if (!file.startsWith(path.resolve(root) + path.sep))
return { status: 403, body: "Forbidden" };
try {
const real = await fs.realpath(file);
if (!real.startsWith((await fs.realpath(root)) + path.sep))
return { status: 403, body: "Forbidden" };
const bytes = await fs.readFile(real);
return {
status: 200,
headers: {
"Content-Type": mime[path.extname(file)] || "application/octet-stream",
"Content-Security-Policy": csp,
"X-Content-Type-Options": "nosniff",
"Cache-Control": "no-store",
},
body: method === "HEAD" ? null : bytes,
};
} catch {
return { status: 404, body: "Not found" };
}
}
module.exports = { readGame, csp };
+60
View File
@@ -0,0 +1,60 @@
export const targets = ["linux", "windows", "android"];
export function normalizeOptions(value = {}) {
const o = {
target: value.target ?? "linux",
name: value.name ?? "Forma Game",
appId: value.appId ?? "games.forma.mygame",
version: value.version ?? "1.0.0",
versionCode: value.versionCode ?? 1,
mode: value.mode ?? "debug",
width: value.width ?? 1280,
height: value.height ?? 720,
fullscreen: value.fullscreen ?? false,
orientation: value.orientation ?? "landscape",
};
if (!targets.includes(o.target)) throw Error("Unknown build target");
if (
typeof o.name !== "string" ||
!o.name.trim() ||
o.name.length > 80 ||
/[\x00-\x1f<>:"/\\|?*]/.test(o.name)
)
throw Error(
"Game name must be 180 characters without file control characters",
);
o.name = o.name.trim();
if (
typeof o.appId !== "string" ||
o.appId.length > 150 ||
!/^[a-z][a-z0-9_]*(\.[a-z][a-z0-9_]*){2,}$/.test(o.appId)
)
throw Error(
"Application ID: games.studio.mygame (lowercase Latin letters)",
);
if (
typeof o.version !== "string" ||
!/^\d{1,4}\.\d{1,4}\.\d{1,4}$/.test(o.version)
)
throw Error("Version must have format 1.0.0");
if (
!Number.isInteger(o.versionCode) ||
o.versionCode < 1 ||
o.versionCode > 2100000000
)
throw Error("Android version code must be a positive integer");
if (!["debug", "release"].includes(o.mode))
throw Error("Mode must be debug or release");
if (!["landscape", "portrait", "sensor"].includes(o.orientation))
throw Error("Invalid orientation");
for (const key of ["width", "height"])
if (!Number.isInteger(o[key]) || o[key] < 320 || o[key] > 7680)
throw Error("Window size must be 3207680");
if (typeof o.fullscreen !== "boolean")
throw Error("Fullscreen must be boolean");
return o;
}
export function artifactStem(o) {
return o.appId.split(".").at(-1) + "-" + o.version;
}
+3600
View File
File diff suppressed because it is too large Load Diff
+18
View File
@@ -0,0 +1,18 @@
{
"name": "forma-native-builder",
"version": "0.2.0",
"private": true,
"type": "module",
"description": "Optional desktop toolchain for Forma game builds",
"engines": {
"node": ">=22.13.0"
},
"scripts": {
"build": "node build.mjs",
"doctor": "node build.mjs --doctor"
},
"devDependencies": {
"electron": "44.3.0",
"electron-builder": "26.15.3"
}
}
+109
View File
@@ -0,0 +1,109 @@
import { promises as fs } from "node:fs";
import { spawn } from "node:child_process";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { createHash } from "node:crypto";
const root = path.dirname(fileURLToPath(import.meta.url));
const dir = path.join(root, ".toolchains");
const proxy = process.env.HTTPS_PROXY || process.env.https_proxy;
const proxyArgs = proxy
? (() => {
const u = new URL(proxy);
return [
"--proxy=http",
"--proxy_host=" + u.hostname,
"--proxy_port=" + (u.port || "80"),
];
})()
: [];
const run = (c, a) =>
new Promise((resolve, reject) => {
const p = spawn(c, a, { stdio: "inherit" });
p.on("error", reject);
p.on("exit", (n) =>
n === 0 ? resolve() : reject(Error(c + " exited " + n)),
);
});
if (process.platform !== "linux")
throw Error(
"Automatic setup supports Linux. On other hosts install Android SDK, JDK 17 and Gradle 8.13; set ANDROID_HOME, JAVA_HOME and FORMA_GRADLE.",
);
await run(
process.env.JAVA_HOME
? path.join(process.env.JAVA_HOME, "bin", "javac")
: "javac",
["-version"],
);
await fs.mkdir(dir, { recursive: true });
const gradleZip = path.join(dir, "gradle.zip");
if (
!(await fs.access(path.join(dir, "gradle-8.13", "bin", "gradle")).then(
() => true,
() => false,
))
) {
await run("curl", [
"-fL",
"--connect-timeout",
"20",
"--max-time",
"300",
"--retry",
"2",
"-o",
gradleZip,
"https://services.gradle.org/distributions/gradle-8.13-bin.zip",
]);
const shaFile = path.join(dir, "gradle.sha256");
await run("curl", [
"-fL",
"-o",
shaFile,
"https://services.gradle.org/distributions/gradle-8.13-bin.zip.sha256",
]);
if (
createHash("sha256")
.update(await fs.readFile(gradleZip))
.digest("hex") !== (await fs.readFile(shaFile, "utf8")).trim()
)
throw Error("Gradle checksum mismatch");
await run("unzip", ["-q", "-o", gradleZip, "-d", dir]);
}
const sdk =
process.env.ANDROID_HOME ||
process.env.ANDROID_SDK_ROOT ||
path.join(dir, "android-sdk");
const manager = path.join(sdk, "cmdline-tools", "latest", "bin", "sdkmanager");
try {
await fs.access(manager);
} catch {
const zip = path.join(dir, "android-tools.zip"),
staging = path.join(dir, "sdk-tools");
await run("curl", [
"-fL",
"--connect-timeout",
"20",
"--max-time",
"300",
"--retry",
"2",
"-o",
zip,
"https://dl.google.com/android/repository/commandlinetools-linux-13114758_latest.zip",
]);
await run("unzip", ["-q", "-o", zip, "-d", staging]);
await fs.mkdir(path.dirname(path.dirname(manager)), { recursive: true });
await fs.cp(
path.join(staging, "cmdline-tools"),
path.join(sdk, "cmdline-tools", "latest"),
{ recursive: true },
);
}
// sdkmanager presents licenses to the human running setup; never silently accepts them.
await run(manager, [
"--sdk_root=" + sdk,
...proxyArgs,
"platforms;android-36",
"build-tools;35.0.0",
]);
console.log("Android toolchain ready. Run node native/build.mjs --doctor.");