From 1e7234d12d2e918d9106d580d8b152855bf5f5da Mon Sep 17 00:00:00 2001 From: Jydet Date: Sat, 11 Jul 2026 10:20:20 +0200 Subject: [PATCH 1/6] feat: edit the settings of the game in the dev tab --- arguments_example.json | 57 +++++ package-lock.json | 4 +- packages/main/src/modules/GameClient.ts | 59 +++-- packages/main/src/modules/GameUpdater.ts | 1 + packages/main/src/services/SettingsManager.ts | 1 + packages/preload/src/index.ts | 2 + .../renderer/src/components/SettingsMenu.tsx | 238 +++++++++++++++++- packages/renderer/src/types/index.ts | 1 + packages/renderer/src/vite-env.d.ts | 1 + 9 files changed, 339 insertions(+), 25 deletions(-) create mode 100644 arguments_example.json diff --git a/arguments_example.json b/arguments_example.json new file mode 100644 index 0000000..e68bc54 --- /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.", + "type": "number" + }, + { + "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 admin.", + "type": "boolean" + } +] \ No newline at end of file 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/modules/GameClient.ts b/packages/main/src/modules/GameClient.ts index 35a86fe..c5a9519 100644 --- a/packages/main/src/modules/GameClient.ts +++ b/packages/main/src/modules/GameClient.ts @@ -1,13 +1,12 @@ -import { ipcMain, app } 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 {app, ipcMain} from "electron"; +import {join} from "path"; +import {chmodSync, existsSync, mkdirSync} from "fs"; +import {appendFile, chmod, readdir, readFile, stat} from "fs/promises"; +import {exec} from "child_process"; import log from "electron-log"; -import { GameUpdater, GameSettings, ReplayFile } from "./GameUpdater.js"; -import type { AppModule } from "../AppModule.js"; -import type { ModuleContext } from "../ModuleContext.js"; +import {GameSettings, GameUpdater, ReplayFile} from "./GameUpdater.js"; +import type {AppModule} from "../AppModule.js"; +import type {ModuleContext} from "../ModuleContext.js"; export class GameClient implements AppModule { private gameUpdater: GameUpdater | null = null; @@ -37,6 +36,9 @@ export class GameClient implements AppModule { ipcMain.handle("gameClient:launchReplayOffline", (_e, path) => this.launchReplayOffline(path) ); + ipcMain.handle("gameClient:getGameArgumentsDescriptor", () => + this.getGameArgumentsDescriptor() + ); } onSettingsUpdate(settings: GameSettings): void { @@ -75,18 +77,15 @@ 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", - ], + extraArgs: this.currentSettings?.devExtraJavaArgs.split(" ").map(arg => `-${arg}`) ?? [], }); } async openReplaysFolder(): Promise { - const { shell } = await import("electron"); + const {shell} = await import("electron"); const replaysPath = join(this.gameClientPath, "game", "replays"); - mkdirSync(replaysPath, { recursive: true }); + mkdirSync(replaysPath, {recursive: true}); try { await shell.openPath(replaysPath); @@ -101,8 +100,8 @@ export class GameClient implements AppModule { async listReplays(): Promise { const replaysPath = join(this.gameClientPath, "game", "replays"); - const { readdir } = await import("fs/promises"); - mkdirSync(replaysPath, { recursive: true }); + const {readdir} = await import("fs/promises"); + mkdirSync(replaysPath, {recursive: true}); try { const files = await readdir(replaysPath); @@ -191,7 +190,7 @@ export class GameClient implements AppModule { settings?: GameSettings; extraArgs?: string[]; }): Promise { - const { mainClass, settings, extraArgs = [] } = options; + const {mainClass, settings, extraArgs = []} = options; const gameDir = join(this.gameClientPath, "game"); const libDir = join(this.gameClientPath, "lib"); const jreDir = join(this.gameClientPath, "jre"); @@ -349,7 +348,7 @@ export class GameClient implements AppModule { return new Promise((resolve, reject) => { const child = exec( `"${javaExecutable}" ${args.join(" ")}`, - { cwd }, + {cwd}, (error) => { if (error && !error.killed) { log.error("Java process error:", error); @@ -380,7 +379,7 @@ export class GameClient implements AppModule { } const child = exec( `"${javaExecutable}" ${args.join(" ")}`, - { cwd }, + {cwd}, (error) => { if (error && !error.killed) { log.error("Java process error:", error); @@ -411,7 +410,7 @@ export class GameClient implements AppModule { } // Ensure we have the full system PATH for finding wine - const env = { ...process.env }; + const env = {...process.env}; if (!env.PATH?.includes("/opt/homebrew/bin")) { env.PATH = `${ env.PATH || "" @@ -420,7 +419,7 @@ export class GameClient implements AppModule { const child = exec( `wine "${javaExecutable}" ${args.join(" ")}`, - { cwd, env }, + {cwd, env}, (error) => { if (error && !error.killed) { log.error("Java process error:", error); @@ -435,6 +434,22 @@ export class GameClient implements AppModule { }); } + async getGameArgumentsDescriptor(): Promise { + try { + let schemaPath = join(this.gameClientPath, "game", "arguments.json"); + 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") + return null; + } catch (error) { + log.error("Failed to load arguments.json:", 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..cd601ec 100644 --- a/packages/main/src/modules/GameUpdater.ts +++ b/packages/main/src/modules/GameUpdater.ts @@ -62,6 +62,7 @@ export interface GameSettings { gameRamAllocation: number; devModeEnabled: boolean; devExtraJavaArgs: string; + devGameArgs: string; devForceVersion: string; devCdnEnvironment: "production" | "staging"; } diff --git a/packages/main/src/services/SettingsManager.ts b/packages/main/src/services/SettingsManager.ts index 1bd6191..d89bd1a 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", 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..c53c33b 100644 --- a/packages/renderer/src/components/SettingsMenu.tsx +++ b/packages/renderer/src/components/SettingsMenu.tsx @@ -16,12 +16,120 @@ 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"; +} + +const parseGameArgs = (argsString: string): Record => { + const parsed: Record = {}; + if (!argsString) return parsed; + argsString.split(/\s+/).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 === true) { + descriptorParts.push(key); + } else if (val !== false && val !== "" && val !== undefined) { + descriptorParts.push(`${key}=${val}`); + } + } else { + if (val === true) { + customParts.push(key); + } else { + customParts.push(`${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 === true) { + descriptorParts.push(key); + } else if (val !== false && val !== "" && val !== undefined) { + descriptorParts.push(`${key}=${val}`); + } + } + }); + + const finalParts = [...descriptorParts]; + if (newCustomStr.trim()) { + finalParts.push(newCustomStr.trim()); + } + + return finalParts.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)) { + if (val === true) { + customParts.push(key); + } else { + customParts.push(`${key}=${val}`); + } + } + }); + + return customParts.join(" "); +}; + interface SettingsMenuProps { isOpen: boolean; onClose: () => void; @@ -44,6 +152,36 @@ export const SettingsMenu: React.FC = ({ // Local settings state for editing (doesn't affect main UI) const [localSettings, setLocalSettings] = useState(settings); + const [ArgumentsDescriptor, setArgumentsDescriptor] = useState(null); + const [descriptorLoading, setDescriptorLoading] = useState(false); + + // 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((item: any) => item && typeof item.key === "string" && typeof item.type === "string")) { + setArgumentsDescriptor(descriptor as ArgumentDescriptorItem[]); + } 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) { @@ -309,6 +447,104 @@ export const SettingsMenu: React.FC = ({ jeu.
+ Arguments personnalisés non inclus dans le fichier de configuration. +
+ Arguments additionnels passés au jeu. +
- Arguments personnalisés non inclus dans le fichier de configuration. + Arguments personnalisés non inclus dans le fichier de + configuration.
Arguments personnalisés non inclus dans le fichier de @@ -570,12 +643,13 @@ export const SettingsMenu: React.FC = ({ ) : ( <> - updateSetting("devGameArgs", e.target.value) + value={rawGameArgsInput} + onChange={(e) => setRawGameArgsInput(e.target.value)} + onBlur={() => + updateSetting("devGameArgs", rawGameArgsInput) } className="w-full px-3 py-2 bg-white/5 border border-white/20 rounded-md text-white placeholder-white/40 h-24 resize-none" - placeholder="valid options: CONFIGURATION_FILE,DEV_MODE,HOT_RELOAD_EFFECT,NO_CAMERA_MIN_ZOOM,NO_CLASS_RESTRICTION,ONLY_ALLOWED_LADDER_TAB,ONLY_ALLOWED_TEAM_TAB,REPLAY_FILE_PATH,SKIP_TURN_NO_DELAY,WORLD_FADE" + placeholder="valid options: CONFIGURATION_FILE,DEV_MODE,HOT_RELOAD_EFFECT,NO_CAMERA_MIN_ZOOM,NO_CLASS_RESTRICTION,ONLY_ALLOWED_LADDER_TAB,ONLY_ALLOWED_TEAM_TAB,SKIP_TURN_NO_DELAY,WORLD_FADE" /> Arguments additionnels passés au jeu. From 8ad50670ea54407d491b4583d0e38bba2822aadf Mon Sep 17 00:00:00 2001 From: Jydet Date: Fri, 28 Aug 2026 14:36:42 +0200 Subject: [PATCH 4/6] feat: zaapi update for JWLGJ and its pseudo native libs --- arguments_example.json | 112 +++++++++++------------ packages/main/src/modules/GameClient.ts | 67 ++++++-------- packages/main/src/modules/GameUpdater.ts | 68 ++++++++------ 3 files changed, 122 insertions(+), 125 deletions(-) diff --git a/arguments_example.json b/arguments_example.json index e68bc54..efa374e 100644 --- a/arguments_example.json +++ b/arguments_example.json @@ -1,57 +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.", - "type": "number" - }, - { - "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 admin.", - "type": "boolean" - } -] \ No newline at end of file + { + "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/packages/main/src/modules/GameClient.ts b/packages/main/src/modules/GameClient.ts index fee3ba1..68fca6b 100644 --- a/packages/main/src/modules/GameClient.ts +++ b/packages/main/src/modules/GameClient.ts @@ -4,7 +4,12 @@ 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 { GameSettings, GameUpdater, ReplayFile } from "./GameUpdater.js"; +import { + GameSettings, + GameUpdater, + ReplayFile, + getPlatformManifestEntry, +} from "./GameUpdater.js"; import type { AppModule } from "../AppModule.js"; import type { ModuleContext } from "../ModuleContext.js"; @@ -248,44 +253,26 @@ export class GameClient implements AppModule { 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) { @@ -326,14 +313,12 @@ export class GameClient implements AppModule { "java.desktop/sun.awt=ALL-UNNAMED", "--enable-native-access=ALL-UNNAMED", "-Djava.net.preferIPv4Stack=true", + "-Dsun.java2d.dpiaware=false", + "-Dsun.java2d.uiScale=1.0", "-Djogl.disable.openglarbcontext=1", - `-Djava.library.path=${nativesPath}`, + "--sun-misc-unsafe-memory-access=allow" ]; - if (process.platform === "darwin") { - javaArgs.push("-XstartOnFirstThread"); - } - if (settings?.devModeEnabled && settings?.devExtraJavaArgs) { javaArgs.push( ...splitCommandLineArgs(settings.devExtraJavaArgs), @@ -342,6 +327,8 @@ export class GameClient implements AppModule { javaArgs.push("-cp", fullClasspath, mainClass, ...fullGameArgs); + log.info("Launching game with ", javaArgs, "in", gameDir, "and exe", javaExecutable) + switch (process.platform) { case "win32": await this.launchJavaProcessWindows(javaExecutable, javaArgs, gameDir); diff --git a/packages/main/src/modules/GameUpdater.ts b/packages/main/src/modules/GameUpdater.ts index d2c4b7f..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 { @@ -276,9 +307,9 @@ 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( @@ -380,28 +411,7 @@ export class GameUpdater implements AppModule { // ---------------- 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( From bd75af994cb2925762e47aac141abe5b3ac49e1a Mon Sep 17 00:00:00 2001 From: Jordan Rey Date: Mon, 14 Sep 2026 22:06:55 +0200 Subject: [PATCH 5/6] fix: launch native Java runtime on macOS --- .github/workflows/compile.yml | 1 + packages/main/package.json | 3 +- packages/main/src/modules/GameClient.ts | 43 ++----- packages/main/test/GameClient.test.cjs | 154 ++++++++++++++++++++++++ 4 files changed, 168 insertions(+), 33 deletions(-) create mode 100644 packages/main/test/GameClient.test.cjs diff --git a/.github/workflows/compile.yml b/.github/workflows/compile.yml index eff07cd..a454e57 100644 --- a/.github/workflows/compile.yml +++ b/.github/workflows/compile.yml @@ -39,6 +39,7 @@ jobs: cache: "npm" - run: npm ci - run: npm run typecheck --if-present + - run: npm test --workspaces --if-present compile: strategy: diff --git a/packages/main/package.json b/packages/main/package.json index 7e8f2f7..f7b73f6 100644 --- a/packages/main/package.json +++ b/packages/main/package.json @@ -3,6 +3,7 @@ "type": "module", "scripts": { "build": "vite build", + "test": "node --test test/*.test.cjs", "typecheck": "tsc --noEmit" }, "exports": { @@ -24,4 +25,4 @@ "typescript": "5.9.2", "vite": "7.1.3" } -} \ No newline at end of file +} diff --git a/packages/main/src/modules/GameClient.ts b/packages/main/src/modules/GameClient.ts index 68fca6b..a13dc30 100644 --- a/packages/main/src/modules/GameClient.ts +++ b/packages/main/src/modules/GameClient.ts @@ -256,9 +256,7 @@ export class GameClient implements AppModule { if (!existsSync(jreDir)) throw new Error("JRE directory not found"); const coreJarPath = join(gameDir, "core.jar"); - // FIXME: Gigahack since darwin relies on wine - const classpathSeparator = - process.platform === "win32" || process.platform === "darwin" ? ";" : ":"; + const classpathSeparator = process.platform === "win32" ? ";" : ":"; const libCP = existsSync(libDir) ? (await readdir(libDir)) .filter((file) => file.endsWith(".jar")) @@ -280,9 +278,7 @@ export class GameClient implements AppModule { javaExecutable = join(jreDir, "bin", "java.exe"); break; case "darwin": - javaExecutable = join(jreDir, "bin", "java.exe"); - // FIXME: Native macos build not yet available - // javaExecutable = join(jreDir, "Contents", "Home", "bin", "java"); + javaExecutable = join(jreDir, "Contents", "Home", "bin", "java"); break; default: javaExecutable = join(jreDir, "bin", "java"); @@ -319,6 +315,11 @@ export class GameClient implements AppModule { "--sun-misc-unsafe-memory-access=allow" ]; + if (process.platform === "darwin") { + // GLFW requires the JVM's main thread to be the first macOS thread. + javaArgs.push("-XstartOnFirstThread"); + } + if (settings?.devModeEnabled && settings?.devExtraJavaArgs) { javaArgs.push( ...splitCommandLineArgs(settings.devExtraJavaArgs), @@ -353,14 +354,9 @@ export class GameClient implements AppModule { } private async ensureJrePermissions(jreDir: string): Promise { - let binDir: string; - if (process.platform === "darwin") { - return; - // FIXME: Native macos build not yet available - // binDir = join(jreDir, "Contents", "Home", "bin"); - } else { - binDir = join(jreDir, "bin"); - } + const binDir = process.platform === "darwin" + ? join(jreDir, "Contents", "Home", "bin") + : join(jreDir, "bin"); if (!existsSync(binDir)) return; const binFiles = await readdir(binDir); @@ -407,24 +403,7 @@ export class GameClient implements AppModule { args: string[], cwd: string, ): Promise { - 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`; - } - - return this.spawnJavaProcess("wine", [javaExecutable, ...args], cwd, env); + return this.spawnJavaProcess(javaExecutable, args, cwd); } private spawnJavaProcess( diff --git a/packages/main/test/GameClient.test.cjs b/packages/main/test/GameClient.test.cjs new file mode 100644 index 0000000..9777a44 --- /dev/null +++ b/packages/main/test/GameClient.test.cjs @@ -0,0 +1,154 @@ +const assert = require("node:assert/strict"); +const { EventEmitter } = require("node:events"); +const fs = require("node:fs"); +const os = require("node:os"); +const path = require("node:path"); +const test = require("node:test"); +const vm = require("node:vm"); +const ts = require("typescript"); + +// Exercise the real launch code without starting Electron or a platform JVM. +const compiledModules = new Map( + ["GameClient", "GameUpdater"].map((name) => [ + name, + ts.transpileModule( + fs.readFileSync(path.join(__dirname, "../src/modules", `${name}.ts`), "utf8"), + { + compilerOptions: { + module: ts.ModuleKind.CommonJS, + target: ts.ScriptTarget.ES2022, + esModuleInterop: true, + }, + }, + ).outputText, + ]), +); + +function createClient(platform, arch, root) { + const launches = []; + const modules = new Map(); + const logger = { info() {}, debug() {}, warn() {}, error() {} }; + + function load(name) { + if (modules.has(name)) return modules.get(name); + const module = { exports: {} }; + modules.set(name, module.exports); + const requireMock = (specifier) => { + switch (specifier) { + case "electron": + return { app: { getPath: () => root } }; + case "electron-log": + return logger; + case "./GameUpdater.js": + return load("GameUpdater"); + case "child_process": + return { + spawn(executable, args, options) { + launches.push({ executable, args: Array.from(args), options }); + const child = new EventEmitter(); + queueMicrotask(() => child.emit("spawn")); + return child; + }, + }; + case "p-queue": + // Downloads are outside the launch path under test. + return class {}; + default: + return require(specifier); + } + }; + + vm.runInNewContext(compiledModules.get(name), { + module, + exports: module.exports, + require: requireMock, + process: { platform, arch, env: { PATH: "/review/bin" } }, + }); + return module.exports; + } + + const client = new (load("GameClient").GameClient)(); + client.setGameUpdater({ + getGameStatus: async () => ({ isInstalled: true, needsUpdate: false }), + }); + client.onSettingsUpdate({ + gameRamAllocation: 2, + devModeEnabled: false, + devGameArgs: 'CONFIGURATION_FILE="config with spaces.properties"', + }); + return { client, launches }; +} + +const targets = [ + { platform: "darwin", arch: "x64", entry: "macos_x64", separator: ":", java: "jre/Contents/Home/bin/java" }, + { platform: "darwin", arch: "arm64", entry: "macos_arm64", separator: ":", java: "jre/Contents/Home/bin/java" }, + { platform: "linux", arch: "x64", entry: "linux_x64", separator: ":", java: "jre/bin/java" }, + { platform: "win32", arch: "x64", entry: "windows_x64", separator: ";", java: "jre/bin/java.exe" }, +]; + +for (const target of targets) { + const launchModes = target.platform === "darwin" ? ["game", "replay"] : ["game"]; + for (const mode of launchModes) { + test(`${target.entry}: launch ${mode} with the bundled runtime`, async (t) => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "launcher native runtime ")); + t.after(() => fs.rmSync(root, { recursive: true, force: true })); + const gameRoot = path.join(root, "ArenaReturnsClient"); + const java = path.join(gameRoot, target.java); + const keytool = path.join(path.dirname(java), "keytool"); + const replay = path.join(gameRoot, "game/replays/replay with spaces.rda"); + const includeLib = target.arch === "x64"; + const files = [ + java, + keytool, + replay, + path.join(gameRoot, "game/core.jar"), + path.join(gameRoot, "natives/shared.jar"), + path.join(gameRoot, "natives", target.entry, "native.jar"), + ...(includeLib ? [path.join(gameRoot, "lib/library.jar")] : []), + ]; + for (const file of files) { + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync(file, "fixture", { mode: 0o644 }); + } + + const { client, launches } = createClient(target.platform, target.arch, root); + if (mode === "replay") await client.launchReplayOffline(replay); + else await client.launchGame(); + + assert.equal(launches.length, 1); + const { executable, args, options } = launches[0]; + assert.equal(executable, java, "spawn the bundled Java executable directly"); + assert.equal(options.cwd, path.join(gameRoot, "game")); + assert.equal(options.env.PATH, "/review/bin"); + + const cpIndex = args.indexOf("-cp"); + assert.ok(cpIndex >= 0); + const expectedClasspath = [ + ...(includeLib ? [path.join(gameRoot, "lib/library.jar")] : []), + path.join(gameRoot, "natives/*"), + path.join(gameRoot, "natives", target.entry, "*"), + path.join(gameRoot, "game/core.jar"), + ].join(target.separator); + assert.equal(args[cpIndex + 1], expectedClasspath); + assert.equal( + args[cpIndex + 2], + mode === "replay" + ? "com.ankamagames.dofusarena.client.DofusArenaReplayPlayer" + : "com.ankamagames.dofusarena.client.DofusArenaClient", + ); + assert.ok(args.includes("-CONFIGURATION_FILE=config with spaces.properties")); + if (mode === "replay") assert.ok(args.includes(`-REPLAY_FILE_PATH=${replay}`)); + + const firstThreadIndex = args.indexOf("-XstartOnFirstThread"); + if (target.platform === "darwin") { + assert.ok(firstThreadIndex >= 0 && firstThreadIndex < cpIndex); + if (process.platform !== "win32") { + assert.equal(fs.statSync(java).mode & 0o777, 0o755); + assert.equal(fs.statSync(keytool).mode & 0o777, 0o755); + } + } else { + assert.equal(firstThreadIndex, -1, "keep macOS JVM options platform-specific"); + } + }); + } +} From c1e29c282e37cc9efb6b6a02ceedf56c776a6a79 Mon Sep 17 00:00:00 2001 From: Jordan Rey Date: Mon, 14 Sep 2026 22:08:30 +0200 Subject: [PATCH 6/6] chore: remove test additions from macOS fix --- .github/workflows/compile.yml | 1 - packages/main/package.json | 3 +- packages/main/test/GameClient.test.cjs | 154 ------------------------- 3 files changed, 1 insertion(+), 157 deletions(-) delete mode 100644 packages/main/test/GameClient.test.cjs diff --git a/.github/workflows/compile.yml b/.github/workflows/compile.yml index a454e57..eff07cd 100644 --- a/.github/workflows/compile.yml +++ b/.github/workflows/compile.yml @@ -39,7 +39,6 @@ jobs: cache: "npm" - run: npm ci - run: npm run typecheck --if-present - - run: npm test --workspaces --if-present compile: strategy: diff --git a/packages/main/package.json b/packages/main/package.json index f7b73f6..7e8f2f7 100644 --- a/packages/main/package.json +++ b/packages/main/package.json @@ -3,7 +3,6 @@ "type": "module", "scripts": { "build": "vite build", - "test": "node --test test/*.test.cjs", "typecheck": "tsc --noEmit" }, "exports": { @@ -25,4 +24,4 @@ "typescript": "5.9.2", "vite": "7.1.3" } -} +} \ No newline at end of file diff --git a/packages/main/test/GameClient.test.cjs b/packages/main/test/GameClient.test.cjs deleted file mode 100644 index 9777a44..0000000 --- a/packages/main/test/GameClient.test.cjs +++ /dev/null @@ -1,154 +0,0 @@ -const assert = require("node:assert/strict"); -const { EventEmitter } = require("node:events"); -const fs = require("node:fs"); -const os = require("node:os"); -const path = require("node:path"); -const test = require("node:test"); -const vm = require("node:vm"); -const ts = require("typescript"); - -// Exercise the real launch code without starting Electron or a platform JVM. -const compiledModules = new Map( - ["GameClient", "GameUpdater"].map((name) => [ - name, - ts.transpileModule( - fs.readFileSync(path.join(__dirname, "../src/modules", `${name}.ts`), "utf8"), - { - compilerOptions: { - module: ts.ModuleKind.CommonJS, - target: ts.ScriptTarget.ES2022, - esModuleInterop: true, - }, - }, - ).outputText, - ]), -); - -function createClient(platform, arch, root) { - const launches = []; - const modules = new Map(); - const logger = { info() {}, debug() {}, warn() {}, error() {} }; - - function load(name) { - if (modules.has(name)) return modules.get(name); - const module = { exports: {} }; - modules.set(name, module.exports); - const requireMock = (specifier) => { - switch (specifier) { - case "electron": - return { app: { getPath: () => root } }; - case "electron-log": - return logger; - case "./GameUpdater.js": - return load("GameUpdater"); - case "child_process": - return { - spawn(executable, args, options) { - launches.push({ executable, args: Array.from(args), options }); - const child = new EventEmitter(); - queueMicrotask(() => child.emit("spawn")); - return child; - }, - }; - case "p-queue": - // Downloads are outside the launch path under test. - return class {}; - default: - return require(specifier); - } - }; - - vm.runInNewContext(compiledModules.get(name), { - module, - exports: module.exports, - require: requireMock, - process: { platform, arch, env: { PATH: "/review/bin" } }, - }); - return module.exports; - } - - const client = new (load("GameClient").GameClient)(); - client.setGameUpdater({ - getGameStatus: async () => ({ isInstalled: true, needsUpdate: false }), - }); - client.onSettingsUpdate({ - gameRamAllocation: 2, - devModeEnabled: false, - devGameArgs: 'CONFIGURATION_FILE="config with spaces.properties"', - }); - return { client, launches }; -} - -const targets = [ - { platform: "darwin", arch: "x64", entry: "macos_x64", separator: ":", java: "jre/Contents/Home/bin/java" }, - { platform: "darwin", arch: "arm64", entry: "macos_arm64", separator: ":", java: "jre/Contents/Home/bin/java" }, - { platform: "linux", arch: "x64", entry: "linux_x64", separator: ":", java: "jre/bin/java" }, - { platform: "win32", arch: "x64", entry: "windows_x64", separator: ";", java: "jre/bin/java.exe" }, -]; - -for (const target of targets) { - const launchModes = target.platform === "darwin" ? ["game", "replay"] : ["game"]; - for (const mode of launchModes) { - test(`${target.entry}: launch ${mode} with the bundled runtime`, async (t) => { - const root = fs.mkdtempSync(path.join(os.tmpdir(), "launcher native runtime ")); - t.after(() => fs.rmSync(root, { recursive: true, force: true })); - const gameRoot = path.join(root, "ArenaReturnsClient"); - const java = path.join(gameRoot, target.java); - const keytool = path.join(path.dirname(java), "keytool"); - const replay = path.join(gameRoot, "game/replays/replay with spaces.rda"); - const includeLib = target.arch === "x64"; - const files = [ - java, - keytool, - replay, - path.join(gameRoot, "game/core.jar"), - path.join(gameRoot, "natives/shared.jar"), - path.join(gameRoot, "natives", target.entry, "native.jar"), - ...(includeLib ? [path.join(gameRoot, "lib/library.jar")] : []), - ]; - for (const file of files) { - fs.mkdirSync(path.dirname(file), { recursive: true }); - fs.writeFileSync(file, "fixture", { mode: 0o644 }); - } - - const { client, launches } = createClient(target.platform, target.arch, root); - if (mode === "replay") await client.launchReplayOffline(replay); - else await client.launchGame(); - - assert.equal(launches.length, 1); - const { executable, args, options } = launches[0]; - assert.equal(executable, java, "spawn the bundled Java executable directly"); - assert.equal(options.cwd, path.join(gameRoot, "game")); - assert.equal(options.env.PATH, "/review/bin"); - - const cpIndex = args.indexOf("-cp"); - assert.ok(cpIndex >= 0); - const expectedClasspath = [ - ...(includeLib ? [path.join(gameRoot, "lib/library.jar")] : []), - path.join(gameRoot, "natives/*"), - path.join(gameRoot, "natives", target.entry, "*"), - path.join(gameRoot, "game/core.jar"), - ].join(target.separator); - assert.equal(args[cpIndex + 1], expectedClasspath); - assert.equal( - args[cpIndex + 2], - mode === "replay" - ? "com.ankamagames.dofusarena.client.DofusArenaReplayPlayer" - : "com.ankamagames.dofusarena.client.DofusArenaClient", - ); - assert.ok(args.includes("-CONFIGURATION_FILE=config with spaces.properties")); - if (mode === "replay") assert.ok(args.includes(`-REPLAY_FILE_PATH=${replay}`)); - - const firstThreadIndex = args.indexOf("-XstartOnFirstThread"); - if (target.platform === "darwin") { - assert.ok(firstThreadIndex >= 0 && firstThreadIndex < cpIndex); - if (process.platform !== "win32") { - assert.equal(fs.statSync(java).mode & 0o777, 0o755); - assert.equal(fs.statSync(keytool).mode & 0o777, 0o755); - } - } else { - assert.equal(firstThreadIndex, -1, "keep macOS JVM options platform-specific"); - } - }); - } -}
Arguments additionnels passés au jeu. From 8ad50670ea54407d491b4583d0e38bba2822aadf Mon Sep 17 00:00:00 2001 From: Jydet Date: Fri, 28 Aug 2026 14:36:42 +0200 Subject: [PATCH 4/6] feat: zaapi update for JWLGJ and its pseudo native libs --- arguments_example.json | 112 +++++++++++------------ packages/main/src/modules/GameClient.ts | 67 ++++++-------- packages/main/src/modules/GameUpdater.ts | 68 ++++++++------ 3 files changed, 122 insertions(+), 125 deletions(-) diff --git a/arguments_example.json b/arguments_example.json index e68bc54..efa374e 100644 --- a/arguments_example.json +++ b/arguments_example.json @@ -1,57 +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.", - "type": "number" - }, - { - "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 admin.", - "type": "boolean" - } -] \ No newline at end of file + { + "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/packages/main/src/modules/GameClient.ts b/packages/main/src/modules/GameClient.ts index fee3ba1..68fca6b 100644 --- a/packages/main/src/modules/GameClient.ts +++ b/packages/main/src/modules/GameClient.ts @@ -4,7 +4,12 @@ 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 { GameSettings, GameUpdater, ReplayFile } from "./GameUpdater.js"; +import { + GameSettings, + GameUpdater, + ReplayFile, + getPlatformManifestEntry, +} from "./GameUpdater.js"; import type { AppModule } from "../AppModule.js"; import type { ModuleContext } from "../ModuleContext.js"; @@ -248,44 +253,26 @@ export class GameClient implements AppModule { 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) { @@ -326,14 +313,12 @@ export class GameClient implements AppModule { "java.desktop/sun.awt=ALL-UNNAMED", "--enable-native-access=ALL-UNNAMED", "-Djava.net.preferIPv4Stack=true", + "-Dsun.java2d.dpiaware=false", + "-Dsun.java2d.uiScale=1.0", "-Djogl.disable.openglarbcontext=1", - `-Djava.library.path=${nativesPath}`, + "--sun-misc-unsafe-memory-access=allow" ]; - if (process.platform === "darwin") { - javaArgs.push("-XstartOnFirstThread"); - } - if (settings?.devModeEnabled && settings?.devExtraJavaArgs) { javaArgs.push( ...splitCommandLineArgs(settings.devExtraJavaArgs), @@ -342,6 +327,8 @@ export class GameClient implements AppModule { javaArgs.push("-cp", fullClasspath, mainClass, ...fullGameArgs); + log.info("Launching game with ", javaArgs, "in", gameDir, "and exe", javaExecutable) + switch (process.platform) { case "win32": await this.launchJavaProcessWindows(javaExecutable, javaArgs, gameDir); diff --git a/packages/main/src/modules/GameUpdater.ts b/packages/main/src/modules/GameUpdater.ts index d2c4b7f..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 { @@ -276,9 +307,9 @@ 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( @@ -380,28 +411,7 @@ export class GameUpdater implements AppModule { // ---------------- 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( From bd75af994cb2925762e47aac141abe5b3ac49e1a Mon Sep 17 00:00:00 2001 From: Jordan Rey Date: Mon, 14 Sep 2026 22:06:55 +0200 Subject: [PATCH 5/6] fix: launch native Java runtime on macOS --- .github/workflows/compile.yml | 1 + packages/main/package.json | 3 +- packages/main/src/modules/GameClient.ts | 43 ++----- packages/main/test/GameClient.test.cjs | 154 ++++++++++++++++++++++++ 4 files changed, 168 insertions(+), 33 deletions(-) create mode 100644 packages/main/test/GameClient.test.cjs diff --git a/.github/workflows/compile.yml b/.github/workflows/compile.yml index eff07cd..a454e57 100644 --- a/.github/workflows/compile.yml +++ b/.github/workflows/compile.yml @@ -39,6 +39,7 @@ jobs: cache: "npm" - run: npm ci - run: npm run typecheck --if-present + - run: npm test --workspaces --if-present compile: strategy: diff --git a/packages/main/package.json b/packages/main/package.json index 7e8f2f7..f7b73f6 100644 --- a/packages/main/package.json +++ b/packages/main/package.json @@ -3,6 +3,7 @@ "type": "module", "scripts": { "build": "vite build", + "test": "node --test test/*.test.cjs", "typecheck": "tsc --noEmit" }, "exports": { @@ -24,4 +25,4 @@ "typescript": "5.9.2", "vite": "7.1.3" } -} \ No newline at end of file +} diff --git a/packages/main/src/modules/GameClient.ts b/packages/main/src/modules/GameClient.ts index 68fca6b..a13dc30 100644 --- a/packages/main/src/modules/GameClient.ts +++ b/packages/main/src/modules/GameClient.ts @@ -256,9 +256,7 @@ export class GameClient implements AppModule { if (!existsSync(jreDir)) throw new Error("JRE directory not found"); const coreJarPath = join(gameDir, "core.jar"); - // FIXME: Gigahack since darwin relies on wine - const classpathSeparator = - process.platform === "win32" || process.platform === "darwin" ? ";" : ":"; + const classpathSeparator = process.platform === "win32" ? ";" : ":"; const libCP = existsSync(libDir) ? (await readdir(libDir)) .filter((file) => file.endsWith(".jar")) @@ -280,9 +278,7 @@ export class GameClient implements AppModule { javaExecutable = join(jreDir, "bin", "java.exe"); break; case "darwin": - javaExecutable = join(jreDir, "bin", "java.exe"); - // FIXME: Native macos build not yet available - // javaExecutable = join(jreDir, "Contents", "Home", "bin", "java"); + javaExecutable = join(jreDir, "Contents", "Home", "bin", "java"); break; default: javaExecutable = join(jreDir, "bin", "java"); @@ -319,6 +315,11 @@ export class GameClient implements AppModule { "--sun-misc-unsafe-memory-access=allow" ]; + if (process.platform === "darwin") { + // GLFW requires the JVM's main thread to be the first macOS thread. + javaArgs.push("-XstartOnFirstThread"); + } + if (settings?.devModeEnabled && settings?.devExtraJavaArgs) { javaArgs.push( ...splitCommandLineArgs(settings.devExtraJavaArgs), @@ -353,14 +354,9 @@ export class GameClient implements AppModule { } private async ensureJrePermissions(jreDir: string): Promise { - let binDir: string; - if (process.platform === "darwin") { - return; - // FIXME: Native macos build not yet available - // binDir = join(jreDir, "Contents", "Home", "bin"); - } else { - binDir = join(jreDir, "bin"); - } + const binDir = process.platform === "darwin" + ? join(jreDir, "Contents", "Home", "bin") + : join(jreDir, "bin"); if (!existsSync(binDir)) return; const binFiles = await readdir(binDir); @@ -407,24 +403,7 @@ export class GameClient implements AppModule { args: string[], cwd: string, ): Promise { - 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`; - } - - return this.spawnJavaProcess("wine", [javaExecutable, ...args], cwd, env); + return this.spawnJavaProcess(javaExecutable, args, cwd); } private spawnJavaProcess( diff --git a/packages/main/test/GameClient.test.cjs b/packages/main/test/GameClient.test.cjs new file mode 100644 index 0000000..9777a44 --- /dev/null +++ b/packages/main/test/GameClient.test.cjs @@ -0,0 +1,154 @@ +const assert = require("node:assert/strict"); +const { EventEmitter } = require("node:events"); +const fs = require("node:fs"); +const os = require("node:os"); +const path = require("node:path"); +const test = require("node:test"); +const vm = require("node:vm"); +const ts = require("typescript"); + +// Exercise the real launch code without starting Electron or a platform JVM. +const compiledModules = new Map( + ["GameClient", "GameUpdater"].map((name) => [ + name, + ts.transpileModule( + fs.readFileSync(path.join(__dirname, "../src/modules", `${name}.ts`), "utf8"), + { + compilerOptions: { + module: ts.ModuleKind.CommonJS, + target: ts.ScriptTarget.ES2022, + esModuleInterop: true, + }, + }, + ).outputText, + ]), +); + +function createClient(platform, arch, root) { + const launches = []; + const modules = new Map(); + const logger = { info() {}, debug() {}, warn() {}, error() {} }; + + function load(name) { + if (modules.has(name)) return modules.get(name); + const module = { exports: {} }; + modules.set(name, module.exports); + const requireMock = (specifier) => { + switch (specifier) { + case "electron": + return { app: { getPath: () => root } }; + case "electron-log": + return logger; + case "./GameUpdater.js": + return load("GameUpdater"); + case "child_process": + return { + spawn(executable, args, options) { + launches.push({ executable, args: Array.from(args), options }); + const child = new EventEmitter(); + queueMicrotask(() => child.emit("spawn")); + return child; + }, + }; + case "p-queue": + // Downloads are outside the launch path under test. + return class {}; + default: + return require(specifier); + } + }; + + vm.runInNewContext(compiledModules.get(name), { + module, + exports: module.exports, + require: requireMock, + process: { platform, arch, env: { PATH: "/review/bin" } }, + }); + return module.exports; + } + + const client = new (load("GameClient").GameClient)(); + client.setGameUpdater({ + getGameStatus: async () => ({ isInstalled: true, needsUpdate: false }), + }); + client.onSettingsUpdate({ + gameRamAllocation: 2, + devModeEnabled: false, + devGameArgs: 'CONFIGURATION_FILE="config with spaces.properties"', + }); + return { client, launches }; +} + +const targets = [ + { platform: "darwin", arch: "x64", entry: "macos_x64", separator: ":", java: "jre/Contents/Home/bin/java" }, + { platform: "darwin", arch: "arm64", entry: "macos_arm64", separator: ":", java: "jre/Contents/Home/bin/java" }, + { platform: "linux", arch: "x64", entry: "linux_x64", separator: ":", java: "jre/bin/java" }, + { platform: "win32", arch: "x64", entry: "windows_x64", separator: ";", java: "jre/bin/java.exe" }, +]; + +for (const target of targets) { + const launchModes = target.platform === "darwin" ? ["game", "replay"] : ["game"]; + for (const mode of launchModes) { + test(`${target.entry}: launch ${mode} with the bundled runtime`, async (t) => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "launcher native runtime ")); + t.after(() => fs.rmSync(root, { recursive: true, force: true })); + const gameRoot = path.join(root, "ArenaReturnsClient"); + const java = path.join(gameRoot, target.java); + const keytool = path.join(path.dirname(java), "keytool"); + const replay = path.join(gameRoot, "game/replays/replay with spaces.rda"); + const includeLib = target.arch === "x64"; + const files = [ + java, + keytool, + replay, + path.join(gameRoot, "game/core.jar"), + path.join(gameRoot, "natives/shared.jar"), + path.join(gameRoot, "natives", target.entry, "native.jar"), + ...(includeLib ? [path.join(gameRoot, "lib/library.jar")] : []), + ]; + for (const file of files) { + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync(file, "fixture", { mode: 0o644 }); + } + + const { client, launches } = createClient(target.platform, target.arch, root); + if (mode === "replay") await client.launchReplayOffline(replay); + else await client.launchGame(); + + assert.equal(launches.length, 1); + const { executable, args, options } = launches[0]; + assert.equal(executable, java, "spawn the bundled Java executable directly"); + assert.equal(options.cwd, path.join(gameRoot, "game")); + assert.equal(options.env.PATH, "/review/bin"); + + const cpIndex = args.indexOf("-cp"); + assert.ok(cpIndex >= 0); + const expectedClasspath = [ + ...(includeLib ? [path.join(gameRoot, "lib/library.jar")] : []), + path.join(gameRoot, "natives/*"), + path.join(gameRoot, "natives", target.entry, "*"), + path.join(gameRoot, "game/core.jar"), + ].join(target.separator); + assert.equal(args[cpIndex + 1], expectedClasspath); + assert.equal( + args[cpIndex + 2], + mode === "replay" + ? "com.ankamagames.dofusarena.client.DofusArenaReplayPlayer" + : "com.ankamagames.dofusarena.client.DofusArenaClient", + ); + assert.ok(args.includes("-CONFIGURATION_FILE=config with spaces.properties")); + if (mode === "replay") assert.ok(args.includes(`-REPLAY_FILE_PATH=${replay}`)); + + const firstThreadIndex = args.indexOf("-XstartOnFirstThread"); + if (target.platform === "darwin") { + assert.ok(firstThreadIndex >= 0 && firstThreadIndex < cpIndex); + if (process.platform !== "win32") { + assert.equal(fs.statSync(java).mode & 0o777, 0o755); + assert.equal(fs.statSync(keytool).mode & 0o777, 0o755); + } + } else { + assert.equal(firstThreadIndex, -1, "keep macOS JVM options platform-specific"); + } + }); + } +} From c1e29c282e37cc9efb6b6a02ceedf56c776a6a79 Mon Sep 17 00:00:00 2001 From: Jordan Rey Date: Mon, 14 Sep 2026 22:08:30 +0200 Subject: [PATCH 6/6] chore: remove test additions from macOS fix --- .github/workflows/compile.yml | 1 - packages/main/package.json | 3 +- packages/main/test/GameClient.test.cjs | 154 ------------------------- 3 files changed, 1 insertion(+), 157 deletions(-) delete mode 100644 packages/main/test/GameClient.test.cjs diff --git a/.github/workflows/compile.yml b/.github/workflows/compile.yml index a454e57..eff07cd 100644 --- a/.github/workflows/compile.yml +++ b/.github/workflows/compile.yml @@ -39,7 +39,6 @@ jobs: cache: "npm" - run: npm ci - run: npm run typecheck --if-present - - run: npm test --workspaces --if-present compile: strategy: diff --git a/packages/main/package.json b/packages/main/package.json index f7b73f6..7e8f2f7 100644 --- a/packages/main/package.json +++ b/packages/main/package.json @@ -3,7 +3,6 @@ "type": "module", "scripts": { "build": "vite build", - "test": "node --test test/*.test.cjs", "typecheck": "tsc --noEmit" }, "exports": { @@ -25,4 +24,4 @@ "typescript": "5.9.2", "vite": "7.1.3" } -} +} \ No newline at end of file diff --git a/packages/main/test/GameClient.test.cjs b/packages/main/test/GameClient.test.cjs deleted file mode 100644 index 9777a44..0000000 --- a/packages/main/test/GameClient.test.cjs +++ /dev/null @@ -1,154 +0,0 @@ -const assert = require("node:assert/strict"); -const { EventEmitter } = require("node:events"); -const fs = require("node:fs"); -const os = require("node:os"); -const path = require("node:path"); -const test = require("node:test"); -const vm = require("node:vm"); -const ts = require("typescript"); - -// Exercise the real launch code without starting Electron or a platform JVM. -const compiledModules = new Map( - ["GameClient", "GameUpdater"].map((name) => [ - name, - ts.transpileModule( - fs.readFileSync(path.join(__dirname, "../src/modules", `${name}.ts`), "utf8"), - { - compilerOptions: { - module: ts.ModuleKind.CommonJS, - target: ts.ScriptTarget.ES2022, - esModuleInterop: true, - }, - }, - ).outputText, - ]), -); - -function createClient(platform, arch, root) { - const launches = []; - const modules = new Map(); - const logger = { info() {}, debug() {}, warn() {}, error() {} }; - - function load(name) { - if (modules.has(name)) return modules.get(name); - const module = { exports: {} }; - modules.set(name, module.exports); - const requireMock = (specifier) => { - switch (specifier) { - case "electron": - return { app: { getPath: () => root } }; - case "electron-log": - return logger; - case "./GameUpdater.js": - return load("GameUpdater"); - case "child_process": - return { - spawn(executable, args, options) { - launches.push({ executable, args: Array.from(args), options }); - const child = new EventEmitter(); - queueMicrotask(() => child.emit("spawn")); - return child; - }, - }; - case "p-queue": - // Downloads are outside the launch path under test. - return class {}; - default: - return require(specifier); - } - }; - - vm.runInNewContext(compiledModules.get(name), { - module, - exports: module.exports, - require: requireMock, - process: { platform, arch, env: { PATH: "/review/bin" } }, - }); - return module.exports; - } - - const client = new (load("GameClient").GameClient)(); - client.setGameUpdater({ - getGameStatus: async () => ({ isInstalled: true, needsUpdate: false }), - }); - client.onSettingsUpdate({ - gameRamAllocation: 2, - devModeEnabled: false, - devGameArgs: 'CONFIGURATION_FILE="config with spaces.properties"', - }); - return { client, launches }; -} - -const targets = [ - { platform: "darwin", arch: "x64", entry: "macos_x64", separator: ":", java: "jre/Contents/Home/bin/java" }, - { platform: "darwin", arch: "arm64", entry: "macos_arm64", separator: ":", java: "jre/Contents/Home/bin/java" }, - { platform: "linux", arch: "x64", entry: "linux_x64", separator: ":", java: "jre/bin/java" }, - { platform: "win32", arch: "x64", entry: "windows_x64", separator: ";", java: "jre/bin/java.exe" }, -]; - -for (const target of targets) { - const launchModes = target.platform === "darwin" ? ["game", "replay"] : ["game"]; - for (const mode of launchModes) { - test(`${target.entry}: launch ${mode} with the bundled runtime`, async (t) => { - const root = fs.mkdtempSync(path.join(os.tmpdir(), "launcher native runtime ")); - t.after(() => fs.rmSync(root, { recursive: true, force: true })); - const gameRoot = path.join(root, "ArenaReturnsClient"); - const java = path.join(gameRoot, target.java); - const keytool = path.join(path.dirname(java), "keytool"); - const replay = path.join(gameRoot, "game/replays/replay with spaces.rda"); - const includeLib = target.arch === "x64"; - const files = [ - java, - keytool, - replay, - path.join(gameRoot, "game/core.jar"), - path.join(gameRoot, "natives/shared.jar"), - path.join(gameRoot, "natives", target.entry, "native.jar"), - ...(includeLib ? [path.join(gameRoot, "lib/library.jar")] : []), - ]; - for (const file of files) { - fs.mkdirSync(path.dirname(file), { recursive: true }); - fs.writeFileSync(file, "fixture", { mode: 0o644 }); - } - - const { client, launches } = createClient(target.platform, target.arch, root); - if (mode === "replay") await client.launchReplayOffline(replay); - else await client.launchGame(); - - assert.equal(launches.length, 1); - const { executable, args, options } = launches[0]; - assert.equal(executable, java, "spawn the bundled Java executable directly"); - assert.equal(options.cwd, path.join(gameRoot, "game")); - assert.equal(options.env.PATH, "/review/bin"); - - const cpIndex = args.indexOf("-cp"); - assert.ok(cpIndex >= 0); - const expectedClasspath = [ - ...(includeLib ? [path.join(gameRoot, "lib/library.jar")] : []), - path.join(gameRoot, "natives/*"), - path.join(gameRoot, "natives", target.entry, "*"), - path.join(gameRoot, "game/core.jar"), - ].join(target.separator); - assert.equal(args[cpIndex + 1], expectedClasspath); - assert.equal( - args[cpIndex + 2], - mode === "replay" - ? "com.ankamagames.dofusarena.client.DofusArenaReplayPlayer" - : "com.ankamagames.dofusarena.client.DofusArenaClient", - ); - assert.ok(args.includes("-CONFIGURATION_FILE=config with spaces.properties")); - if (mode === "replay") assert.ok(args.includes(`-REPLAY_FILE_PATH=${replay}`)); - - const firstThreadIndex = args.indexOf("-XstartOnFirstThread"); - if (target.platform === "darwin") { - assert.ok(firstThreadIndex >= 0 && firstThreadIndex < cpIndex); - if (process.platform !== "win32") { - assert.equal(fs.statSync(java).mode & 0o777, 0o755); - assert.equal(fs.statSync(keytool).mode & 0o777, 0o755); - } - } else { - assert.equal(firstThreadIndex, -1, "keep macOS JVM options platform-specific"); - } - }); - } -}