From ad8e39bdf4a1e49d7ca03928c2691b141af63b87 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: Sat, 12 Apr 2025 20:06:03 +0300 Subject: [PATCH] WIP: Basic ASAR modding in renderer Also fix a few issues in Electron code. This is not as polished yet, UI from old mod support was reused for now and is likely broken. Mods can be loaded, but there isn't much QoL around the support for now. --- electron/package-lock.json | 72 ++++++--- electron/package.json | 6 +- electron/src/index.ts | 22 +-- electron/src/mods/loader.ts | 52 ++++--- electron/src/mods/locator.ts | 8 +- electron/src/mods/metadata.ts | 38 +++++ electron/src/mods/protocol_handler.ts | 15 +- electron/tsconfig.json | 3 +- src/js/mods/disabled_mod.ts | 7 + src/js/mods/errored_mod.ts | 11 ++ src/js/mods/mod.js | 36 ----- src/js/mods/mod.ts | 46 ++++++ src/js/mods/mod_metadata.d.ts | 38 +++++ src/js/mods/modloader.js | 211 -------------------------- src/js/mods/modloader.ts | 172 +++++++++++++++++++++ src/js/states/main_menu.js | 16 +- src/js/states/mods.tsx | 19 ++- 17 files changed, 446 insertions(+), 326 deletions(-) create mode 100644 electron/src/mods/metadata.ts create mode 100644 src/js/mods/disabled_mod.ts create mode 100644 src/js/mods/errored_mod.ts delete mode 100644 src/js/mods/mod.js create mode 100644 src/js/mods/mod.ts create mode 100644 src/js/mods/mod_metadata.d.ts delete mode 100644 src/js/mods/modloader.js create mode 100644 src/js/mods/modloader.ts diff --git a/electron/package-lock.json b/electron/package-lock.json index 75dc5876..9faab32c 100644 --- a/electron/package-lock.json +++ b/electron/package-lock.json @@ -8,8 +8,14 @@ "name": "electron", "version": "1.0.0", "license": "MIT", + "dependencies": { + "semver": "^7.7.1", + "zod": "^3.24.2" + }, "devDependencies": { - "electron": "^31.3.0" + "@types/semver": "^7.7.0", + "electron": "^31.3.0", + "typescript": "^5.8.2" } }, "node_modules/@electron/get": { @@ -34,6 +40,16 @@ "global-agent": "^3.0.0" } }, + "node_modules/@electron/get/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, "node_modules/@sindresorhus/is": { "version": "4.6.0", "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-4.6.0.tgz", @@ -110,6 +126,13 @@ "@types/node": "*" } }, + "node_modules/@types/semver": { + "version": "7.7.0", + "resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.7.0.tgz", + "integrity": "sha512-k107IF4+Xr7UHjwDc7Cfd6PRQfbdkiRabXGRjo07b4WyPahFBZCZ1sE+BNxYIJPPg73UkfOsVOLwqVc/6ETrIA==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/yauzl": { "version": "2.10.3", "resolved": "https://registry.npmjs.org/@types/yauzl/-/yauzl-2.10.3.tgz", @@ -449,20 +472,6 @@ "node": ">=10.0" } }, - "node_modules/global-agent/node_modules/semver": { - "version": "7.7.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.1.tgz", - "integrity": "sha512-hlq8tAfn0m/61p4BVRcPzIGr6LKiMwo4VM6dGi6pt4qcRkmNzTcWq6eCEjEh+qXjkMDvPlOFFSGwQjoEa6gyMA==", - "dev": true, - "license": "ISC", - "optional": true, - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/globalthis": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.4.tgz", @@ -764,13 +773,15 @@ } }, "node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "dev": true, + "version": "7.7.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.1.tgz", + "integrity": "sha512-hlq8tAfn0m/61p4BVRcPzIGr6LKiMwo4VM6dGi6pt4qcRkmNzTcWq6eCEjEh+qXjkMDvPlOFFSGwQjoEa6gyMA==", "license": "ISC", "bin": { "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" } }, "node_modules/semver-compare": { @@ -833,6 +844,20 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/typescript": { + "version": "5.8.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz", + "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, "node_modules/undici-types": { "version": "6.19.8", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.19.8.tgz", @@ -867,6 +892,15 @@ "buffer-crc32": "~0.2.3", "fd-slicer": "~1.1.0" } + }, + "node_modules/zod": { + "version": "3.24.2", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.24.2.tgz", + "integrity": "sha512-lY7CDW43ECgW9u1TcT3IoXHflywfVqDYze4waEz812jR/bZ8FHDsl7pFQoSZTz5N+2NqRXs8GBwnAwo3ZNxqhQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } } } } diff --git a/electron/package.json b/electron/package.json index c47d3cbd..13cfffa3 100644 --- a/electron/package.json +++ b/electron/package.json @@ -8,8 +8,12 @@ "scripts": { "start": "tsc && electron ." }, - "dependencies": {}, + "dependencies": { + "semver": "^7.7.1", + "zod": "^3.24.2" + }, "devDependencies": { + "@types/semver": "^7.7.0", "electron": "^31.3.0", "typescript": "^5.8.2" } diff --git a/electron/src/index.ts b/electron/src/index.ts index 111e0b42..8522b4f0 100644 --- a/electron/src/index.ts +++ b/electron/src/index.ts @@ -34,7 +34,7 @@ function createWindow() { // The protocol can only be handled after "ready" event modProtocol.install(); - win = new BrowserWindow({ + const window = new BrowserWindow({ minWidth: 800, minHeight: 600, useContentSize: true, @@ -46,24 +46,26 @@ function createWindow() { }, }); + win = window; + if (!switches.dev) { - win.removeMenu(); + window.removeMenu(); } - win.on("ready-to-show", () => { - win.show(); + window.on("ready-to-show", () => { + window.show(); if (switches.dev && !switches.hideDevtools) { - win.webContents.openDevTools(); + window.webContents.openDevTools(); } }); - ipc.install(win); - win.loadURL(pageUrl); + ipc.install(window); + window.loadURL(pageUrl); // Redirect any kind of main frame navigation to external applications - win.webContents.on("will-navigate", (ev, url) => { - if (url === win.webContents.getURL()) { + window.webContents.on("will-navigate", (ev, url) => { + if (url === window.webContents.getURL()) { // Avoid handling reloads externally return; } @@ -73,7 +75,7 @@ function createWindow() { }); // Also redirect window.open - win.webContents.setWindowOpenHandler(({ url }) => { + window.webContents.setWindowOpenHandler(({ url }) => { openExternalUrl(url); return { action: "deny" }; }); diff --git a/electron/src/mods/loader.ts b/electron/src/mods/loader.ts index 1906e3cb..434860ef 100644 --- a/electron/src/mods/loader.ts +++ b/electron/src/mods/loader.ts @@ -1,13 +1,10 @@ import fs from "node:fs/promises"; import path from "node:path"; import { DevelopmentModLocator, DistroModLocator, ModLocator, UserModLocator } from "./locator.js"; +import { IpcModMetadata, ModMetadata } from "./metadata.js"; type ModSource = "user" | "distro" | "dev"; -// FIXME: temporary type -// eslint-disable-next-line @typescript-eslint/no-explicit-any -type ModMetadata = any; - interface ModLocation { source: ModSource; file: string; @@ -18,13 +15,39 @@ interface DisabledMod { id: string; } -interface Mod extends ModLocation { +interface IpcMod extends ModLocation { disabled: boolean; - metadata: ModMetadata; + metadata: IpcModMetadata; } const METADATA_FILE = "mod.json"; +class Mod { + readonly source: ModSource; + readonly file: string; + readonly metadata: ModMetadata; + + disabled = false; + + constructor(source: ModSource, file: string, metadata: ModMetadata) { + this.source = source; + this.file = file; + this.metadata = metadata; + } + + toJSON(): IpcMod { + return { + source: this.source, + file: this.file, + disabled: this.disabled, + metadata: { + ...this.metadata, + version: this.metadata.version.format(), + }, + }; + } +} + export class ModLoader { private mods: Mod[] = []; private readonly locators = new Map(); @@ -37,6 +60,7 @@ export class ModLoader { async loadMods(): Promise { const mods: Mod[] = []; + this.mods = mods; const locations = await this.locateAllMods(); for (const location of locations) { @@ -51,11 +75,7 @@ export class ModLoader { continue; } - mods.push({ - ...location, - disabled: false, - metadata, - }); + mods.push(new Mod(location.source, location.file, metadata)); } // Check for mods that should be disabled @@ -65,14 +85,10 @@ export class ModLoader { target.disabled = true; } } - - this.mods = mods; } - getAllMods(): Mod[] { - // This is the IPC response handler for now - // FIXME: review the format of get-mods IPC message - return [...this.mods]; + getAllMods(): IpcMod[] { + return this.mods.map(mod => mod.toJSON()); } isModPresent(id: string): boolean { @@ -102,7 +118,7 @@ export class ModLoader { const filePath = path.join(mod.file, METADATA_FILE); try { const contents = await fs.readFile(filePath, "utf-8"); - return JSON.parse(contents); + return ModMetadata.parse(JSON.parse(contents)); } catch (err) { // TODO: Collect mod errors, show to the user once all mods are loaded console.error("Failed to read mod metadata", err); diff --git a/electron/src/mods/locator.ts b/electron/src/mods/locator.ts index 18f7d2ec..9fd85182 100644 --- a/electron/src/mods/locator.ts +++ b/electron/src/mods/locator.ts @@ -57,7 +57,7 @@ abstract class DirectoryModLocator implements ModLocator { .filter(entry => entry.name.endsWith(MOD_FILE_SUFFIX)) .map(entry => path.join(entry.path, entry.name)); } catch (err) { - if ("code" in err && err.code === "ENOENT") { + if ((err as NodeJS.ErrnoException).code === "ENOENT") { // The directory does not exist return []; } @@ -86,7 +86,7 @@ abstract class DirectoryModLocator implements ModLocator { await this.readDisabledModsFile(); } - return [...this.disabledMods]; + return [...this.disabledMods!]; } private async readDisabledModsFile(): Promise { @@ -100,7 +100,7 @@ abstract class DirectoryModLocator implements ModLocator { // Ensure we don't fail twice this.disabledMods ??= new Set(); - if ("code" in err && err.code == "ENOENT") { + if ((err as NodeJS.ErrnoException).code == "ENOENT") { // Ignore error entirely if the file is missing return; } @@ -116,7 +116,7 @@ abstract class DirectoryModLocator implements ModLocator { private async writeDisabledModsFile(): Promise { try { - const contents = JSON.stringify([...this.disabledMods]); + const contents = JSON.stringify([...(this.disabledMods ?? new Set())]); await fs.writeFile(this.disabledModsFile, contents, "utf-8"); } catch (err: unknown) { // Nothing we can do diff --git a/electron/src/mods/metadata.ts b/electron/src/mods/metadata.ts new file mode 100644 index 00000000..623bd5be --- /dev/null +++ b/electron/src/mods/metadata.ts @@ -0,0 +1,38 @@ +import SemVer from "semver/classes/semver.js"; +import { z } from "zod"; + +const semver = z.string().transform((str, ctx) => { + try { + return new SemVer(str); + } catch { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Not a valid SemVer version string", + }); + return z.NEVER; + } +}); + +// TBD: dependencies, icons, readme +export const ModMetadata = z.object({ + format: z.literal(1), + id: z.string().regex(/^[a-z0-9][a-z0-9_-]{0,48}[a-z0-9]$/g), + entry: z.string().nonempty(), + name: z.string().nonempty(), + description: z.ostring(), + authors: z + .object({ + name: z.string().nonempty(), + website: z.string().url().optional(), + }) + .array(), + version: semver, + savegameResident: z.boolean().default(true), + website: z.string().url().optional(), + source: z.string().url().optional(), +}); + +export type ModMetadata = z.infer; +export type IpcModMetadata = Omit & { + version: string; +}; diff --git a/electron/src/mods/protocol_handler.ts b/electron/src/mods/protocol_handler.ts index 957075a6..b6fc59db 100644 --- a/electron/src/mods/protocol_handler.ts +++ b/electron/src/mods/protocol_handler.ts @@ -34,18 +34,13 @@ export class ModProtocolHandler { try { const fileUrl = this.getFileUrlForRequest(request); if (fileUrl === undefined) { - return new Response(undefined, { - status: 404, - statusText: "Not Found", - }); + return Response.error(); } - return net.fetch(fileUrl); - } catch { - return new Response(undefined, { - status: 400, - statusText: "Bad Request", - }); + return await net.fetch(fileUrl); + } catch (err) { + console.error("Failed to fetch:", err); + return Response.error(); } } diff --git a/electron/tsconfig.json b/electron/tsconfig.json index e8f72323..2ababc88 100644 --- a/electron/tsconfig.json +++ b/electron/tsconfig.json @@ -3,6 +3,7 @@ "include": ["./src/**/*"], "compilerOptions": { "noEmit": false, - "outDir": "./dist" + "outDir": "./dist", + "strict": true } } diff --git a/src/js/mods/disabled_mod.ts b/src/js/mods/disabled_mod.ts new file mode 100644 index 00000000..8e78c3be --- /dev/null +++ b/src/js/mods/disabled_mod.ts @@ -0,0 +1,7 @@ +import { Mod } from "./mod"; + +export class DisabledMod extends Mod { + init(): void | Promise { + // Do nothing + } +} diff --git a/src/js/mods/errored_mod.ts b/src/js/mods/errored_mod.ts new file mode 100644 index 00000000..b7bfe9da --- /dev/null +++ b/src/js/mods/errored_mod.ts @@ -0,0 +1,11 @@ +import { Mod } from "./mod"; + +/** + * This {@link Mod} subclass is used to differentiate disabled mods and those + * that couldn't be parsed or constructed due to an error. + */ +export class ErroredMod extends Mod { + init(): void | Promise { + // Do nothing + } +} diff --git a/src/js/mods/mod.js b/src/js/mods/mod.js deleted file mode 100644 index cea152d8..00000000 --- a/src/js/mods/mod.js +++ /dev/null @@ -1,36 +0,0 @@ -/* typehints:start */ -import { Application } from "../application"; -import { ModLoader } from "./modloader"; -/* typehints:end */ - -import { MOD_SIGNALS } from "./mod_signals"; - -export class Mod { - /** - * @param {object} param0 - * @param {Application} param0.app - * @param {ModLoader} param0.modLoader - * @param {import("./modloader").ModMetadata} param0.meta - * @param {Object} param0.settings - * @param {() => Promise} param0.saveSettings - */ - constructor({ app, modLoader, meta, settings, saveSettings }) { - this.app = app; - this.modLoader = modLoader; - this.metadata = meta; - - this.signals = MOD_SIGNALS; - this.modInterface = modLoader.modInterface; - - this.settings = settings; - this.saveSettings = saveSettings; - } - - init() { - // to be overridden - } - - get dialogs() { - return this.modInterface.dialogs; - } -} diff --git a/src/js/mods/mod.ts b/src/js/mods/mod.ts new file mode 100644 index 00000000..4b756b60 --- /dev/null +++ b/src/js/mods/mod.ts @@ -0,0 +1,46 @@ +import { Application } from "@/application"; +import { ModInterface } from "./mod_interface"; +import { FrozenModMetadata, ModMetadata } from "./mod_metadata"; +import { MOD_SIGNALS } from "./mod_signals"; +import { ModLoader } from "./modloader"; + +export type ModConstructor = new (metadata: ModMetadata, app: Application, modLoader: ModLoader) => Mod; + +function freezeMetadata(metadata: ModMetadata): FrozenModMetadata { + // Note: Object.freeze doesn't create a copy of the object + for (const author of metadata.authors) { + Object.freeze(author); + } + + Object.freeze(metadata.authors); + return Object.freeze(metadata); +} + +export abstract class Mod { + // TODO: Review what properties are necessary while improving ModInterface + protected readonly app: Application; + protected readonly modLoader: ModLoader; + protected readonly modInterface: ModInterface; + protected readonly signals = MOD_SIGNALS; + + // Exposed for convenience + readonly id: string; + readonly metadata: FrozenModMetadata; + readonly errors: Error[] = []; + + constructor(metadata: ModMetadata, app: Application, modLoader: ModLoader) { + this.app = app; + this.modLoader = modLoader; + // TODO: ModInterface should accept the mod instance + this.modInterface = new ModInterface(modLoader); + + this.id = metadata.id; + this.metadata = freezeMetadata(metadata); + } + + abstract init(): void | Promise; + + get dialogs() { + return this.modInterface.dialogs; + } +} diff --git a/src/js/mods/mod_metadata.d.ts b/src/js/mods/mod_metadata.d.ts new file mode 100644 index 00000000..451d1916 --- /dev/null +++ b/src/js/mods/mod_metadata.d.ts @@ -0,0 +1,38 @@ +import { Mod } from "./mod"; + +export interface ModAuthor { + name: string; + website?: string; +} + +export interface ModMetadata { + // format: 1; + id: string; + entry: string; + name: string; + description?: string; + authors: ModAuthor[]; + version: string; + savegameResident: boolean; + website?: string; + source?: string; +} + +export type ModSource = "user" | "distro" | "dev"; + +export interface ModQueueEntry { + source: ModSource; + file: string; + disabled: boolean; + metadata: ModMetadata; +} + +export interface ModInfo { + source: ModSource; + file: string; + mod: Mod; +} + +export interface FrozenModMetadata extends Readonly { + authors: ReadonlyArray>; +} diff --git a/src/js/mods/modloader.js b/src/js/mods/modloader.js deleted file mode 100644 index 3458ce12..00000000 --- a/src/js/mods/modloader.js +++ /dev/null @@ -1,211 +0,0 @@ -import { GLOBAL_APP } from "@/core/globals"; -import { FsError } from "@/platform/fs_error"; -import { createLogger } from "../core/logging"; -import { Storage } from "../platform/storage"; -import { Mod } from "./mod"; -import { ModInterface } from "./mod_interface"; -import { MOD_SIGNALS } from "./mod_signals"; - -const LOG = createLogger("mods"); - -/** - * @typedef {{ - * name: string; - * version: string; - * author: string; - * website: string; - * description: string; - * id: string; - * settings: []; - * doesNotAffectSavegame?: boolean - * }} ModMetadata - */ - -export class ModLoader { - constructor() { - LOG.log("modloader created"); - - /** @type {Mod[]} */ - this.mods = []; - - this.modInterface = new ModInterface(this); - - /** @type {({ meta: ModMetadata, modClass: typeof Mod})[]} */ - this.modLoadQueue = []; - - this.initialized = false; - - this.signals = MOD_SIGNALS; - } - - get app() { - return GLOBAL_APP; - } - - anyModsActive() { - return this.mods.length > 0; - } - - /** - * - * @returns {import("../savegame/savegame_typedefs").SavegameStoredMods} - */ - getModsListForSavegame() { - return this.mods - .filter(mod => !mod.metadata.doesNotAffectSavegame) - .map(mod => ({ - id: mod.metadata.id, - version: mod.metadata.version, - website: mod.metadata.website, - name: mod.metadata.name, - author: mod.metadata.author, - })); - } - - /** - * - * @param {import("../savegame/savegame_typedefs").SavegameStoredMods} originalMods - */ - computeModDifference(originalMods) { - /** - * @type {import("../savegame/savegame_typedefs").SavegameStoredMods} - */ - let missing = []; - - const current = this.getModsListForSavegame(); - - originalMods.forEach(mod => { - for (let i = 0; i < current.length; ++i) { - const currentMod = current[i]; - if (currentMod.id === mod.id && currentMod.version === mod.version) { - current.splice(i, 1); - return; - } - } - missing.push(mod); - }); - - return { - missing, - extra: current, - }; - } - - exposeExports() { - const exports = {}; - const modules = import.meta.webpackContext("../", { - recursive: true, - regExp: /\.[jt]sx?$/, - exclude: /\.d\.ts$/, - }); - - Array.from(modules.keys()).forEach(key => { - /** @type {object} */ - const module = modules(key); - for (const member in module) { - if (member === "default") { - continue; - } - if (exports[member]) { - throw new Error("Duplicate export of " + member); - } - - Object.defineProperty(exports, member, { - get() { - return module[member]; - }, - }); - } - }); - - window.shapez = exports; - } - - async initMods() { - // Create a storage for reading mod settings - const storage = new Storage(this.app); - await storage.initialize(); - - 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); - - window.$shapez_registerMod = (modClass, meta) => { - if (this.initialized) { - throw new Error("Can't register mod after modloader is initialized"); - } - if (this.modLoadQueue.some(entry => entry.meta.id === meta.id)) { - console.warn("Not registering mod", meta, "since a mod with the same id is already loaded"); - return; - } - this.modLoadQueue.push({ - modClass, - meta, - }); - }; - - mods.forEach(modCode => { - modCode += ` - if (typeof Mod !== 'undefined') { - if (typeof METADATA !== 'object') { - throw new Error("No METADATA variable found"); - } - window.$shapez_registerMod(Mod, METADATA); - } - `; - try { - const func = new Function(modCode); - func(); - } catch (ex) { - console.error(ex); - alert("Failed to parse mod (launch with --dev for more info): \n\n" + ex); - } - }); - - delete window.$shapez_registerMod; - - for (let i = 0; i < this.modLoadQueue.length; i++) { - const { modClass, meta } = this.modLoadQueue[i]; - const modDataFile = "modsettings_" + meta.id + "__" + meta.version + ".json"; - - let settings = meta.settings; - - if (meta.settings) { - try { - const storedSettings = await storage.readFileAsync(modDataFile); - settings = JSON.parse(storedSettings); - } catch (ex) { - if (ex instanceof FsError && ex.isFileNotFound()) { - // Write default data - await storage.writeFileAsync(modDataFile, JSON.stringify(meta.settings)); - } else { - alert("Failed to load settings for " + meta.id + ", will use defaults:\n\n" + ex); - } - } - } - - try { - const mod = new modClass({ - app: this.app, - modLoader: this, - meta, - settings, - saveSettings: () => storage.writeFileAsync(modDataFile, JSON.stringify(mod.settings)), - }); - await mod.init(); - this.mods.push(mod); - } catch (ex) { - console.error(ex); - alert("Failed to initialize mods (launch with --dev for more info): \n\n" + ex); - } - } - - this.modLoadQueue = []; - this.initialized = true; - } -} - -export const MODS = new ModLoader(); diff --git a/src/js/mods/modloader.ts b/src/js/mods/modloader.ts new file mode 100644 index 00000000..26581b0e --- /dev/null +++ b/src/js/mods/modloader.ts @@ -0,0 +1,172 @@ +import { GLOBAL_APP } from "@/core/globals"; +import { SavegameStoredMods } from "@/savegame/savegame_typedefs"; +import { createLogger } from "../core/logging"; +import { DisabledMod } from "./disabled_mod"; +import { ErroredMod } from "./errored_mod"; +import { Mod, ModConstructor } from "./mod"; +import { ModInfo, ModMetadata, ModQueueEntry } from "./mod_metadata"; +import { MOD_SIGNALS } from "./mod_signals"; + +const LOG = createLogger("mods"); + +export class ModLoader { + private readonly mods = new Map(); + + // FIXME: Used for ModInterface, should be improved? + readonly signals = MOD_SIGNALS; + + constructor() { + LOG.log("modloader created"); + } + + get app() { + return GLOBAL_APP; + } + + get allMods(): ModInfo[] { + return [...this.mods.values()]; + } + + get activeMods(): ModInfo[] { + const mods: ModInfo[] = []; + for (const mod of this.mods.values()) { + if (mod.mod instanceof DisabledMod) { + continue; + } + + mods.push(mod); + } + + return mods; + } + + getModsListForSavegame(): SavegameStoredMods { + // FIXME: new implementation TBD + return this.activeMods + .filter(info => info.mod.metadata.savegameResident) + .map(({ mod }) => ({ + id: mod.metadata.id, + version: mod.metadata.version, + website: mod.metadata.website, + name: mod.metadata.name, + author: mod.metadata.authors.map(a => a.name).join(","), + })); + } + + computeModDifference(originalMods: SavegameStoredMods) { + // FIXME: new implementation TBD + const missing: SavegameStoredMods = []; + const current = this.getModsListForSavegame(); + + originalMods.forEach(mod => { + for (let i = 0; i < current.length; ++i) { + const currentMod = current[i]; + if (currentMod.id === mod.id && currentMod.version === mod.version) { + current.splice(i, 1); + return; + } + } + missing.push(mod); + }); + + return { + missing, + extra: current, + }; + } + + async initMods() { + this.exposeExports(); + const queue: ModQueueEntry[] = await ipcRenderer.invoke("get-mods"); + + // Mods can be parsed and constructed in parallel + const loadedMods = await Promise.all( + queue.map(async e => ({ entry: e, mod: await this.loadMod(e) })) + ); + + // Initialize all mods sequentially and collect errors + // TODO: Also collect early errors from the main process + for (const { entry, mod } of loadedMods) { + try { + await mod.init(); + } catch (err) { + if (err instanceof Error) { + mod.errors.push(err); + } + } + + this.mods.set(mod.id, { + source: entry.source, + file: entry.file, + mod, + }); + } + } + + private exposeExports() { + const exports = {}; + const modules = import.meta.webpackContext("../", { + recursive: true, + regExp: /\.[jt]sx?$/, + exclude: /\.d\.ts$/, + }); + + Array.from(modules.keys()).forEach(key => { + const module: object = modules(key); + for (const member in module) { + if (member === "default") { + continue; + } + if (exports[member]) { + throw new Error("Duplicate export of " + member); + } + + Object.defineProperty(exports, member, { + get() { + return module[member]; + }, + }); + } + }); + + window.shapez = exports; + } + + private async loadMod(entry: ModQueueEntry): Promise { + if (entry.disabled) { + return new DisabledMod(entry.metadata, this.app, this); + } + + try { + return await this.createModInstance(entry.metadata); + } catch (err) { + const mod = new ErroredMod(entry.metadata, this.app, this); + mod.errors.push(err instanceof Error ? err : new Error(err.toString())); + return mod; + } + } + + private async createModInstance(metadata: ModMetadata): Promise { + const url = this.getModEntryUrl(metadata); + const module = await import(/* webpackIgnore: true */ url); + + if (!(module.default?.prototype instanceof Mod)) { + throw new Error("Default export is not a Mod constructor"); + } + + const modClass: ModConstructor = module.default; + const mod = new modClass(metadata, this.app, this); + + if (mod.id !== metadata.id) { + throw new Error(`Mod was created with invalid ID "${mod.id}"`); + } + + return mod; + } + + private getModEntryUrl(mod: ModMetadata): string { + return `mod://${mod.id}/${mod.entry}`; + } +} + +export const MODS = new ModLoader(); diff --git a/src/js/states/main_menu.js b/src/js/states/main_menu.js index 31af52e2..ff154572 100644 --- a/src/js/states/main_menu.js +++ b/src/js/states/main_menu.js @@ -30,7 +30,7 @@ export class MainMenuState extends GameState { } getInnerHTML() { - const hasMods = MODS.anyModsActive(); + const hasMods = MODS.allMods.length > 0; return `
@@ -77,16 +77,10 @@ export class MainMenuState extends GameState {
- ${MODS.mods - .map(mod => { - return ` -
-
${mod.metadata.name}
-
by ${mod.metadata.author}
-
- `; - }) - .join("")} +
+
Mod support in progress
+
Not implemented yet
+
diff --git a/src/js/states/mods.tsx b/src/js/states/mods.tsx index eff89c36..cef657e6 100644 --- a/src/js/states/mods.tsx +++ b/src/js/states/mods.tsx @@ -1,4 +1,5 @@ import { Mod } from "@/mods/mod"; +import { ModAuthor } from "@/mods/mod_metadata"; import { MODS } from "@/mods/modloader"; import { TextualGameState } from "../core/textual_game_state"; import { T } from "../translations"; @@ -13,13 +14,17 @@ export class ModsState extends TextualGameState { } protected getInitialContent() { - const modElements = MODS.mods.map(mod => this.getModElement(mod)); + // TODO: implement proper UI for disabled, errored mods etc. + const modElements = MODS.allMods.map(info => this.getModElement(info.mod)); + const hasMods = modElements.length > 0; + + if (!hasMods) { + modElements.push(this.getNoModsMessage()); + } return (
-
- {MODS.anyModsActive() ? modElements : this.getNoModsMessage()} -
+
{modElements}
); } @@ -29,7 +34,7 @@ export class ModsState extends TextualGameState { return (
- {mod.metadata.name} by {mod.metadata.author} + {mod.metadata.name} by {this.formatAuthors(mod.metadata.authors)}
{mod.metadata.description}
@@ -39,6 +44,10 @@ export class ModsState extends TextualGameState { ); } + private formatAuthors(authors: readonly ModAuthor[]): string { + return authors.map(author => author.name).join(", "); + } + private getNoModsMessage(): HTMLElement { return
No mods are currently installed.
; }