Publish Forma Engine 0.3.0 source with documentation and CI
This commit is contained in:
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 };
|
||||
Reference in New Issue
Block a user