From c836589d9be053c084f113c599fc61fa30cfc4fc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=B0=D0=BD=D0=B8=D1=97=D0=BB=20=D0=93=D1=80=D0=B8?= =?UTF-8?q?=D0=B3=D0=BE=D1=80=27=D1=94=D0=B2?= Date: Fri, 21 Mar 2025 16:30:13 +0200 Subject: [PATCH] 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. --- .gitignore | 2 + electron/index.js | 335 --------------------------- electron/mods/README.txt | 6 - electron/package.json | 12 +- electron/{preload.js => preload.cjs} | 0 electron/src/config.ts | 20 ++ electron/src/fsjob.ts | 70 ++++++ electron/src/index.ts | 88 +++++++ electron/src/ipc.ts | 34 +++ electron/src/mods.ts | 74 ++++++ electron/tsconfig.json | 8 + electron/yarn.lock | 25 -- gulp/standalone.js | 59 ++--- gulp/typescript.js | 66 ++++++ package.json | 2 +- src/js/core/read_write_proxy.js | 4 +- src/js/mods/modloader.js | 8 +- src/js/platform/fs_error.ts | 23 ++ src/js/platform/storage.js | 66 ------ src/js/platform/storage.ts | 59 +++++ src/js/platform/wrapper.js | 6 +- yarn.lock | 15 +- 22 files changed, 490 insertions(+), 492 deletions(-) delete mode 100644 electron/index.js delete mode 100644 electron/mods/README.txt rename electron/{preload.js => preload.cjs} (100%) create mode 100644 electron/src/config.ts create mode 100644 electron/src/fsjob.ts create mode 100644 electron/src/index.ts create mode 100644 electron/src/ipc.ts create mode 100644 electron/src/mods.ts create mode 100644 electron/tsconfig.json create mode 100644 gulp/typescript.js create mode 100644 src/js/platform/fs_error.ts delete mode 100644 src/js/platform/storage.js create mode 100644 src/js/platform/storage.ts diff --git a/.gitignore b/.gitignore index 257ed51d..662d9091 100644 --- a/.gitignore +++ b/.gitignore @@ -58,3 +58,5 @@ tmp src/js/built-temp translations/tmp gulp/additional_build_files + +electron/dist diff --git a/electron/index.js b/electron/index.js deleted file mode 100644 index db60e086..00000000 --- a/electron/index.js +++ /dev/null @@ -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; -}); diff --git a/electron/mods/README.txt b/electron/mods/README.txt deleted file mode 100644 index 666cc18f..00000000 --- a/electron/mods/README.txt +++ /dev/null @@ -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 --- diff --git a/electron/package.json b/electron/package.json index 3371a784..b19c7d38 100644 --- a/electron/package.json +++ b/electron/package.json @@ -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" } } diff --git a/electron/preload.js b/electron/preload.cjs similarity index 100% rename from electron/preload.js rename to electron/preload.cjs diff --git a/electron/src/config.ts b/electron/src/config.ts new file mode 100644 index 00000000..129dde77 --- /dev/null +++ b/electron/src/config.ts @@ -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"), +}; diff --git a/electron/src/fsjob.ts b/electron/src/fsjob.ts new file mode 100644 index 00000000..6672ad8d --- /dev/null +++ b/electron/src/fsjob.ts @@ -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 { + 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 { + // Bare-bones implementation + return fs.readdir(subdir); + } + + private read(file: string): Promise { + return fs.readFile(file, "utf-8"); + } + + private async write(file: string, contents: string): Promise { + // Backups not implemented yet. + await fs.writeFile(file, contents, { + encoding: "utf-8", + flush: true, + }); + return contents; + } + + private delete(file: string): Promise { + 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); + } +} diff --git a/electron/src/index.ts b/electron/src/index.ts new file mode 100644 index 00000000..5962a2db --- /dev/null +++ b/electron/src/index.ts @@ -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(); +}); diff --git a/electron/src/ipc.ts b/electron/src/ipc.ts new file mode 100644 index 00000000..ffc7a6f8 --- /dev/null +++ b/electron/src/ipc.ts @@ -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); + } +} diff --git a/electron/src/mods.ts b/electron/src/mods.ts new file mode 100644 index 00000000..ce57596d --- /dev/null +++ b/electron/src/mods.ts @@ -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 { + 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 { + 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 { + 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)); + } +} diff --git a/electron/tsconfig.json b/electron/tsconfig.json new file mode 100644 index 00000000..e8f72323 --- /dev/null +++ b/electron/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../tsconfig.json", + "include": ["./src/**/*"], + "compilerOptions": { + "noEmit": false, + "outDir": "./dist" + } +} diff --git a/electron/yarn.lock b/electron/yarn.lock index cb67c4a1..ada2f8aa 100644 --- a/electron/yarn.lock +++ b/electron/yarn.lock @@ -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" diff --git a/gulp/standalone.js b/gulp/standalone.js index 40f9452d..af6ea75e 100644 --- a/gulp/standalone.js +++ b/gulp/standalone.js @@ -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, }, diff --git a/gulp/typescript.js b/gulp/typescript.js new file mode 100644 index 00000000..48cb7f72 --- /dev/null +++ b/gulp/typescript.js @@ -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."); + } +} diff --git a/package.json b/package.json index 0078f5fb..01eaca86 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/js/core/read_write_proxy.js b/src/js/core/read_write_proxy.js index 02afa2a2..2bc31396 100644 --- a/src/js/core/read_write_proxy.js +++ b/src/js/core/read_write_proxy.js @@ -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 diff --git a/src/js/mods/modloader.js b/src/js/mods/modloader.js index 61b80112..2e2b8845 100644 --- a/src/js/mods/modloader.js +++ b/src/js/mods/modloader.js @@ -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 { diff --git a/src/js/platform/fs_error.ts b/src/js/platform/fs_error.ts new file mode 100644 index 00000000..9e15aa31 --- /dev/null +++ b/src/js/platform/fs_error.ts @@ -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"; + } +} diff --git a/src/js/platform/storage.js b/src/js/platform/storage.js deleted file mode 100644 index ac2be5be..00000000 --- a/src/js/platform/storage.js +++ /dev/null @@ -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} - */ - initialize() { - return Promise.resolve(); - } - - /** - * Writes a string to a file asynchronously - * @param {string} filename - * @param {string} contents - * @returns {Promise} - */ - writeFileAsync(filename, contents) { - return ipcRenderer.invoke("fs-job", { - type: "write", - filename, - contents, - }); - } - - /** - * Reads a string asynchronously. Returns Promise if file was not found. - * @param {string} filename - * @returns {Promise} - */ - 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} - */ - deleteFileAsync(filename) { - return ipcRenderer.invoke("fs-job", { - type: "delete", - filename, - }); - } -} diff --git a/src/js/platform/storage.ts b/src/js/platform/storage.ts new file mode 100644 index 00000000..250a0ee2 --- /dev/null +++ b/src/js/platform/storage.ts @@ -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 { + return Promise.resolve(); + } + + /** + * Writes a string to a file asynchronously + */ + writeFileAsync(filename: string, contents: string): Promise { + return ipcRenderer + .invoke("fs-job", { + type: "write", + filename, + contents, + }) + .catch(e => this.wrapError(e)); + } + + /** + * Reads a string asynchronously + */ + readFileAsync(filename: string): Promise { + return ipcRenderer + .invoke("fs-job", { + type: "read", + filename, + }) + .catch(e => this.wrapError(e)); + } + + /** + * Tries to delete a file + */ + deleteFileAsync(filename: string): Promise { + return ipcRenderer + .invoke("fs-job", { + type: "delete", + filename, + }) + .catch(e => this.wrapError(e)); + } + + private wrapError(err: unknown): Promise { + const message = err instanceof Error ? err.message : err.toString(); + return Promise.reject(new FsError(message, { cause: err })); + } +} diff --git a/src/js/platform/wrapper.js b/src/js/platform/wrapper.js index 92b64b84..2facbde0 100644 --- a/src/js/platform/wrapper.js +++ b/src/js/platform/wrapper.js @@ -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(); } /** diff --git a/yarn.lock b/yarn.lock index 3cd4bf19..89b1f334 100644 --- a/yarn.lock +++ b/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"