mirror of
https://github.com/tobspr/shapez.io.git
synced 2025-12-11 09:11:50 +00:00
Rewrite the Electron wrapper (#47)
* Migrate Electron wrapper to ESM Use ESM syntax for main process, move some fs usages to fs.promises, switch to import.meta.url/import.meta.dirname to handle file paths; clean up redundant code. * Add TypeScript support to Electron wrapper The support is very basic, tsc is used to transpile code. Build scripts are modified to not copy any Electron code other than preload.cjs and use an extremely cursed setup to call the TypeScript compiler. * [TS] Rename platform/storage * Rewrite Electron wrapper MVP, missing some features from the old wrapper and most planned features. Some of the functionality hasn't been verified.
This commit is contained in:
parent
17a18fce41
commit
c836589d9b
2
.gitignore
vendored
2
.gitignore
vendored
@ -58,3 +58,5 @@ tmp
|
||||
src/js/built-temp
|
||||
translations/tmp
|
||||
gulp/additional_build_files
|
||||
|
||||
electron/dist
|
||||
|
||||
@ -1,335 +0,0 @@
|
||||
const { app, BrowserWindow, Menu, MenuItem, ipcMain, shell, dialog, session } = require("electron");
|
||||
const path = require("path");
|
||||
const url = require("url");
|
||||
const fs = require("fs");
|
||||
const asyncLock = require("async-lock");
|
||||
const windowStateKeeper = require("electron-window-state");
|
||||
|
||||
// Disable hardware key handling, i.e. being able to pause/resume the game music
|
||||
// with hardware keys
|
||||
app.commandLine.appendSwitch("disable-features", "HardwareMediaKeyHandling");
|
||||
|
||||
const isDev = app.commandLine.hasSwitch("dev");
|
||||
const safeMode = app.commandLine.hasSwitch("safe-mode");
|
||||
const externalMod = app.commandLine.getSwitchValue("load-mod");
|
||||
|
||||
app.setName("shapez-ce");
|
||||
const userData = app.getPath("userData");
|
||||
|
||||
const storePath = path.join(userData, "saves");
|
||||
const modsPath = path.join(userData, "mods");
|
||||
|
||||
fs.mkdirSync(storePath, { recursive: true });
|
||||
fs.mkdirSync(modsPath, { recursive: true });
|
||||
|
||||
/** @type {BrowserWindow} */
|
||||
let win = null;
|
||||
let menu = null;
|
||||
|
||||
function createWindow() {
|
||||
let faviconExtension = ".png";
|
||||
if (process.platform === "win32") {
|
||||
faviconExtension = ".ico";
|
||||
}
|
||||
|
||||
const mainWindowState = windowStateKeeper({
|
||||
defaultWidth: 1000,
|
||||
defaultHeight: 800,
|
||||
});
|
||||
|
||||
win = new BrowserWindow({
|
||||
x: mainWindowState.x,
|
||||
y: mainWindowState.y,
|
||||
width: mainWindowState.width,
|
||||
height: mainWindowState.height,
|
||||
show: false,
|
||||
backgroundColor: "#222428",
|
||||
useContentSize: false,
|
||||
minWidth: 800,
|
||||
minHeight: 600,
|
||||
title: "shapez",
|
||||
transparent: false,
|
||||
icon: path.join(__dirname, "favicon" + faviconExtension),
|
||||
// fullscreen: true,
|
||||
autoHideMenuBar: !isDev,
|
||||
webPreferences: {
|
||||
disableBlinkFeatures: "Auxclick",
|
||||
preload: path.join(__dirname, "preload.js"),
|
||||
},
|
||||
});
|
||||
|
||||
mainWindowState.manage(win);
|
||||
|
||||
if (!app.isPackaged) {
|
||||
win.loadURL("http://localhost:3005");
|
||||
} else {
|
||||
win.loadURL(
|
||||
url.format({
|
||||
pathname: path.join(__dirname, "index.html"),
|
||||
protocol: "file:",
|
||||
slashes: true,
|
||||
})
|
||||
);
|
||||
}
|
||||
win.webContents.session.clearCache();
|
||||
win.webContents.session.clearStorageData();
|
||||
|
||||
////// SECURITY
|
||||
|
||||
// Disable permission requests
|
||||
win.webContents.session.setPermissionRequestHandler((webContents, permission, callback) => {
|
||||
callback(false);
|
||||
});
|
||||
session.fromPartition("default").setPermissionRequestHandler((webContents, permission, callback) => {
|
||||
callback(false);
|
||||
});
|
||||
|
||||
app.on("web-contents-created", (event, contents) => {
|
||||
// Disable vewbiew
|
||||
contents.on("will-attach-webview", (event, webPreferences, params) => {
|
||||
event.preventDefault();
|
||||
});
|
||||
// Disable navigation
|
||||
contents.on("will-navigate", (event, navigationUrl) => {
|
||||
event.preventDefault();
|
||||
});
|
||||
});
|
||||
|
||||
win.webContents.on("will-redirect", (contentsEvent, navigationUrl) => {
|
||||
// Log and prevent the app from redirecting to a new page
|
||||
console.error(
|
||||
`The application tried to redirect to the following address: '${navigationUrl}'. This attempt was blocked.`
|
||||
);
|
||||
contentsEvent.preventDefault();
|
||||
});
|
||||
|
||||
//// END SECURITY
|
||||
|
||||
win.webContents.on("will-navigate", (event, pth) => {
|
||||
event.preventDefault();
|
||||
|
||||
if (pth.startsWith("https://")) {
|
||||
shell.openExternal(pth);
|
||||
}
|
||||
});
|
||||
|
||||
win.on("closed", () => {
|
||||
console.log("Window closed");
|
||||
win = null;
|
||||
});
|
||||
|
||||
if (isDev) {
|
||||
menu = new Menu();
|
||||
|
||||
win.webContents.toggleDevTools();
|
||||
|
||||
const mainItem = new MenuItem({
|
||||
label: "Toggle Dev Tools",
|
||||
click: () => win.webContents.toggleDevTools(),
|
||||
accelerator: "F12",
|
||||
});
|
||||
menu.append(mainItem);
|
||||
|
||||
const reloadItem = new MenuItem({
|
||||
label: "Reload",
|
||||
click: () => win.reload(),
|
||||
accelerator: "F5",
|
||||
});
|
||||
menu.append(reloadItem);
|
||||
|
||||
const fullscreenItem = new MenuItem({
|
||||
label: "Fullscreen",
|
||||
click: () => win.setFullScreen(!win.isFullScreen()),
|
||||
accelerator: "F11",
|
||||
});
|
||||
menu.append(fullscreenItem);
|
||||
|
||||
const mainMenu = new Menu();
|
||||
mainMenu.append(
|
||||
new MenuItem({
|
||||
label: "shapez.io",
|
||||
submenu: menu,
|
||||
})
|
||||
);
|
||||
|
||||
Menu.setApplicationMenu(mainMenu);
|
||||
} else {
|
||||
Menu.setApplicationMenu(null);
|
||||
}
|
||||
|
||||
win.once("ready-to-show", () => {
|
||||
win.show();
|
||||
win.focus();
|
||||
});
|
||||
}
|
||||
|
||||
if (!app.requestSingleInstanceLock()) {
|
||||
app.exit(0);
|
||||
} else {
|
||||
app.on("second-instance", () => {
|
||||
// Someone tried to run a second instance, we should focus
|
||||
if (win) {
|
||||
if (win.isMinimized()) {
|
||||
win.restore();
|
||||
}
|
||||
win.focus();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
app.on("ready", createWindow);
|
||||
|
||||
app.on("window-all-closed", () => {
|
||||
console.log("All windows closed");
|
||||
app.quit();
|
||||
});
|
||||
|
||||
ipcMain.on("set-fullscreen", (event, flag) => {
|
||||
win.setFullScreen(flag);
|
||||
});
|
||||
|
||||
ipcMain.on("exit-app", () => {
|
||||
win.close();
|
||||
app.quit();
|
||||
});
|
||||
|
||||
let renameCounter = 1;
|
||||
|
||||
const fileLock = new asyncLock({
|
||||
timeout: 30000,
|
||||
maxPending: 1000,
|
||||
});
|
||||
|
||||
function niceFileName(filename) {
|
||||
return filename.replace(storePath, "@");
|
||||
}
|
||||
|
||||
async function writeFileSafe(filename, contents) {
|
||||
++renameCounter;
|
||||
const prefix = "[ " + renameCounter + ":" + niceFileName(filename) + " ] ";
|
||||
const transactionId = String(new Date().getTime()) + "." + renameCounter;
|
||||
|
||||
if (fileLock.isBusy()) {
|
||||
console.warn(prefix, "Concurrent write process on", filename);
|
||||
}
|
||||
|
||||
fileLock.acquire(filename, async () => {
|
||||
console.log(prefix, "Starting write on", niceFileName(filename), "in transaction", transactionId);
|
||||
|
||||
if (!fs.existsSync(filename)) {
|
||||
// this one is easy
|
||||
console.log(prefix, "Writing file instantly because it does not exist:", niceFileName(filename));
|
||||
await fs.promises.writeFile(filename, contents, "utf8");
|
||||
return;
|
||||
}
|
||||
|
||||
// first, write a temporary file (.tmp-XXX)
|
||||
const tempName = filename + ".tmp-" + transactionId;
|
||||
console.log(prefix, "Writing temporary file", niceFileName(tempName));
|
||||
await fs.promises.writeFile(tempName, contents, "utf8");
|
||||
|
||||
// now, rename the original file to (.backup-XXX)
|
||||
const oldTemporaryName = filename + ".backup-" + transactionId;
|
||||
console.log(
|
||||
prefix,
|
||||
"Renaming old file",
|
||||
niceFileName(filename),
|
||||
"to",
|
||||
niceFileName(oldTemporaryName)
|
||||
);
|
||||
await fs.promises.rename(filename, oldTemporaryName);
|
||||
|
||||
// now, rename the temporary file (.tmp-XXX) to the target
|
||||
console.log(
|
||||
prefix,
|
||||
"Renaming the temporary file",
|
||||
niceFileName(tempName),
|
||||
"to the original",
|
||||
niceFileName(filename)
|
||||
);
|
||||
await fs.promises.rename(tempName, filename);
|
||||
|
||||
// we are done now, try to create a backup, but don't fail if the backup fails
|
||||
try {
|
||||
// check if there is an old backup file
|
||||
const backupFileName = filename + ".backup";
|
||||
if (fs.existsSync(backupFileName)) {
|
||||
console.log(prefix, "Deleting old backup file", niceFileName(backupFileName));
|
||||
// delete the old backup
|
||||
await fs.promises.unlink(backupFileName);
|
||||
}
|
||||
|
||||
// rename the old file to the new backup file
|
||||
console.log(prefix, "Moving", niceFileName(oldTemporaryName), "to the backup file location");
|
||||
await fs.promises.rename(oldTemporaryName, backupFileName);
|
||||
} catch (ex) {
|
||||
console.error(prefix, "Failed to switch backup files:", ex);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
ipcMain.handle("fs-job", async (event, job) => {
|
||||
const filenameSafe = job.filename.replace(/[^a-z.\-_0-9]/gi, "_");
|
||||
const fname = path.join(storePath, filenameSafe);
|
||||
switch (job.type) {
|
||||
case "read": {
|
||||
if (!fs.existsSync(fname)) {
|
||||
// Special FILE_NOT_FOUND error code
|
||||
return { error: "file_not_found" };
|
||||
}
|
||||
return await fs.promises.readFile(fname, "utf8");
|
||||
}
|
||||
case "write": {
|
||||
await writeFileSafe(fname, job.contents);
|
||||
return job.contents;
|
||||
}
|
||||
|
||||
case "delete": {
|
||||
await fs.promises.unlink(fname);
|
||||
return;
|
||||
}
|
||||
|
||||
default:
|
||||
throw new Error("Unknown fs job: " + job.type);
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle("open-mods-folder", async () => {
|
||||
shell.openPath(modsPath);
|
||||
});
|
||||
|
||||
console.log("Loading mods ...");
|
||||
|
||||
function loadMods() {
|
||||
if (safeMode) {
|
||||
console.log("Safe Mode enabled for mods, skipping mod search");
|
||||
}
|
||||
console.log("Loading mods from", modsPath);
|
||||
let modFiles = safeMode
|
||||
? []
|
||||
: fs
|
||||
.readdirSync(modsPath)
|
||||
.filter(filename => filename.endsWith(".js"))
|
||||
.map(filename => path.join(modsPath, filename));
|
||||
|
||||
if (externalMod) {
|
||||
console.log("Adding external mod source:", externalMod);
|
||||
const externalModPaths = externalMod.split(",");
|
||||
modFiles = modFiles.concat(externalModPaths);
|
||||
}
|
||||
|
||||
return modFiles.map(filename => fs.readFileSync(filename, "utf8"));
|
||||
}
|
||||
|
||||
let mods = [];
|
||||
try {
|
||||
mods = loadMods();
|
||||
console.log("Loaded", mods.length, "mods");
|
||||
} catch (ex) {
|
||||
console.error("Failed to load mods");
|
||||
dialog.showErrorBox("Failed to load mods:", ex);
|
||||
}
|
||||
|
||||
ipcMain.handle("get-mods", async () => {
|
||||
return mods;
|
||||
});
|
||||
@ -1,6 +0,0 @@
|
||||
Here you can place mods. Every mod should be a single file ending with ".js".
|
||||
|
||||
--- WARNING ---
|
||||
Mods can potentially access to your filesystem.
|
||||
Please only install mods from trusted sources and developers.
|
||||
--- WARNING ---
|
||||
@ -1,19 +1,15 @@
|
||||
{
|
||||
"name": "electron",
|
||||
"version": "1.0.0",
|
||||
"main": "index.js",
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"start": "electron .",
|
||||
"startDev": "electron . --dev"
|
||||
"start": "tsc && electron ."
|
||||
},
|
||||
"dependencies": {},
|
||||
"devDependencies": {
|
||||
"electron": "^31.3.0"
|
||||
},
|
||||
"optionalDependencies": {},
|
||||
"dependencies": {
|
||||
"async-lock": "^1.4.1",
|
||||
"electron-window-state": "^5.0.3"
|
||||
}
|
||||
}
|
||||
|
||||
20
electron/src/config.ts
Normal file
20
electron/src/config.ts
Normal file
@ -0,0 +1,20 @@
|
||||
import { app } from "electron";
|
||||
|
||||
const disabledFeatures = ["HardwareMediaKeyHandling"];
|
||||
app.commandLine.appendSwitch("disable-features", disabledFeatures.join(","));
|
||||
|
||||
app.setName("shapez-ce");
|
||||
|
||||
// This variable should be used to avoid situations where the app name
|
||||
// wasn't set yet.
|
||||
export const userData = app.getPath("userData");
|
||||
|
||||
export const pageUrl = app.isPackaged
|
||||
? new URL("../index.html", import.meta.url).href
|
||||
: "http://localhost:3005/";
|
||||
|
||||
export const switches = {
|
||||
dev: app.commandLine.hasSwitch("dev"),
|
||||
hideDevtools: app.commandLine.hasSwitch("hide-devtools"),
|
||||
safeMode: app.commandLine.hasSwitch("safe-mode"),
|
||||
};
|
||||
70
electron/src/fsjob.ts
Normal file
70
electron/src/fsjob.ts
Normal file
@ -0,0 +1,70 @@
|
||||
import fs from "fs/promises";
|
||||
import path from "path";
|
||||
import { userData } from "./config.js";
|
||||
|
||||
interface GenericFsJob {
|
||||
filename: string;
|
||||
}
|
||||
|
||||
type ListFsJob = GenericFsJob & { type: "list" };
|
||||
type ReadFsJob = GenericFsJob & { type: "read" };
|
||||
type WriteFsJob = GenericFsJob & { type: "write"; contents: string };
|
||||
type DeleteFsJob = GenericFsJob & { type: "delete" };
|
||||
|
||||
export type FsJob = ListFsJob | ReadFsJob | WriteFsJob | DeleteFsJob;
|
||||
type FsJobResult = string | string[] | void;
|
||||
|
||||
export class FsJobHandler {
|
||||
readonly rootDir: string;
|
||||
|
||||
constructor(subDir: string) {
|
||||
this.rootDir = path.join(userData, subDir);
|
||||
}
|
||||
|
||||
handleJob(job: FsJob): Promise<FsJobResult> {
|
||||
const filename = this.safeFileName(job.filename);
|
||||
|
||||
switch (job.type) {
|
||||
case "list":
|
||||
return this.list(filename);
|
||||
case "read":
|
||||
return this.read(filename);
|
||||
case "write":
|
||||
return this.write(filename, job.contents);
|
||||
case "delete":
|
||||
return this.delete(filename);
|
||||
}
|
||||
|
||||
// @ts-expect-error this method can actually receive garbage
|
||||
throw new Error(`Unknown FS job type: ${job.type}`);
|
||||
}
|
||||
|
||||
private list(subdir: string): Promise<string[]> {
|
||||
// Bare-bones implementation
|
||||
return fs.readdir(subdir);
|
||||
}
|
||||
|
||||
private read(file: string): Promise<string> {
|
||||
return fs.readFile(file, "utf-8");
|
||||
}
|
||||
|
||||
private async write(file: string, contents: string): Promise<string> {
|
||||
// Backups not implemented yet.
|
||||
await fs.writeFile(file, contents, {
|
||||
encoding: "utf-8",
|
||||
flush: true,
|
||||
});
|
||||
return contents;
|
||||
}
|
||||
|
||||
private delete(file: string): Promise<void> {
|
||||
return fs.unlink(file);
|
||||
}
|
||||
|
||||
private safeFileName(name: string) {
|
||||
// TODO: Rather than restricting file names, attempt to resolve everything
|
||||
// relative to the data directory (i.e. normalize the file path, then join)
|
||||
const relative = name.replace(/[^a-z.0-9_-]/gi, "_");
|
||||
return path.join(this.rootDir, relative);
|
||||
}
|
||||
}
|
||||
88
electron/src/index.ts
Normal file
88
electron/src/index.ts
Normal file
@ -0,0 +1,88 @@
|
||||
import { BrowserWindow, app, shell } from "electron";
|
||||
import path from "path";
|
||||
import { pageUrl, switches } from "./config.js";
|
||||
import { FsJobHandler } from "./fsjob.js";
|
||||
import { IpcHandler } from "./ipc.js";
|
||||
import { ModsHandler } from "./mods.js";
|
||||
|
||||
let win: BrowserWindow | null = null;
|
||||
|
||||
if (!app.requestSingleInstanceLock()) {
|
||||
app.quit();
|
||||
} else {
|
||||
app.on("second-instance", () => {
|
||||
if (win?.isMinimized()) {
|
||||
win.restore();
|
||||
}
|
||||
|
||||
win?.focus();
|
||||
});
|
||||
}
|
||||
|
||||
// TODO: Implement a redirector/advanced storage system
|
||||
// Let mods have own data directories with easy access and
|
||||
// split savegames/configs - only implement backups and gzip
|
||||
// files if requested. Perhaps, use streaming to make large
|
||||
// transfers less "blocking"
|
||||
const fsJob = new FsJobHandler("saves");
|
||||
const mods = new ModsHandler();
|
||||
const ipc = new IpcHandler(fsJob, mods);
|
||||
|
||||
function createWindow() {
|
||||
win = new BrowserWindow({
|
||||
minWidth: 800,
|
||||
minHeight: 600,
|
||||
useContentSize: true,
|
||||
autoHideMenuBar: !switches.dev,
|
||||
show: false,
|
||||
webPreferences: {
|
||||
preload: path.join(import.meta.dirname, "../preload.cjs"),
|
||||
},
|
||||
});
|
||||
|
||||
if (!switches.dev) {
|
||||
win.removeMenu();
|
||||
}
|
||||
|
||||
win.on("ready-to-show", () => {
|
||||
win.show();
|
||||
|
||||
if (switches.dev && !switches.hideDevtools) {
|
||||
win.webContents.openDevTools();
|
||||
}
|
||||
});
|
||||
|
||||
ipc.install(win);
|
||||
win.loadURL(pageUrl);
|
||||
|
||||
// Redirect any kind of main frame navigation to external applications
|
||||
win.webContents.on("will-navigate", (ev, url) => {
|
||||
if (url === win.webContents.getURL()) {
|
||||
// Avoid handling reloads externally
|
||||
return;
|
||||
}
|
||||
|
||||
ev.preventDefault();
|
||||
openExternalUrl(url);
|
||||
});
|
||||
}
|
||||
|
||||
function openExternalUrl(urlString: string) {
|
||||
try {
|
||||
const url = new URL(urlString);
|
||||
|
||||
// TODO: Let the user explicitly allow other protocols
|
||||
if (["http:", "https:"].includes(url.protocol)) {
|
||||
shell.openExternal(urlString);
|
||||
}
|
||||
} catch {
|
||||
// Ignore invalid URLs
|
||||
}
|
||||
}
|
||||
|
||||
await mods.reload();
|
||||
|
||||
app.on("ready", createWindow);
|
||||
app.on("window-all-closed", () => {
|
||||
app.quit();
|
||||
});
|
||||
34
electron/src/ipc.ts
Normal file
34
electron/src/ipc.ts
Normal file
@ -0,0 +1,34 @@
|
||||
import { BrowserWindow, IpcMainInvokeEvent, ipcMain } from "electron";
|
||||
import { FsJob, FsJobHandler } from "./fsjob.js";
|
||||
import { ModsHandler } from "./mods.js";
|
||||
|
||||
export class IpcHandler {
|
||||
private readonly fsJob: FsJobHandler;
|
||||
private readonly mods: ModsHandler;
|
||||
|
||||
constructor(fsJob: FsJobHandler, mods: ModsHandler) {
|
||||
this.fsJob = fsJob;
|
||||
this.mods = mods;
|
||||
}
|
||||
|
||||
install(window: BrowserWindow) {
|
||||
ipcMain.handle("fs-job", this.handleFsJob.bind(this));
|
||||
ipcMain.handle("get-mods", this.getMods.bind(this));
|
||||
ipcMain.handle("set-fullscreen", this.setFullscreen.bind(this, window));
|
||||
|
||||
// Not implemented
|
||||
// ipcMain.handle("open-mods-folder", ...)
|
||||
}
|
||||
|
||||
private handleFsJob(_event: IpcMainInvokeEvent, job: FsJob) {
|
||||
return this.fsJob.handleJob(job);
|
||||
}
|
||||
|
||||
private getMods() {
|
||||
return this.mods.getMods();
|
||||
}
|
||||
|
||||
private setFullscreen(window: BrowserWindow, _event: IpcMainInvokeEvent, flag: boolean) {
|
||||
window.setFullScreen(flag);
|
||||
}
|
||||
}
|
||||
74
electron/src/mods.ts
Normal file
74
electron/src/mods.ts
Normal file
@ -0,0 +1,74 @@
|
||||
import { app } from "electron";
|
||||
import fs from "fs/promises";
|
||||
import path from "path";
|
||||
import { switches, userData } from "./config.js";
|
||||
|
||||
const localPrefix = "@/";
|
||||
const modFileSuffix = ".js";
|
||||
|
||||
interface Mod {
|
||||
file: string;
|
||||
source: string;
|
||||
}
|
||||
|
||||
export class ModsHandler {
|
||||
private mods: Mod[] = [];
|
||||
readonly modsDir = path.join(userData, "mods");
|
||||
|
||||
async getMods(): Promise<Mod[]> {
|
||||
return this.mods;
|
||||
}
|
||||
|
||||
async reload() {
|
||||
// Ensure the directory exists!
|
||||
fs.mkdir(this.modsDir, { recursive: true });
|
||||
|
||||
// Note: this method is written with classic .js mods in mind
|
||||
const files = await this.getModPaths();
|
||||
const allMods: Mod[] = [];
|
||||
|
||||
for (const file of files) {
|
||||
const source = await fs.readFile(file, "utf-8");
|
||||
allMods.push({ file, source });
|
||||
}
|
||||
|
||||
this.mods = allMods;
|
||||
}
|
||||
|
||||
private async getModPaths(): Promise<string[]> {
|
||||
const mods: string[] = switches.safeMode ? [] : await this.findModFiles();
|
||||
|
||||
// Note: old switch name, extend support later
|
||||
const cmdLine = app.commandLine.getSwitchValue("load-mod");
|
||||
const explicitMods = cmdLine === "" ? [] : cmdLine.split(",");
|
||||
|
||||
mods.push(...explicitMods.map(mod => this.resolveModLocation(mod)));
|
||||
|
||||
return [...mods];
|
||||
}
|
||||
|
||||
private resolveModLocation(mod: string) {
|
||||
if (mod.startsWith(localPrefix)) {
|
||||
// Let users specify --safe-mode and easily load only some mods
|
||||
const name = mod.slice(localPrefix.length);
|
||||
return path.join(this.modsDir, name);
|
||||
}
|
||||
|
||||
// Note: here, it's a good idea NOT to resolve mod paths
|
||||
// from mods directory, as that can make development easier:
|
||||
//
|
||||
// $ shapez --load-mod=mymod.js # resolved as $PWD/mymod.js
|
||||
return path.resolve(mod);
|
||||
}
|
||||
|
||||
private async findModFiles(): Promise<string[]> {
|
||||
const directory = await fs.readdir(this.modsDir, {
|
||||
withFileTypes: true,
|
||||
});
|
||||
|
||||
return directory
|
||||
.filter(entry => entry.name.endsWith(modFileSuffix))
|
||||
.filter(entry => !entry.isDirectory())
|
||||
.map(entry => path.join(entry.path, entry.name));
|
||||
}
|
||||
}
|
||||
8
electron/tsconfig.json
Normal file
8
electron/tsconfig.json
Normal file
@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "../tsconfig.json",
|
||||
"include": ["./src/**/*"],
|
||||
"compilerOptions": {
|
||||
"noEmit": false,
|
||||
"outDir": "./dist"
|
||||
}
|
||||
}
|
||||
@ -72,11 +72,6 @@
|
||||
dependencies:
|
||||
"@types/node" "*"
|
||||
|
||||
async-lock@^1.4.1:
|
||||
version "1.4.1"
|
||||
resolved "https://registry.yarnpkg.com/async-lock/-/async-lock-1.4.1.tgz#56b8718915a9b68b10fce2f2a9a3dddf765ef53f"
|
||||
integrity sha512-Az2ZTpuytrtqENulXwO3GGv1Bztugx6TT37NIo7imr/Qo0gsYiGtSdBa2B6fsXhTpVZDNfu1Qn3pk531e3q+nQ==
|
||||
|
||||
boolean@^3.0.1:
|
||||
version "3.2.0"
|
||||
resolved "https://registry.yarnpkg.com/boolean/-/boolean-3.2.0.tgz#9e5294af4e98314494cbb17979fa54ca159f116b"
|
||||
@ -154,14 +149,6 @@ detect-node@^2.0.4:
|
||||
resolved "https://registry.yarnpkg.com/detect-node/-/detect-node-2.1.0.tgz#c9c70775a49c3d03bc2c06d9a73be550f978f8b1"
|
||||
integrity sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g==
|
||||
|
||||
electron-window-state@^5.0.3:
|
||||
version "5.0.3"
|
||||
resolved "https://registry.yarnpkg.com/electron-window-state/-/electron-window-state-5.0.3.tgz#4f36d09e3f953d87aff103bf010f460056050aa8"
|
||||
integrity sha512-1mNTwCfkolXl3kMf50yW3vE2lZj0y92P/HYWFBrb+v2S/pCka5mdwN3cagKm458A7NjndSwijynXgcLWRodsVg==
|
||||
dependencies:
|
||||
jsonfile "^4.0.0"
|
||||
mkdirp "^0.5.1"
|
||||
|
||||
electron@^31.3.0:
|
||||
version "31.3.0"
|
||||
resolved "https://registry.yarnpkg.com/electron/-/electron-31.3.0.tgz#4a084a8229d5bd829c33b8b65073381d0e925093"
|
||||
@ -393,18 +380,6 @@ mimic-response@^3.1.0:
|
||||
resolved "https://registry.yarnpkg.com/mimic-response/-/mimic-response-3.1.0.tgz#2d1d59af9c1b129815accc2c46a022a5ce1fa3c9"
|
||||
integrity sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==
|
||||
|
||||
minimist@^1.2.6:
|
||||
version "1.2.8"
|
||||
resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.8.tgz#c1a464e7693302e082a075cee0c057741ac4772c"
|
||||
integrity sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==
|
||||
|
||||
mkdirp@^0.5.1:
|
||||
version "0.5.6"
|
||||
resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-0.5.6.tgz#7def03d2432dcae4ba1d611445c48396062255f6"
|
||||
integrity sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==
|
||||
dependencies:
|
||||
minimist "^1.2.6"
|
||||
|
||||
ms@2.1.2:
|
||||
version "2.1.2"
|
||||
resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.2.tgz#d09d1f357b443f493382a8eb3ccd183872ae6009"
|
||||
|
||||
@ -1,13 +1,11 @@
|
||||
import packager from "electron-packager";
|
||||
import pj from "../electron/package.json" with { type: "json" };
|
||||
import path from "path/posix";
|
||||
import { getVersion } from "./buildutils.js";
|
||||
import fs from "fs/promises";
|
||||
import childProcess from "child_process";
|
||||
import { promisify } from "util";
|
||||
const exec = promisify(childProcess.exec);
|
||||
import gulp from "gulp";
|
||||
import path from "path/posix";
|
||||
import electronPackageJson from "../electron/package.json" with { type: "json" };
|
||||
import { BUILD_VARIANTS } from "./build_variants.js";
|
||||
import { getVersion } from "./buildutils.js";
|
||||
import { buildProject } from "./typescript.js";
|
||||
|
||||
import gulpClean from "gulp-clean";
|
||||
|
||||
@ -30,6 +28,7 @@ export default Object.fromEntries(
|
||||
|
||||
function copyPrefab() {
|
||||
const requiredFiles = [
|
||||
path.join(electronBaseDir, "preload.cjs"),
|
||||
path.join(electronBaseDir, "node_modules", "**", "*.*"),
|
||||
path.join(electronBaseDir, "node_modules", "**", ".*"),
|
||||
path.join(electronBaseDir, "favicon*"),
|
||||
@ -37,53 +36,34 @@ export default Object.fromEntries(
|
||||
return gulp.src(requiredFiles, { base: electronBaseDir }).pipe(gulp.dest(tempDestBuildDir));
|
||||
}
|
||||
|
||||
async function writePackageJson() {
|
||||
const packageJsonString = JSON.stringify(
|
||||
{
|
||||
scripts: {
|
||||
start: pj.scripts.start,
|
||||
},
|
||||
devDependencies: pj.devDependencies,
|
||||
dependencies: pj.dependencies,
|
||||
optionalDependencies: pj.optionalDependencies,
|
||||
},
|
||||
null,
|
||||
4
|
||||
);
|
||||
async function transpileTypeScript() {
|
||||
const tsconfigPath = path.join(electronBaseDir, "tsconfig.json");
|
||||
const outDir = path.join(tempDestBuildDir, "dist");
|
||||
|
||||
await fs.writeFile(path.join(tempDestBuildDir, "package.json"), packageJsonString);
|
||||
buildProject(tsconfigPath, undefined, outDir);
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
function minifyCode() {
|
||||
return gulp.src(path.join(electronBaseDir, "*.js")).pipe(gulp.dest(tempDestBuildDir));
|
||||
async function writePackageJson() {
|
||||
const pkgJson = structuredClone(electronPackageJson);
|
||||
pkgJson.version = getVersion();
|
||||
delete pkgJson.scripts;
|
||||
|
||||
const packageJsonString = JSON.stringify(pkgJson);
|
||||
await fs.writeFile(path.join(tempDestBuildDir, "package.json"), packageJsonString);
|
||||
}
|
||||
|
||||
function copyGamefiles() {
|
||||
return gulp.src("../build/**/*.*", { base: "../build" }).pipe(gulp.dest(tempDestBuildDir));
|
||||
}
|
||||
|
||||
async function killRunningInstances() {
|
||||
try {
|
||||
await exec("taskkill /F /IM shapezio.exe");
|
||||
} catch (ex) {
|
||||
console.warn("Failed to kill running instances, maybe none are up.");
|
||||
}
|
||||
}
|
||||
|
||||
const prepare = {
|
||||
cleanup,
|
||||
copyPrefab,
|
||||
transpileTypeScript,
|
||||
writePackageJson,
|
||||
minifyCode,
|
||||
copyGamefiles,
|
||||
all: gulp.series(
|
||||
killRunningInstances,
|
||||
cleanup,
|
||||
copyPrefab,
|
||||
writePackageJson,
|
||||
minifyCode,
|
||||
copyGamefiles
|
||||
),
|
||||
all: gulp.series(cleanup, copyPrefab, transpileTypeScript, writePackageJson, copyGamefiles),
|
||||
};
|
||||
|
||||
/**
|
||||
@ -136,7 +116,6 @@ export default Object.fromEntries(
|
||||
return [
|
||||
variant,
|
||||
{
|
||||
killRunningInstances,
|
||||
prepare,
|
||||
package: pack,
|
||||
},
|
||||
|
||||
66
gulp/typescript.js
Normal file
66
gulp/typescript.js
Normal file
@ -0,0 +1,66 @@
|
||||
import * as path from "path";
|
||||
import ts from "typescript";
|
||||
|
||||
/**
|
||||
* @param {ts.Diagnostic} diagnostic
|
||||
*/
|
||||
function printDiagnostic(diagnostic) {
|
||||
if (!diagnostic.file) {
|
||||
console.log(ts.flattenDiagnosticMessageText(diagnostic.messageText, "\n"));
|
||||
return;
|
||||
}
|
||||
|
||||
const { line, character } = ts.getLineAndCharacterOfPosition(diagnostic.file, diagnostic.start);
|
||||
const message = ts.flattenDiagnosticMessageText(diagnostic.messageText, "\n");
|
||||
console.log(`${diagnostic.file.fileName} (${line + 1},${character + 1}): ${message}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads the TypeScript compiler configuration from the specified path.
|
||||
* @param {string} configPath Path to the tsconfig.json file
|
||||
* @param {string} baseDir Directory used to resolve relative file paths
|
||||
* @param {string?} outDir Optional override for output directory
|
||||
*/
|
||||
function readConfig(configPath, baseDir, outDir) {
|
||||
// Please forgive me for this sin, copied from random parts of TS itself
|
||||
const cfgSource = ts.sys.readFile(configPath);
|
||||
const result = ts.parseJsonText(configPath, cfgSource);
|
||||
|
||||
return ts.parseJsonSourceFileConfigFileContent(
|
||||
result,
|
||||
ts.sys,
|
||||
baseDir,
|
||||
outDir ? { outDir } : undefined,
|
||||
configPath
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a TypeScript project.
|
||||
* Mostly based on https://github.com/microsoft/TypeScript/wiki/Using-the-Compiler-API
|
||||
* @param {string} configPath Path to the tsconfig.json file
|
||||
* @param {string?} baseDir Directory used to resolve relative file paths
|
||||
* @param {string?} outDir Optional override for output directory
|
||||
*/
|
||||
export function buildProject(configPath, baseDir = undefined, outDir = undefined) {
|
||||
configPath = path.resolve(configPath);
|
||||
|
||||
if (baseDir === undefined) {
|
||||
baseDir = path.dirname(configPath);
|
||||
}
|
||||
baseDir = path.resolve(baseDir);
|
||||
|
||||
const config = readConfig(configPath, baseDir, outDir);
|
||||
const program = ts.createProgram(config.fileNames, config.options);
|
||||
const result = program.emit();
|
||||
|
||||
const diagnostics = ts.getPreEmitDiagnostics(program).concat(result.diagnostics);
|
||||
for (const diagnostic of diagnostics) {
|
||||
printDiagnostic(diagnostic);
|
||||
}
|
||||
|
||||
const success = !result.emitSkipped;
|
||||
if (!success) {
|
||||
throw new Error("TypeScript compilation failed! Relevant errors may have been displayed above.");
|
||||
}
|
||||
}
|
||||
@ -40,7 +40,7 @@
|
||||
"@types/gulp": "^4.0.9",
|
||||
"@types/gulp-htmlmin": "^1.3.32",
|
||||
"@types/lz-string": "^1.3.34",
|
||||
"@types/node": "^16.0.0",
|
||||
"@types/node": "20.14.*",
|
||||
"@types/webpack": "^5.28.0",
|
||||
"browser-sync": "^2.27.10",
|
||||
"circular-dependency-plugin": "^5.2.2",
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
import { Application } from "../application";
|
||||
/* typehints:end */
|
||||
|
||||
import { FILE_NOT_FOUND } from "../platform/storage";
|
||||
import { FsError } from "@/platform/fs_error";
|
||||
import { compressObject, decompressObject } from "../savegame/savegame_compressor";
|
||||
import { asyncCompressor, compressionPrefix } from "./async_compression";
|
||||
import { IS_DEBUG, globalConfig } from "./config";
|
||||
@ -165,7 +165,7 @@ export class ReadWriteProxy {
|
||||
|
||||
// Check for errors during read
|
||||
.catch(err => {
|
||||
if (err === FILE_NOT_FOUND) {
|
||||
if (err instanceof FsError && err.isFileNotFound()) {
|
||||
logger.log("File not found, using default data");
|
||||
|
||||
// File not found or unreadable, assume default file
|
||||
|
||||
@ -1,9 +1,10 @@
|
||||
/* typehints:start */
|
||||
import { Application } from "../application";
|
||||
/* typehints:end */
|
||||
import { FsError } from "@/platform/fs_error";
|
||||
import { globalConfig } from "../core/config";
|
||||
import { createLogger } from "../core/logging";
|
||||
import { FILE_NOT_FOUND, Storage } from "../platform/storage";
|
||||
import { Storage } from "../platform/storage";
|
||||
import { Mod } from "./mod";
|
||||
import { ModInterface } from "./mod_interface";
|
||||
import { MOD_SIGNALS } from "./mod_signals";
|
||||
@ -140,7 +141,10 @@ export class ModLoader {
|
||||
LOG.log("hook:init", this.app, this.app.storage);
|
||||
this.exposeExports();
|
||||
|
||||
// TODO: Make use of the passed file name, or wait for ModV2
|
||||
let mods = await ipcRenderer.invoke("get-mods");
|
||||
mods = mods.map(mod => mod.source);
|
||||
|
||||
if (G_IS_DEV && globalConfig.debug.externalModUrl) {
|
||||
const modURLs = Array.isArray(globalConfig.debug.externalModUrl)
|
||||
? globalConfig.debug.externalModUrl
|
||||
@ -224,7 +228,7 @@ export class ModLoader {
|
||||
const storedSettings = await storage.readFileAsync(modDataFile);
|
||||
settings = JSON.parse(storedSettings);
|
||||
} catch (ex) {
|
||||
if (ex === FILE_NOT_FOUND) {
|
||||
if (ex instanceof FsError && ex.isFileNotFound()) {
|
||||
// Write default data
|
||||
await storage.writeFileAsync(modDataFile, JSON.stringify(meta.settings));
|
||||
} else {
|
||||
|
||||
23
src/js/platform/fs_error.ts
Normal file
23
src/js/platform/fs_error.ts
Normal file
@ -0,0 +1,23 @@
|
||||
/**
|
||||
* Represents a filesystem error as reported by the main process.
|
||||
*/
|
||||
export class FsError extends Error {
|
||||
code?: string;
|
||||
|
||||
constructor(message?: string, options?: ErrorOptions) {
|
||||
super(message, options);
|
||||
Error.captureStackTrace(this, FsError);
|
||||
this.name = "FsError";
|
||||
|
||||
// Take the code from the error message, quite ugly
|
||||
if (options?.cause && options.cause instanceof Error) {
|
||||
// Example message:
|
||||
// Error invoking remote method 'fs-job': Error: ENOENT: no such...
|
||||
this.code = options.cause.message.split(":")[2].trim();
|
||||
}
|
||||
}
|
||||
|
||||
isFileNotFound(): boolean {
|
||||
return this.code === "ENOENT";
|
||||
}
|
||||
}
|
||||
@ -1,66 +0,0 @@
|
||||
/* typehints:start */
|
||||
import { Application } from "../application";
|
||||
/* typehints:end */
|
||||
|
||||
export const FILE_NOT_FOUND = "file_not_found";
|
||||
|
||||
export class Storage {
|
||||
constructor(app) {
|
||||
/** @type {Application} */
|
||||
this.app = app;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initializes the storage
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
initialize() {
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes a string to a file asynchronously
|
||||
* @param {string} filename
|
||||
* @param {string} contents
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
writeFileAsync(filename, contents) {
|
||||
return ipcRenderer.invoke("fs-job", {
|
||||
type: "write",
|
||||
filename,
|
||||
contents,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads a string asynchronously. Returns Promise<FILE_NOT_FOUND> if file was not found.
|
||||
* @param {string} filename
|
||||
* @returns {Promise<string>}
|
||||
*/
|
||||
readFileAsync(filename) {
|
||||
return ipcRenderer
|
||||
.invoke("fs-job", {
|
||||
type: "read",
|
||||
filename,
|
||||
})
|
||||
.then(res => {
|
||||
if (res && res.error === FILE_NOT_FOUND) {
|
||||
throw FILE_NOT_FOUND;
|
||||
}
|
||||
|
||||
return res;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Tries to delete a file
|
||||
* @param {string} filename
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
deleteFileAsync(filename) {
|
||||
return ipcRenderer.invoke("fs-job", {
|
||||
type: "delete",
|
||||
filename,
|
||||
});
|
||||
}
|
||||
}
|
||||
59
src/js/platform/storage.ts
Normal file
59
src/js/platform/storage.ts
Normal file
@ -0,0 +1,59 @@
|
||||
import { Application } from "@/application";
|
||||
import { FsError } from "./fs_error";
|
||||
|
||||
export class Storage {
|
||||
readonly app: Application;
|
||||
|
||||
constructor(app: Application) {
|
||||
this.app = app;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initializes the storage
|
||||
*/
|
||||
initialize(): Promise<void> {
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes a string to a file asynchronously
|
||||
*/
|
||||
writeFileAsync(filename: string, contents: string): Promise<void> {
|
||||
return ipcRenderer
|
||||
.invoke("fs-job", {
|
||||
type: "write",
|
||||
filename,
|
||||
contents,
|
||||
})
|
||||
.catch(e => this.wrapError(e));
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads a string asynchronously
|
||||
*/
|
||||
readFileAsync(filename: string): Promise<string> {
|
||||
return ipcRenderer
|
||||
.invoke("fs-job", {
|
||||
type: "read",
|
||||
filename,
|
||||
})
|
||||
.catch(e => this.wrapError(e));
|
||||
}
|
||||
|
||||
/**
|
||||
* Tries to delete a file
|
||||
*/
|
||||
deleteFileAsync(filename: string): Promise<void> {
|
||||
return ipcRenderer
|
||||
.invoke("fs-job", {
|
||||
type: "delete",
|
||||
filename,
|
||||
})
|
||||
.catch(e => this.wrapError(e));
|
||||
}
|
||||
|
||||
private wrapError(err: unknown): Promise<never> {
|
||||
const message = err instanceof Error ? err.message : err.toString();
|
||||
return Promise.reject(new FsError(message, { cause: err }));
|
||||
}
|
||||
}
|
||||
@ -93,18 +93,18 @@ export class PlatformWrapperImplElectron {
|
||||
* @param {boolean} flag
|
||||
*/
|
||||
setFullscreen(flag) {
|
||||
ipcRenderer.send("set-fullscreen", flag);
|
||||
ipcRenderer.invoke("set-fullscreen", flag);
|
||||
}
|
||||
|
||||
getSupportsAppExit() {
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempts to quit the app
|
||||
*/
|
||||
exitApp() {
|
||||
logger.log(this, "Sending app exit signal");
|
||||
ipcRenderer.send("exit-app");
|
||||
window.close();
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
15
yarn.lock
15
yarn.lock
@ -367,10 +367,12 @@
|
||||
resolved "https://registry.yarnpkg.com/@types/node/-/node-18.11.10.tgz#4c64759f3c2343b7e6c4b9caf761c7a3a05cee34"
|
||||
integrity sha512-juG3RWMBOqcOuXC643OAdSA525V44cVgGV6dUDuiFtss+8Fk5x1hI93Rsld43VeJVIeqlP9I7Fn9/qaVqoEAuQ==
|
||||
|
||||
"@types/node@^16.0.0":
|
||||
version "16.18.4"
|
||||
resolved "https://registry.yarnpkg.com/@types/node/-/node-16.18.4.tgz#712ba61b4caf091fc6490301b1888356638c17bd"
|
||||
integrity sha512-9qGjJ5GyShZjUfx2ArBIGM+xExdfLvvaCyQR0t6yRXKPcWCVYF/WemtX/uIU3r7FYECXRXkIiw2Vnhn6y8d+pw==
|
||||
"@types/node@20.14.*":
|
||||
version "20.14.15"
|
||||
resolved "https://registry.yarnpkg.com/@types/node/-/node-20.14.15.tgz#e59477ab7bc7db1f80c85540bfd192a0becc588b"
|
||||
integrity sha512-Fz1xDMCF/B00/tYSVMlmK7hVeLh7jE5f3B7X1/hmV0MJBwE27KlS7EvD/Yp+z1lm8mVhwV5w+n8jOZG8AfTlKw==
|
||||
dependencies:
|
||||
undici-types "~5.26.4"
|
||||
|
||||
"@types/q@^1.5.1":
|
||||
version "1.5.5"
|
||||
@ -9864,6 +9866,11 @@ undertaker@^1.2.1:
|
||||
object.reduce "^1.0.0"
|
||||
undertaker-registry "^1.0.0"
|
||||
|
||||
undici-types@~5.26.4:
|
||||
version "5.26.5"
|
||||
resolved "https://registry.yarnpkg.com/undici-types/-/undici-types-5.26.5.tgz#bcd539893d00b56e964fd2657a4866b221a65617"
|
||||
integrity sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==
|
||||
|
||||
union-value@^1.0.0:
|
||||
version "1.0.1"
|
||||
resolved "https://registry.yarnpkg.com/union-value/-/union-value-1.0.1.tgz#0b6fe7b835aecda61c6ea4d4f02c14221e109847"
|
||||
|
||||
Loading…
Reference in New Issue
Block a user