1
0
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:
Даниїл Григор'єв 2025-03-21 16:30:13 +02:00 committed by GitHub
parent 17a18fce41
commit c836589d9b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
22 changed files with 490 additions and 492 deletions

2
.gitignore vendored
View File

@ -58,3 +58,5 @@ tmp
src/js/built-temp
translations/tmp
gulp/additional_build_files
electron/dist

View File

@ -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;
});

View File

@ -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 ---

View File

@ -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
View 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
View 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
View 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
View 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
View 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
View File

@ -0,0 +1,8 @@
{
"extends": "../tsconfig.json",
"include": ["./src/**/*"],
"compilerOptions": {
"noEmit": false,
"outDir": "./dist"
}
}

View File

@ -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"

View File

@ -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
View 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.");
}
}

View File

@ -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",

View File

@ -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

View 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 {

View 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";
}
}

View File

@ -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,
});
}
}

View 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 }));
}
}

View File

@ -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();
}
/**

View File

@ -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"