Publish Forma Engine 0.3.0 source with documentation and CI
This commit is contained in:
@@ -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>
|
||||
@@ -0,0 +1 @@
|
||||
plugins { id 'com.android.application' version '8.13.2' apply false }
|
||||
@@ -0,0 +1,3 @@
|
||||
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
|
||||
android.useAndroidX=true
|
||||
org.gradle.daemon=false
|
||||
@@ -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'
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 |
@@ -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());
|
||||
@@ -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 };
|
||||
@@ -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 1–80 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 320–7680");
|
||||
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;
|
||||
}
|
||||
Generated
+3600
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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.");
|
||||
Reference in New Issue
Block a user