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.
; }