mirror of
https://github.com/tobspr/shapez.io.git
synced 2025-12-09 16:21:51 +00:00
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.
This commit is contained in:
parent
321f517593
commit
ad8e39bdf4
72
electron/package-lock.json
generated
72
electron/package-lock.json
generated
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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"
|
||||
}
|
||||
|
||||
@ -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" };
|
||||
});
|
||||
|
||||
@ -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<ModSource, ModLocator>();
|
||||
@ -37,6 +60,7 @@ export class ModLoader {
|
||||
|
||||
async loadMods(): Promise<void> {
|
||||
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);
|
||||
|
||||
@ -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<void> {
|
||||
@ -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<void> {
|
||||
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
|
||||
|
||||
38
electron/src/mods/metadata.ts
Normal file
38
electron/src/mods/metadata.ts
Normal file
@ -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<typeof ModMetadata>;
|
||||
export type IpcModMetadata = Omit<ModMetadata, "version"> & {
|
||||
version: string;
|
||||
};
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -3,6 +3,7 @@
|
||||
"include": ["./src/**/*"],
|
||||
"compilerOptions": {
|
||||
"noEmit": false,
|
||||
"outDir": "./dist"
|
||||
"outDir": "./dist",
|
||||
"strict": true
|
||||
}
|
||||
}
|
||||
|
||||
7
src/js/mods/disabled_mod.ts
Normal file
7
src/js/mods/disabled_mod.ts
Normal file
@ -0,0 +1,7 @@
|
||||
import { Mod } from "./mod";
|
||||
|
||||
export class DisabledMod extends Mod {
|
||||
init(): void | Promise<void> {
|
||||
// Do nothing
|
||||
}
|
||||
}
|
||||
11
src/js/mods/errored_mod.ts
Normal file
11
src/js/mods/errored_mod.ts
Normal file
@ -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<void> {
|
||||
// Do nothing
|
||||
}
|
||||
}
|
||||
@ -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<void>} 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;
|
||||
}
|
||||
}
|
||||
46
src/js/mods/mod.ts
Normal file
46
src/js/mods/mod.ts
Normal file
@ -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<void>;
|
||||
|
||||
get dialogs() {
|
||||
return this.modInterface.dialogs;
|
||||
}
|
||||
}
|
||||
38
src/js/mods/mod_metadata.d.ts
vendored
Normal file
38
src/js/mods/mod_metadata.d.ts
vendored
Normal file
@ -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<ModMetadata> {
|
||||
authors: ReadonlyArray<Readonly<ModAuthor>>;
|
||||
}
|
||||
@ -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();
|
||||
172
src/js/mods/modloader.ts
Normal file
172
src/js/mods/modloader.ts
Normal file
@ -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<string, ModInfo>();
|
||||
|
||||
// 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<Mod> {
|
||||
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<Mod> {
|
||||
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();
|
||||
@ -30,7 +30,7 @@ export class MainMenuState extends GameState {
|
||||
}
|
||||
|
||||
getInnerHTML() {
|
||||
const hasMods = MODS.anyModsActive();
|
||||
const hasMods = MODS.allMods.length > 0;
|
||||
|
||||
return `
|
||||
<div class="topButtons">
|
||||
@ -77,16 +77,10 @@ export class MainMenuState extends GameState {
|
||||
<button class="styledButton editMods"></button>
|
||||
</div>
|
||||
<div class="modsList">
|
||||
${MODS.mods
|
||||
.map(mod => {
|
||||
return `
|
||||
<div class="mod">
|
||||
<div class="name">${mod.metadata.name}</div>
|
||||
<div class="author">by ${mod.metadata.author}</div>
|
||||
</div>
|
||||
`;
|
||||
})
|
||||
.join("")}
|
||||
<div class="mod">
|
||||
<div class="name">Mod support in progress</div>
|
||||
<div class="author">Not implemented yet</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="dlcHint">
|
||||
|
||||
@ -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 (
|
||||
<div class="content">
|
||||
<div class={`modsGrid${MODS.anyModsActive() ? "" : " noMods"}`}>
|
||||
{MODS.anyModsActive() ? modElements : this.getNoModsMessage()}
|
||||
</div>
|
||||
<div class={`modsGrid ${hasMods ? "" : "noMods"}`}>{modElements}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -29,7 +34,7 @@ export class ModsState extends TextualGameState {
|
||||
return (
|
||||
<div class="mod">
|
||||
<div class="title">
|
||||
<b>{mod.metadata.name}</b> by <i>{mod.metadata.author}</i>
|
||||
<b>{mod.metadata.name}</b> by <i>{this.formatAuthors(mod.metadata.authors)}</i>
|
||||
</div>
|
||||
<div class="description">{mod.metadata.description}</div>
|
||||
<div class="advanced">
|
||||
@ -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 <div class="noModsMessage">No mods are currently installed.</div>;
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user