1
0
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:
Даниїл Григор'єв 2025-04-07 06:30:47 +03:00
parent 75e306ec59
commit f7d2ccccff
No known key found for this signature in database
GPG Key ID: B890DF16341D8C1D
7 changed files with 399 additions and 85 deletions

View File

@ -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

View File

@ -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();

View File

@ -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) {

View File

@ -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
View 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;
}
}

View 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);
}
}

View 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();
}
}