mirror of
https://github.com/tobspr/shapez.io.git
synced 2025-12-09 16:21:51 +00:00
Preliminary ASAR mods support in main process
Consider mods broken in this commit. Many areas are unfinished and use weird or missing types.
This commit is contained in:
parent
75e306ec59
commit
f7d2ccccff
@ -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
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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<Mod[]> {
|
||||
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<string[]> {
|
||||
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<string[]> {
|
||||
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));
|
||||
}
|
||||
}
|
||||
114
electron/src/mods/loader.ts
Normal file
114
electron/src/mods/loader.ts
Normal file
@ -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<ModSource, ModLocator>();
|
||||
|
||||
constructor() {
|
||||
this.locators.set("user", new UserModLocator());
|
||||
this.locators.set("distro", new DistroModLocator());
|
||||
this.locators.set("dev", new DevelopmentModLocator());
|
||||
}
|
||||
|
||||
async loadMods(): Promise<void> {
|
||||
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<ModLocation[]> {
|
||||
// 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<ModMetadata | null> {
|
||||
// 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<DisabledMod[]> {
|
||||
const result: DisabledMod[] = [];
|
||||
|
||||
for (const [source, locator] of this.locators.entries()) {
|
||||
for (const id of await locator.getDisabledMods()) {
|
||||
result.push({ source, id });
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
195
electron/src/mods/locator.ts
Normal file
195
electron/src/mods/locator.ts
Normal file
@ -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<string[]>;
|
||||
|
||||
/**
|
||||
* 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<void>;
|
||||
|
||||
/**
|
||||
* Retrieve the list of mod IDs that should not be loaded.
|
||||
*
|
||||
* @returns IDs of the disabled mods
|
||||
*/
|
||||
getDisabledMods(): Promise<string[]>;
|
||||
}
|
||||
|
||||
abstract class DirectoryModLocator implements ModLocator {
|
||||
abstract readonly priority: number;
|
||||
|
||||
protected readonly directory: string;
|
||||
private readonly disabledModsFile: string;
|
||||
private disabledMods: Set<string> | null = null;
|
||||
|
||||
constructor(directory: string) {
|
||||
this.directory = directory;
|
||||
this.disabledModsFile = path.join(directory, DISABLED_MODS_FILE);
|
||||
}
|
||||
|
||||
async locateMods(): Promise<string[]> {
|
||||
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<void> {
|
||||
// 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<string[]> {
|
||||
if (this.disabledMods === null) {
|
||||
await this.readDisabledModsFile();
|
||||
}
|
||||
|
||||
return [...this.disabledMods];
|
||||
}
|
||||
|
||||
private async readDisabledModsFile(): Promise<void> {
|
||||
// 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<void> {
|
||||
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<string[]> {
|
||||
// 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<string>();
|
||||
|
||||
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<string[]> {
|
||||
return Promise.resolve(this.modFiles);
|
||||
}
|
||||
|
||||
setModDisabled(id: string, flag: boolean): Promise<void> {
|
||||
if (flag) {
|
||||
this.disabledMods.add(id);
|
||||
} else {
|
||||
this.disabledMods.delete(id);
|
||||
}
|
||||
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
getDisabledMods(): Promise<string[]> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
72
electron/src/mods/protocol_handler.ts
Normal file
72
electron/src/mods/protocol_handler.ts
Normal file
@ -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<GlobalResponse> {
|
||||
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();
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user