diff --git a/electron/src/config.ts b/electron/src/config.ts index 4cbad037..a131f8d4 100644 --- a/electron/src/config.ts +++ b/electron/src/config.ts @@ -1,4 +1,5 @@ import { app } from "electron"; +import path from "node:path"; const disabledFeatures = ["HardwareMediaKeyHandling"]; app.commandLine.appendSwitch("disable-features", disabledFeatures.join(",")); @@ -9,6 +10,7 @@ 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 executableDir = path.dirname(app.getPath("exe")); export const pageUrl = app.isPackaged ? new URL("../index.html", import.meta.url).href diff --git a/electron/src/index.ts b/electron/src/index.ts index c2459977..111e0b42 100644 --- a/electron/src/index.ts +++ b/electron/src/index.ts @@ -3,7 +3,8 @@ import path from "path"; import { defaultWindowTitle, pageUrl, switches } from "./config.js"; import { FsJobHandler } from "./fsjob.js"; import { IpcHandler } from "./ipc.js"; -import { ModsHandler } from "./mods.js"; +import { ModLoader } from "./mods/loader.js"; +import { ModProtocolHandler } from "./mods/protocol_handler.js"; let win: BrowserWindow | null = null; @@ -25,10 +26,14 @@ if (!app.requestSingleInstanceLock()) { // 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); +const modLoader = new ModLoader(); +const modProtocol = new ModProtocolHandler(modLoader); +const ipc = new IpcHandler(fsJob, modLoader); function createWindow() { + // The protocol can only be handled after "ready" event + modProtocol.install(); + win = new BrowserWindow({ minWidth: 800, minHeight: 600, @@ -87,8 +92,6 @@ function openExternalUrl(urlString: string) { } } -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 index 8d23bd14..43a6b0d4 100644 --- a/electron/src/ipc.ts +++ b/electron/src/ipc.ts @@ -1,14 +1,14 @@ import { BrowserWindow, IpcMainInvokeEvent, ipcMain } from "electron"; import { FsJob, FsJobHandler } from "./fsjob.js"; -import { ModsHandler } from "./mods.js"; +import { ModLoader } from "./mods/loader.js"; export class IpcHandler { private readonly fsJob: FsJobHandler; - private readonly mods: ModsHandler; + private readonly modLoader: ModLoader; - constructor(fsJob: FsJobHandler, mods: ModsHandler) { + constructor(fsJob: FsJobHandler, modLoader: ModLoader) { this.fsJob = fsJob; - this.mods = mods; + this.modLoader = modLoader; } install(window: BrowserWindow) { @@ -24,8 +24,10 @@ export class IpcHandler { return this.fsJob.handleJob(job); } - private getMods() { - return this.mods.getMods(); + private async getMods() { + // TODO: Split mod reloads into a different IPC request + await this.modLoader.loadMods(); + return this.modLoader.getAllMods(); } private setFullscreen(window: BrowserWindow, _event: IpcMainInvokeEvent, flag: boolean) { diff --git a/electron/src/mods.ts b/electron/src/mods.ts deleted file mode 100644 index ce57596d..00000000 --- a/electron/src/mods.ts +++ /dev/null @@ -1,74 +0,0 @@ -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/src/mods/loader.ts b/electron/src/mods/loader.ts new file mode 100644 index 00000000..79917906 --- /dev/null +++ b/electron/src/mods/loader.ts @@ -0,0 +1,114 @@ +import fs from "node:fs/promises"; +import path from "node:path"; +import { DevelopmentModLocator, DistroModLocator, ModLocator, UserModLocator } from "./locator.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; +} + +interface DisabledMod { + source: ModSource; + id: string; +} + +interface Mod extends ModLocation { + disabled: boolean; + metadata: ModMetadata; +} + +const METADATA_FILE = "mod.json"; + +export class ModLoader { + private mods: Mod[] = []; + private readonly locators = new Map(); + + constructor() { + this.locators.set("user", new UserModLocator()); + this.locators.set("distro", new DistroModLocator()); + this.locators.set("dev", new DevelopmentModLocator()); + } + + async loadMods(): Promise { + const mods: Mod[] = []; + + const locations = await this.locateAllMods(); + for (const location of locations) { + const metadata = await this.resolveMetadata(location); + if (metadata === null) { + continue; + } + + mods.push({ + ...location, + disabled: false, + metadata, + }); + } + + // Check for mods that should be disabled + for (const { source, id } of await this.collectDisabledMods()) { + const target = mods.find(m => m.source === source && m.metadata.id === id); + if (target !== undefined) { + 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]; + } + + getModById(id: string): Mod | undefined { + return this.mods.find(mod => mod.metadata.id === id); + } + + private async locateAllMods(): Promise { + // Sort locators by priority, lowest number is highest priority + const locators = [...this.locators.entries()].sort(([, a], [, b]) => a.priority - b.priority); + const result: ModLocation[] = []; + + for (const [source, locator] of locators) { + for (const file of await locator.locateMods()) { + result.push({ source, file }); + } + } + + return result; + } + + private async resolveMetadata(mod: ModLocation): Promise { + // TODO: This function might call validation routines + const filePath = path.join(mod.file, METADATA_FILE); + try { + const contents = await fs.readFile(filePath, "utf-8"); + return 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); + return null; + } + } + + private async collectDisabledMods(): Promise { + const result: DisabledMod[] = []; + + for (const [source, locator] of this.locators.entries()) { + for (const id of await locator.getDisabledMods()) { + result.push({ source, id }); + } + } + + return result; + } +} diff --git a/electron/src/mods/locator.ts b/electron/src/mods/locator.ts new file mode 100644 index 00000000..18f7d2ec --- /dev/null +++ b/electron/src/mods/locator.ts @@ -0,0 +1,195 @@ +import { app } from "electron"; +import fs from "node:fs/promises"; +import path from "node:path"; +import { executableDir, userData } from "../config.js"; + +export const MOD_FILE_SUFFIX = ".asar"; + +const DISABLED_MODS_FILE = "disabled-mods.json"; +const USER_MODS_DIR = path.join(userData, "mods"); +const DISTRO_MODS_DIR = path.join(executableDir, "mods"); + +const DEV_SWITCH = "load-mod"; +const DEV_USER_MOD_PREFIX = "@/"; + +export interface ModLocator { + readonly priority: number; + + /** + * Asynchronously look for mod candidates. + * + * @returns absolute file paths of located mods + */ + locateMods(): Promise; + + /** + * Mark or unmark the specified mod as disabled. + * + * @param id ID of the mod to disable or enable + * @param flag whether to disable the mod + */ + setModDisabled(id: string, flag: boolean): Promise; + + /** + * Retrieve the list of mod IDs that should not be loaded. + * + * @returns IDs of the disabled mods + */ + getDisabledMods(): Promise; +} + +abstract class DirectoryModLocator implements ModLocator { + abstract readonly priority: number; + + protected readonly directory: string; + private readonly disabledModsFile: string; + private disabledMods: Set | null = null; + + constructor(directory: string) { + this.directory = directory; + this.disabledModsFile = path.join(directory, DISABLED_MODS_FILE); + } + + async locateMods(): Promise { + try { + const dir = await fs.readdir(this.directory, { withFileTypes: true }); + return dir + .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") { + // The directory does not exist + return []; + } + + // Propagate all other errors + throw err; + } + } + + setModDisabled(id: string, flag: boolean): Promise { + // Note: it is assumed that calling this before accessing + // getDisabledMods will overwrite the file. + this.disabledMods ??= new Set(); + + if (flag) { + this.disabledMods.add(id); + } else { + this.disabledMods.delete(id); + } + + return this.writeDisabledModsFile(); + } + + async getDisabledMods(): Promise { + if (this.disabledMods === null) { + await this.readDisabledModsFile(); + } + + return [...this.disabledMods]; + } + + private async readDisabledModsFile(): Promise { + // TODO: Validate internal structure (once something is added for + // mod metadata file validation) + + try { + const contents = await fs.readFile(this.disabledModsFile, "utf-8"); + this.disabledMods = new Set(JSON.parse(contents)); + } catch (err) { + // Ensure we don't fail twice + this.disabledMods ??= new Set(); + + if ("code" in err && err.code == "ENOENT") { + // Ignore error entirely if the file is missing + return; + } + + if (err instanceof SyntaxError) { + // Malformed JSON, replace the file + return this.writeDisabledModsFile(); + } + + console.warn(`Reading ${this.disabledModsFile} failed:`, err); + } + } + + private async writeDisabledModsFile(): Promise { + try { + const contents = JSON.stringify([...this.disabledMods]); + await fs.writeFile(this.disabledModsFile, contents, "utf-8"); + } catch (err: unknown) { + // Nothing we can do + console.warn(`Writing ${this.disabledModsFile} failed:`, err); + } + } +} + +export class UserModLocator extends DirectoryModLocator { + readonly priority = 1; + + constructor() { + super(USER_MODS_DIR); + } + + async locateMods(): Promise { + // Ensure the directory exists + await fs.mkdir(this.directory, { recursive: true }); + return super.locateMods(); + } +} + +export class DistroModLocator extends DirectoryModLocator { + readonly priority = 2; + + constructor() { + super(DISTRO_MODS_DIR); + } +} + +export class DevelopmentModLocator implements ModLocator { + readonly priority = 0; + + private readonly modFiles: string[] = []; + private readonly disabledMods = new Set(); + + constructor() { + const switchValue = app.commandLine.getSwitchValue(DEV_SWITCH); + if (switchValue === "") { + // Empty string = switch not passed + return; + } + + const resolved = switchValue.split(",").map(f => this.resolveFile(f)); + this.modFiles.push(...resolved); + } + + locateMods(): Promise { + return Promise.resolve(this.modFiles); + } + + setModDisabled(id: string, flag: boolean): Promise { + if (flag) { + this.disabledMods.add(id); + } else { + this.disabledMods.delete(id); + } + + return Promise.resolve(); + } + + getDisabledMods(): Promise { + return Promise.resolve([...this.disabledMods]); + } + + private resolveFile(file: string) { + // Allow using @/*.asar to reference user mods directory + if (file.startsWith(DEV_USER_MOD_PREFIX)) { + file = file.slice(DEV_USER_MOD_PREFIX.length); + return path.join(USER_MODS_DIR, file); + } + + // Resolve mods relative to CWD, useful for development + return path.resolve(file); + } +} diff --git a/electron/src/mods/protocol_handler.ts b/electron/src/mods/protocol_handler.ts new file mode 100644 index 00000000..957075a6 --- /dev/null +++ b/electron/src/mods/protocol_handler.ts @@ -0,0 +1,72 @@ +import { net, protocol } from "electron"; +import path from "node:path"; +import { pathToFileURL } from "node:url"; +import { ModLoader } from "./loader.js"; + +export const MOD_SCHEME = "mod"; + +export class ModProtocolHandler { + private modLoader: ModLoader; + + constructor(modLoader: ModLoader) { + this.modLoader = modLoader; + + protocol.registerSchemesAsPrivileged([ + { + scheme: MOD_SCHEME, + privileges: { + allowServiceWorkers: true, + bypassCSP: true, + secure: true, + standard: true, + stream: true, + supportFetchAPI: true, + }, + }, + ]); + } + + install() { + protocol.handle(MOD_SCHEME, this.handler.bind(this)); + } + + private async handler(request: GlobalRequest): Promise { + try { + const fileUrl = this.getFileUrlForRequest(request); + if (fileUrl === undefined) { + return new Response(undefined, { + status: 404, + statusText: "Not Found", + }); + } + + return net.fetch(fileUrl); + } catch { + return new Response(undefined, { + status: 400, + statusText: "Bad Request", + }); + } + } + + private getFileUrlForRequest(request: GlobalRequest): string | undefined { + // mod://mod-id/path/to/file + const modUrl = new URL(request.url); + const mod = this.modLoader.getModById(modUrl.hostname); + if (mod === undefined) { + return undefined; + } + + const bundle = mod.file; + const filePath = path.join(bundle, modUrl.pathname); + + // Check if the path escapes the bundle as per Electron example + // NOTE: this means file names cannot start with .. + const relative = path.relative(bundle, filePath); + if (relative === "" || relative.startsWith("..") || path.isAbsolute(relative)) { + return undefined; + } + + return pathToFileURL(filePath).toString(); + } +}