diff --git a/.npmrc b/.npmrc new file mode 100644 index 0000000..7b1e8b3 --- /dev/null +++ b/.npmrc @@ -0,0 +1 @@ +allow-git=all \ No newline at end of file diff --git a/arguments_example.json b/arguments_example.json new file mode 100644 index 0000000..efa374e --- /dev/null +++ b/arguments_example.json @@ -0,0 +1,57 @@ +[ + { + "key": "DEV_MODE", + "description": "Activates the developer mode, enabling additional debug tools and shortcuts.", + "type": "boolean" + }, + { + "key": "CONFIGURATION_FILE", + "description": "Path to the configuration file to use.", + "type": "string" + }, + { + "key": "NO_CAMERA_MIN_ZOOM", + "description": "Disables the minimum zoom restriction on the camera.", + "type": "boolean" + }, + { + "key": "ONLY_ALLOWED_TEAM_TAB", + "description": "The tab number of the only tab allowed in the Fighter Management UI.", + "type": "number" + }, + { + "key": "ONLY_ALLOWED_LADDER_TAB", + "description": "The tab number of the only tab allowed in the Ladder UI one of [ONE_VS_ONE,EVOLUTION,TWO_VS_TWO,GUILD,TOURNAMENT,PRO]", + "type": "string" + }, + { + "key": "SKIP_TURN_NO_DELAY", + "description": "Bypass the delay before ending a turn.", + "type": "boolean" + }, + { + "key": "REPLAY_FILE_PATH", + "description": "Path to a replay file to load and play.", + "type": "string" + }, + { + "key": "WORLD_FADE", + "description": "Activates the world fade effect during transitions.", + "type": "boolean" + }, + { + "key": "HOT_RELOAD_EFFECT", + "description": "Enables hot reloading of visual effects.", + "type": "boolean" + }, + { + "key": "NO_CLASS_RESTRICTION", + "description": "Disables class restrictions for character creation or editing.", + "type": "boolean" + }, + { + "key": "RESTORE_CONSOLE_PATH", + "description": "Restores the last used console path on startup instead of defaulting to server.", + "type": "boolean" + } +] diff --git a/package-lock.json b/package-lock.json index e0cc8d0..1565856 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "arena-returns-launcher", - "version": "3.4.2", + "version": "3.5.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "arena-returns-launcher", - "version": "3.4.2", + "version": "3.5.1", "workspaces": [ "packages/*" ], diff --git a/packages/main/src/index.ts b/packages/main/src/index.ts index 9e309a2..e06f0dd 100644 --- a/packages/main/src/index.ts +++ b/packages/main/src/index.ts @@ -44,7 +44,7 @@ export async function initApp(initConfig: AppInitConfig) { createWindowManagerModule({ initConfig, openDevTools: import.meta.env.DEV, - }) + }), ) .init(disallowMultipleAppInstance()) .init(terminateAppOnLastWindowClose()) @@ -59,16 +59,18 @@ export async function initApp(initConfig: AppInitConfig) { .init( allowInternalOrigins( new Set( - initConfig.renderer instanceof URL ? [initConfig.renderer.origin] : [] - ) - ) + initConfig.renderer instanceof URL + ? [initConfig.renderer.origin] + : [], + ), + ), ) .init( allowExternalUrls( initConfig.renderer instanceof URL ? ALLOWED_EXTERNAL_ORIGINS - : new Set() - ) + : new Set(), + ), ); await moduleRunner; diff --git a/packages/main/src/modules/GameClient.ts b/packages/main/src/modules/GameClient.ts index 35a86fe..68fca6b 100644 --- a/packages/main/src/modules/GameClient.ts +++ b/packages/main/src/modules/GameClient.ts @@ -1,14 +1,62 @@ -import { ipcMain, app } from "electron"; +import { app, ipcMain } from "electron"; import { join } from "path"; -import { existsSync, mkdirSync } from "fs"; -import { chmodSync } from "fs"; -import { stat, chmod, readdir, readFile, appendFile } from "fs/promises"; -import { exec } from "child_process"; +import { chmodSync, existsSync, mkdirSync } from "fs"; +import { appendFile, chmod, readdir, readFile, stat } from "fs/promises"; +import { spawn } from "child_process"; import log from "electron-log"; -import { GameUpdater, GameSettings, ReplayFile } from "./GameUpdater.js"; +import { + GameSettings, + GameUpdater, + ReplayFile, + getPlatformManifestEntry, +} from "./GameUpdater.js"; import type { AppModule } from "../AppModule.js"; import type { ModuleContext } from "../ModuleContext.js"; +const splitCommandLineArgs = (argsString: string): string[] => { + const args: string[] = []; + let current = ""; + let quote: '"' | "'" | null = null; + let hasContent = false; + + for (let index = 0; index < argsString.length; index += 1) { + const character = argsString[index]; + + if (quote) { + if (character === quote) { + quote = null; + } else if ( + character === "\\" && + argsString[index + 1] !== undefined && + (argsString[index + 1] === quote || argsString[index + 1] === "\\") + ) { + current += argsString[index + 1]; + index += 1; + } else { + current += character; + } + continue; + } + + if (character === '"' || character === "'") { + quote = character; + hasContent = true; + } else if (/\s/.test(character)) { + if (hasContent) { + args.push(current); + current = ""; + hasContent = false; + } + } else { + current += character; + hasContent = true; + } + } + + if (hasContent) args.push(current); + return args; +}; + export class GameClient implements AppModule { private gameUpdater: GameUpdater | null = null; private gameClientPath: string; @@ -31,11 +79,14 @@ export class GameClient implements AppModule { // Register GameClient-specific IPC handlers ipcMain.handle("gameClient:launchGame", () => this.launchGame()); ipcMain.handle("gameClient:openReplaysFolder", () => - this.openReplaysFolder() + this.openReplaysFolder(), ); ipcMain.handle("gameClient:listReplays", () => this.listReplays()); ipcMain.handle("gameClient:launchReplayOffline", (_e, path) => - this.launchReplayOffline(path) + this.launchReplayOffline(path), + ); + ipcMain.handle("gameClient:getGameArgumentsDescriptor", () => + this.getGameArgumentsDescriptor(), ); } @@ -75,10 +126,6 @@ export class GameClient implements AppModule { await this.startJavaProcess({ mainClass: "com.ankamagames.dofusarena.client.DofusArenaClient", settings: this.currentSettings || undefined, - extraArgs: [ - "-ONLY_ALLOWED_TEAM_TAB=1", - "-ONLY_ALLOWED_LADDER_TAB=ONE_VS_ONE", - ], }); } @@ -94,7 +141,7 @@ export class GameClient implements AppModule { throw new Error( `Failed to open replays folder: ${ error instanceof Error ? error.message : "Unknown error" - }` + }`, ); } } @@ -153,7 +200,7 @@ export class GameClient implements AppModule { const gameConfigPath = join( this.gameClientPath, "game", - "config.properties" + "config.properties", ); try { @@ -174,11 +221,11 @@ export class GameClient implements AppModule { log.info("Adding dev mode proxy settings to config.properties"); await appendFile( gameConfigPath, - "\nproxyGroup_2=Localhost\nproxyAddresses_2=localhost:5555\n" + "\nproxyGroup_2=Localhost\nproxyAddresses_2=localhost:5555\n", ); await appendFile( gameConfigPath, - "\nproxyGroup_3=Staging\nproxyAddresses_3=minuit-staging.arenareturns.com:6666\n" + "\nproxyGroup_3=Staging\nproxyAddresses_3=minuit-staging.arenareturns.com:6666\n", ); } catch (error) { log.error("Failed to update config.properties for dev mode:", error); @@ -192,50 +239,40 @@ export class GameClient implements AppModule { extraArgs?: string[]; }): Promise { const { mainClass, settings, extraArgs = [] } = options; + const fullGameArgs = [ + ...(this.currentSettings?.devGameArgs + ? splitCommandLineArgs(this.currentSettings.devGameArgs).map( + (arg) => `-${arg}`, + ) + : []), + ...extraArgs, + ]; const gameDir = join(this.gameClientPath, "game"); const libDir = join(this.gameClientPath, "lib"); const jreDir = join(this.gameClientPath, "jre"); const nativesDir = join(this.gameClientPath, "natives"); if (!existsSync(gameDir)) throw new Error("Game directory not found"); - if (!existsSync(libDir)) throw new Error("Library directory not found"); if (!existsSync(jreDir)) throw new Error("JRE directory not found"); - if (!existsSync(nativesDir)) throw new Error("Natives directory not found"); - const libFiles = (await readdir(libDir)).filter((f) => f.endsWith(".jar")); - // FIXME: Gigahack since darwin relies on wine - const classpath = libFiles - .map((jar) => join(libDir, jar)) - .join( - process.platform === "win32" || process.platform === "darwin" - ? ";" - : ":" - ); const coreJarPath = join(gameDir, "core.jar"); // FIXME: Gigahack since darwin relies on wine - const fullClasspath = - classpath + - (process.platform === "win32" || process.platform === "darwin" - ? ";" - : ":") + - coreJarPath; - - let nativesPath: string; - switch (process.platform) { - case "win32": - nativesPath = join(nativesDir, "win32", "x64"); - break; - case "darwin": - nativesPath = join(nativesDir, "win32", "x64"); - // FIXME: Native macos build not yet available - // nativesPath = join(nativesDir, "darwin", "universal"); - break; - case "linux": - nativesPath = join(nativesDir, "linux", "x64"); - break; - default: - throw new Error(`Unsupported platform: ${process.platform}`); - } + const classpathSeparator = + process.platform === "win32" || process.platform === "darwin" ? ";" : ":"; + const libCP = existsSync(libDir) + ? (await readdir(libDir)) + .filter((file) => file.endsWith(".jar")) + .map((jar) => join(libDir, jar)) + : []; + + const fullClasspath = [ + ...libCP, + + join(nativesDir, "*"), //pseudo-natives library in jar format; needs to be passed to the cp + join(nativesDir, getPlatformManifestEntry(), "*"), + + coreJarPath, + ].join(classpathSeparator); let javaExecutable: string; switch (process.platform) { @@ -271,27 +308,26 @@ export class GameClient implements AppModule { "-XX:+UseG1GC", "-XX:G1NewSizePercent=20", "-XX:G1ReservePercent=20", + "-XX:ReservedCodeCacheSize=256m", "--add-exports", "java.desktop/sun.awt=ALL-UNNAMED", + "--enable-native-access=ALL-UNNAMED", "-Djava.net.preferIPv4Stack=true", - "-Dsun.awt.noerasebackground=true", - "-Dsun.java2d.noddraw=true", "-Dsun.java2d.dpiaware=false", "-Dsun.java2d.uiScale=1.0", - "-Djogl.disable.openglarbcontext", - `-Djava.library.path="${nativesPath}"`, + "-Djogl.disable.openglarbcontext=1", + "--sun-misc-unsafe-memory-access=allow" ]; if (settings?.devModeEnabled && settings?.devExtraJavaArgs) { javaArgs.push( - ...settings.devExtraJavaArgs - .split(" ") - .map((arg) => arg.trim()) - .filter((arg) => arg.length > 0) + ...splitCommandLineArgs(settings.devExtraJavaArgs), ); } - javaArgs.push("-cp", `"${fullClasspath}"`, mainClass, ...extraArgs); + javaArgs.push("-cp", fullClasspath, mainClass, ...fullGameArgs); + + log.info("Launching game with ", javaArgs, "in", gameDir, "and exe", javaExecutable) switch (process.platform) { case "win32": @@ -344,97 +380,91 @@ export class GameClient implements AppModule { private async launchJavaProcessWindows( javaExecutable: string, args: string[], - cwd: string + cwd: string, ): Promise { - return new Promise((resolve, reject) => { - const child = exec( - `"${javaExecutable}" ${args.join(" ")}`, - { cwd }, - (error) => { - if (error && !error.killed) { - log.error("Java process error:", error); - } - } - ); - if (child.pid) { - resolve(); - } else { - reject(new Error("Failed to start Java process")); - } - }); + return this.spawnJavaProcess(javaExecutable, args, cwd); } private async launchJavaProcessLinux( javaExecutable: string, args: string[], - cwd: string + cwd: string, ): Promise { - return new Promise((resolve, reject) => { - try { - chmodSync(javaExecutable, 0o755); - } catch (error) { - log.warn( - `Failed to set permissions on Java executable ${javaExecutable}:`, - error - ); - } - const child = exec( - `"${javaExecutable}" ${args.join(" ")}`, - { cwd }, - (error) => { - if (error && !error.killed) { - log.error("Java process error:", error); - } - } + try { + chmodSync(javaExecutable, 0o755); + } catch (error) { + log.warn( + `Failed to set permissions on Java executable ${javaExecutable}:`, + error, ); - if (child.pid) { - resolve(); - } else { - reject(new Error("Failed to start Java process")); - } - }); + } + + return this.spawnJavaProcess(javaExecutable, args, cwd); } private async launchJavaProcessDarwin( javaExecutable: string, args: string[], - cwd: string + cwd: string, ): Promise { - return new Promise((resolve, reject) => { - try { - chmodSync(javaExecutable, 0o755); - } catch (error) { - log.warn( - `Failed to set permissions on Java executable ${javaExecutable}:`, - error - ); - } + try { + chmodSync(javaExecutable, 0o755); + } catch (error) { + log.warn( + `Failed to set permissions on Java executable ${javaExecutable}:`, + error, + ); + } - // Ensure we have the full system PATH for finding wine - const env = { ...process.env }; - if (!env.PATH?.includes("/opt/homebrew/bin")) { - env.PATH = `${ - env.PATH || "" - }:/opt/homebrew/bin:/usr/local/bin:/opt/local/bin`; - } + // Ensure we have the full system PATH for finding wine + const env = { ...process.env }; + if (!env.PATH?.includes("/opt/homebrew/bin")) { + env.PATH = `${ + env.PATH || "" + }:/opt/homebrew/bin:/usr/local/bin:/opt/local/bin`; + } - const child = exec( - `wine "${javaExecutable}" ${args.join(" ")}`, - { cwd, env }, - (error) => { - if (error && !error.killed) { - log.error("Java process error:", error); - } + return this.spawnJavaProcess("wine", [javaExecutable, ...args], cwd, env); + } + + private spawnJavaProcess( + executable: string, + args: string[], + cwd: string, + env: NodeJS.ProcessEnv = process.env, + ): Promise { + return new Promise((resolve, reject) => { + const child = spawn(executable, args, { cwd, env, stdio: "ignore" }); + + child.once("error", reject); + child.once("spawn", resolve); + child.once("exit", (code, signal) => { + if (code !== 0) { + log.error( + `Java process exited with code ${code ?? "unknown"}` + + (signal ? ` (signal: ${signal})` : ""), + ); } - ); - if (child.pid) { - resolve(); - } else { - reject(new Error("Failed to start Java process")); - } + }); }); } + async getGameArgumentsDescriptor(): Promise { + try { + let schemaPath = join(this.gameClientPath, "game", "args"); + if (existsSync(schemaPath)) { + log.info("Loading arguments descriptor from", schemaPath); + const content = await readFile(schemaPath, "utf-8"); + return JSON.parse(content); + } + log.info("Arguments descriptor not found at", schemaPath); + return null; + } catch (error) { + log.error("Failed to load arguments descriptor:", error); + return null; + } + } + private parseReplayFilename(filename: string, fullPath: string): ReplayFile { const replayFile: ReplayFile = { filename, diff --git a/packages/main/src/modules/GameUpdater.ts b/packages/main/src/modules/GameUpdater.ts index 7042480..f610be4 100644 --- a/packages/main/src/modules/GameUpdater.ts +++ b/packages/main/src/modules/GameUpdater.ts @@ -35,10 +35,41 @@ export interface FileManifest { export interface VersionManifest { version: string; base: FileManifest[]; - windows: FileManifest[]; - "macos-intel": FileManifest[]; - "macos-arm": FileManifest[]; - linux: FileManifest[]; + linux_arm64: FileManifest[]; + linux_x64: FileManifest[]; + windows_arm64: FileManifest[]; + windows_x86: FileManifest[]; + windows_x64: FileManifest[]; + macos_x64: FileManifest[]; + macos_arm64: FileManifest[]; +} + +export type PlatformManifestEntry = Exclude< + keyof VersionManifest, + "version" | "base" +>; + +export function getPlatformManifestEntry(): PlatformManifestEntry { + switch (`${process.platform}_${process.arch}`) { + case "linux_arm64": + return "linux_arm64"; + case "linux_x64": + return "linux_x64"; + case "win32_arm64": + return "windows_arm64"; + // case "win32_ia32": //no jdk to support it \o/ + // return "windows_x86"; + case "win32_x64": + return "windows_x64"; + case "darwin_x64": + return "macos_x64"; + case "darwin_arm64": + return "macos_arm64"; + default: + throw new Error( + `Unsupported platform or architecture: ${process.platform}/${process.arch}`, + ); + } } export interface GameStatus { @@ -62,6 +93,7 @@ export interface GameSettings { gameRamAllocation: number; devModeEnabled: boolean; devExtraJavaArgs: string; + devGameArgs: string; devForceVersion: string; devCdnEnvironment: "production" | "staging"; } @@ -118,12 +150,12 @@ export class GameUpdater implements AppModule { ipcMain.handle("gameUpdater:checkForUpdates", () => this.checkForUpdates()); ipcMain.handle("gameUpdater:startDownload", () => this.startDownload()); ipcMain.handle("gameUpdater:getDownloadProgress", () => - this.getDownloadProgress() + this.getDownloadProgress(), ); ipcMain.handle("gameUpdater:cancelDownload", () => this.cancelDownload()); ipcMain.handle("gameUpdater:repairClient", () => this.repairClient()); ipcMain.handle("gameUpdater:openGameDirectory", () => - this.openGameDirectory() + this.openGameDirectory(), ); } @@ -172,7 +204,7 @@ export class GameUpdater implements AppModule { if (previousSettings) { if (previousSettings.devCdnEnvironment !== settings.devCdnEnvironment) { log.debug( - `CDN environment changed from ${previousSettings.devCdnEnvironment} to ${settings.devCdnEnvironment}, notifying UI to refresh status` + `CDN environment changed from ${previousSettings.devCdnEnvironment} to ${settings.devCdnEnvironment}, notifying UI to refresh status`, ); this.notifyRenderer("status-changed", {}); @@ -225,7 +257,7 @@ export class GameUpdater implements AppModule { if (!response.ok) { throw new Error( - `Failed to fetch version info: ${response.status} ${response.statusText}` + `Failed to fetch version info: ${response.status} ${response.statusText}`, ); } @@ -250,7 +282,7 @@ export class GameUpdater implements AppModule { throw new Error( `Failed to update local version: ${ error instanceof Error ? error.message : "Unknown error" - }` + }`, ); } } @@ -275,13 +307,13 @@ export class GameUpdater implements AppModule { this.notifyRenderer("download-started", this.downloadProgress); const remoteVersion = await this.getRemoteVersion(); - const manifestResponse = await fetch( - `${this.cdnUrl}/versions/${remoteVersion}.json` - ); + let manifestURL = `${this.cdnUrl}/versions/${remoteVersion}.json`; + log.info("Fetching manifest from ", manifestURL) + const manifestResponse = await fetch(manifestURL); if (!manifestResponse.ok) { throw new Error( - `Failed to fetch version manifest: ${manifestResponse.status}` + `Failed to fetch version manifest: ${manifestResponse.status}`, ); } @@ -334,12 +366,12 @@ export class GameUpdater implements AppModule { const remoteVersion = await this.getRemoteVersion(); const manifestResponse = await fetch( - `${this.cdnUrl}/versions/${remoteVersion}.json` + `${this.cdnUrl}/versions/${remoteVersion}.json`, ); if (!manifestResponse.ok) { throw new Error( - `Failed to fetch version manifest: ${manifestResponse.status}` + `Failed to fetch version manifest: ${manifestResponse.status}`, ); } @@ -372,40 +404,19 @@ export class GameUpdater implements AppModule { throw new Error( `Failed to open game directory: ${ error instanceof Error ? error.message : "Unknown error" - }` + }`, ); } } // ---------------- Internal helpers ---------------- private getPlatformFiles(manifest: VersionManifest): FileManifest[] { - const files: FileManifest[] = [...manifest.base]; - - switch (process.platform) { - case "win32": - files.push(...manifest.windows); - break; - case "darwin": - // FIXME: Gigahack since darwin relies on wine - files.push(...manifest.windows); - break; - /*if (process.arch === "x64") { - files.push(...manifest["macos-intel"]); - } else { - files.push(...manifest["macos-arm"]); - }*/ - break; - case "linux": - files.push(...manifest.linux); - break; - } - - return files; + return [...manifest.base, ...manifest[getPlatformManifestEntry()]]; } private async checkFiles( files: FileManifest[], - forceCheck = false + forceCheck = false, ): Promise { if (!existsSync(this.gameClientPath)) { mkdirSync(this.gameClientPath, { recursive: true }); @@ -491,7 +502,7 @@ export class GameUpdater implements AppModule { const downloadPromises = files.map((file) => queue.add(() => this.downloadAndSaveFile(file), { priority: 1, - }) + }), ); try { @@ -535,7 +546,7 @@ export class GameUpdater implements AppModule { const response = await fetch(fileUrl); if (!response.ok) { throw new Error( - `Failed to download file: ${response.status} ${response.statusText}` + `Failed to download file: ${response.status} ${response.statusText}`, ); } @@ -548,7 +559,7 @@ export class GameUpdater implements AppModule { const dir = join( this.gameClientPath, - file.path.split("/").slice(0, -1).join("/") + file.path.split("/").slice(0, -1).join("/"), ); if (dir && !existsSync(dir)) { mkdirSync(dir, { recursive: true }); @@ -569,7 +580,7 @@ export class GameUpdater implements AppModule { throw new Error( `Failed to download file ${file.path}: ${ error instanceof Error ? error.message : "Unknown error" - }` + }`, ); } } @@ -652,7 +663,7 @@ export class GameUpdater implements AppModule { private async getAllLocalFiles( dirPath: string = this.gameClientPath, relativeTo: string = this.gameClientPath, - files: string[] = [] + files: string[] = [], ): Promise { try { const entries = await readdir(dirPath); diff --git a/packages/main/src/services/SettingsManager.ts b/packages/main/src/services/SettingsManager.ts index 1bd6191..f91036b 100644 --- a/packages/main/src/services/SettingsManager.ts +++ b/packages/main/src/services/SettingsManager.ts @@ -21,6 +21,7 @@ export class SettingsManager { return { gameRamAllocation: 2, devModeEnabled: false, + devGameArgs: "ONLY_ALLOWED_TEAM_TAB=1 ONLY_ALLOWED_LADDER_TAB=ONE_VS_ONE", devExtraJavaArgs: "", devForceVersion: "", devCdnEnvironment: "production", @@ -66,7 +67,7 @@ export class SettingsManager { await writeFile( this.settingsPath, JSON.stringify(settings, null, 2), - "utf-8" + "utf-8", ); log.info("Settings saved successfully:", settings); @@ -81,7 +82,7 @@ export class SettingsManager { throw new Error( `Failed to save settings: ${ error instanceof Error ? error.message : "Unknown error" - }` + }`, ); } } @@ -119,7 +120,7 @@ export class SettingsManager { */ private notifySettingsChange(settings: GameSettings): void { log.info( - `Notifying ${this.changeCallbacks.size} modules of settings change` + `Notifying ${this.changeCallbacks.size} modules of settings change`, ); for (const callback of this.changeCallbacks) { diff --git a/packages/preload/src/index.ts b/packages/preload/src/index.ts index 8777d36..9d9af93 100644 --- a/packages/preload/src/index.ts +++ b/packages/preload/src/index.ts @@ -58,6 +58,8 @@ const gameClient = { listReplays: () => ipcRenderer.invoke("gameClient:listReplays"), launchReplayOffline: (replayPath: string) => ipcRenderer.invoke("gameClient:launchReplayOffline", replayPath), + getGameArgumentsDescriptor: () => + ipcRenderer.invoke("gameClient:getGameArgumentsDescriptor"), }; // News functions diff --git a/packages/renderer/src/components/SettingsMenu.tsx b/packages/renderer/src/components/SettingsMenu.tsx index 255ccbb..f79d678 100644 --- a/packages/renderer/src/components/SettingsMenu.tsx +++ b/packages/renderer/src/components/SettingsMenu.tsx @@ -16,12 +16,185 @@ import { Code, AlertTriangle, } from "lucide-react"; -import { SimpleSlider, SimpleSelect } from "./common/FormControls"; +import { + SimpleSlider, + SimpleSelect, + SimpleSwitch, +} from "./common/FormControls"; import type { SettingsState } from "@/types"; import { gameClient, gameUpdater, system } from "@app/preload"; import { useGameStateContext } from "@/contexts/GameStateContext"; import log from "@/utils/logger"; +interface ArgumentDescriptorItem { + key: string; + description: string; + type: "boolean" | "string" | "number"; +} + +type GameArgValue = string | true; + +const isArgumentDescriptorItem = ( + item: unknown, +): item is ArgumentDescriptorItem => { + if (!item || typeof item !== "object") return false; + + const candidate = item as Record; + return ( + typeof candidate.key === "string" && + typeof candidate.description === "string" && + (candidate.type === "boolean" || + candidate.type === "string" || + candidate.type === "number") + ); +}; + +const splitCommandLineArgs = (argsString: string): string[] => { + const args: string[] = []; + let current = ""; + let quote: '"' | "'" | null = null; + let hasContent = false; + + for (let index = 0; index < argsString.length; index += 1) { + const character = argsString[index]; + + if (quote) { + if (character === quote) { + quote = null; + } else if ( + character === "\\" && + argsString[index + 1] !== undefined && + (argsString[index + 1] === quote || argsString[index + 1] === "\\") + ) { + current += argsString[index + 1]; + index += 1; + } else { + current += character; + } + continue; + } + + if (character === '"' || character === "'") { + quote = character; + hasContent = true; + } else if (/\s/.test(character)) { + if (hasContent) { + args.push(current); + current = ""; + hasContent = false; + } + } else { + current += character; + hasContent = true; + } + } + + if (hasContent) args.push(current); + return args; +}; + +const serializeGameArg = (key: string, value: GameArgValue): string => { + if (value === true) return key; + + const serializedValue = /\s|["']/.test(value) + ? JSON.stringify(value) + : value; + return `${key}=${serializedValue}`; +}; + +const parseGameArgs = ( + argsString: string, +): Record => { + const parsed: Record = {}; + if (!argsString) return parsed; + splitCommandLineArgs(argsString).forEach((part) => { + if (!part) return; + const [key, ...valParts] = part.split("="); + if (valParts.length > 0) { + parsed[key] = valParts.join("="); + } else { + parsed[key] = true; + } + }); + return parsed; +}; + +const updateDescriptorArg = ( + currentArgsStr: string, + descriptor: ArgumentDescriptorItem[], + keyToUpdate: string, + newValue: string | boolean, +): string => { + const parsed = parseGameArgs(currentArgsStr); + const descriptorKeys = new Set(descriptor.map((s) => s.key)); + + if (newValue === false || newValue === "") { + delete parsed[keyToUpdate]; + } else { + parsed[keyToUpdate] = newValue; + } + + const descriptorParts: string[] = []; + const customParts: string[] = []; + + Object.entries(parsed).forEach(([key, val]) => { + if (descriptorKeys.has(key)) { + if (val !== "") { + descriptorParts.push(serializeGameArg(key, val)); + } + } else { + customParts.push(serializeGameArg(key, val)); + } + }); + + const finalParts = [...descriptorParts]; + if (customParts.length > 0) { + finalParts.push(customParts.join(" ")); + } + + return finalParts.join(" "); +}; + +const updateCustomArgs = ( + currentArgsStr: string, + descriptor: ArgumentDescriptorItem[], + newCustomStr: string, +): string => { + const parsed = parseGameArgs(currentArgsStr); + const descriptorKeys = new Set(descriptor.map((s) => s.key)); + + const descriptorParts: string[] = []; + Object.entries(parsed).forEach(([key, val]) => { + if (descriptorKeys.has(key)) { + if (val !== "") { + descriptorParts.push(serializeGameArg(key, val)); + } + } + }); + + const customArgs = newCustomStr.trim(); + return customArgs + ? [...descriptorParts, customArgs].join(" ") + : descriptorParts.join(" "); +}; + +const getCustomArgsString = ( + currentArgsStr: string, + descriptor: ArgumentDescriptorItem[], +): string => { + const parsed = parseGameArgs(currentArgsStr); + const descriptorKeys = new Set(descriptor.map((s) => s.key)); + const customParts: string[] = []; + + Object.entries(parsed).forEach(([key, val]) => { + if (!descriptorKeys.has(key)) { + customParts.push(serializeGameArg(key, val)); + } + }); + + return customParts.join(" "); +}; + interface SettingsMenuProps { isOpen: boolean; onClose: () => void; @@ -44,6 +217,43 @@ export const SettingsMenu: React.FC = ({ // Local settings state for editing (doesn't affect main UI) const [localSettings, setLocalSettings] = useState(settings); + const [ArgumentsDescriptor, setArgumentsDescriptor] = useState< + ArgumentDescriptorItem[] | null + >(null); + const [descriptorLoading, setDescriptorLoading] = useState(false); + const [customGameArgsInput, setCustomGameArgsInput] = useState(""); + const [rawGameArgsInput, setRawGameArgsInput] = useState(""); + + // Fetch arguments.json descriptor when developer mode is enabled and settings menu is open + useEffect(() => { + if (!isOpen || !localSettings.devModeEnabled) { + setArgumentsDescriptor(null); + return; + } + + const fetchDescriptor = async () => { + setDescriptorLoading(true); + try { + const descriptor = await gameClient.getGameArgumentsDescriptor(); + if ( + Array.isArray(descriptor) && + descriptor.every(isArgumentDescriptorItem) + ) { + setArgumentsDescriptor(descriptor); + } else { + setArgumentsDescriptor(null); + } + } catch (error) { + log.error("Failed to fetch arguments descriptor:", error); + setArgumentsDescriptor(null); + } finally { + setDescriptorLoading(false); + } + }; + + fetchDescriptor(); + }, [isOpen, localSettings.devModeEnabled]); + // Update local settings when menu opens or settings prop changes useEffect(() => { if (isOpen) { @@ -52,6 +262,20 @@ export const SettingsMenu: React.FC = ({ } }, [isOpen, settings]); + useEffect(() => { + if (!ArgumentsDescriptor) return; + + setCustomGameArgsInput( + getCustomArgsString(localSettings.devGameArgs, ArgumentsDescriptor), + ); + }, [ArgumentsDescriptor, localSettings.devGameArgs]); + + useEffect(() => { + if (ArgumentsDescriptor) return; + + setRawGameArgsInput(localSettings.devGameArgs); + }, [ArgumentsDescriptor, localSettings.devGameArgs]); + // Fetch app version and log directory when settings menu opens useEffect(() => { if (!isOpen) return; @@ -81,6 +305,19 @@ export const SettingsMenu: React.FC = ({ }); }; + const handleCustomGameArgsBlur = () => { + if (!ArgumentsDescriptor) return; + + setLocalSettings((currentSettings) => ({ + ...currentSettings, + devGameArgs: updateCustomArgs( + currentSettings.devGameArgs, + ArgumentsDescriptor, + customGameArgsInput, + ), + })); + }; + const handleSave = async () => { setIsSaving(true); setSaveError(null); @@ -267,7 +504,7 @@ export const SettingsMenu: React.FC = ({ onChange={(value) => updateSetting( "devCdnEnvironment", - value as "production" | "staging" + value as "production" | "staging", ) } options={[ @@ -309,6 +546,117 @@ export const SettingsMenu: React.FC = ({ jeu.

+ +
+ + {descriptorLoading ? ( +
+ Chargement des arguments... +
+ ) : ArgumentsDescriptor ? ( +
+
+ {ArgumentsDescriptor.map((item) => { + const parsedArgs = parseGameArgs( + localSettings.devGameArgs, + ); + const currentValue = parsedArgs[item.key]; + + return ( +
+
+ + {item.key} + + {item.type === "boolean" ? ( + { + const updated = updateDescriptorArg( + localSettings.devGameArgs, + ArgumentsDescriptor, + item.key, + checked, + ); + updateSetting("devGameArgs", updated); + }} + /> + ) : ( + { + const updated = updateDescriptorArg( + localSettings.devGameArgs, + ArgumentsDescriptor, + item.key, + e.target.value, + ); + updateSetting("devGameArgs", updated); + }} + className="w-1/2 px-2 py-1 bg-black/40 border border-white/20 rounded text-white text-sm" + /> + )} +
+ + {item.description} + +
+ ); + })} +
+ +
+ +