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"