From 8037a5f72589ee57f5e49f0c24a5b4c68a943581 Mon Sep 17 00:00:00 2001 From: Jordan Rey Date: Thu, 3 Jul 2025 00:53:41 +0200 Subject: [PATCH 1/7] improve communication between renderer and preload --- packages/preload/src/index.ts | 2 +- .../renderer/src/components/MainLauncher.tsx | 2 +- .../renderer/src/components/Preloader.tsx | 2 +- .../renderer/src/components/SettingsMenu.tsx | 2 +- packages/renderer/src/components/TitleBar.tsx | 56 +++++++------------ .../components/launcher/DownloadButton.tsx | 2 +- .../src/components/launcher/GameTab.tsx | 2 +- .../src/components/launcher/ReplaysTab.tsx | 2 +- packages/renderer/src/lib/electronAPI.ts | 37 ------------ packages/renderer/src/vite-env.d.ts | 49 ---------------- packages/renderer/vite.config.ts | 1 + 11 files changed, 27 insertions(+), 130 deletions(-) delete mode 100644 packages/renderer/src/lib/electronAPI.ts diff --git a/packages/preload/src/index.ts b/packages/preload/src/index.ts index 2493a53..51d12ef 100644 --- a/packages/preload/src/index.ts +++ b/packages/preload/src/index.ts @@ -12,7 +12,7 @@ function send(channel: string, message: string) { // IPC event listener functions const ipcEvents = { on: (channel: string, listener: (...args: unknown[]) => void) => { - ipcRenderer.on(channel, (event, ...args) => listener(...args)); + ipcRenderer.on(channel, (_event, ...args) => listener(...args)); }, off: (channel: string, listener: (...args: unknown[]) => void) => { ipcRenderer.off(channel, listener); diff --git a/packages/renderer/src/components/MainLauncher.tsx b/packages/renderer/src/components/MainLauncher.tsx index bdff680..bb2aeb5 100644 --- a/packages/renderer/src/components/MainLauncher.tsx +++ b/packages/renderer/src/components/MainLauncher.tsx @@ -13,7 +13,7 @@ import { } from "@/components/ui/dialog"; import { Button } from "@/components/ui/button"; import type { SettingsState } from "@/types"; -import { gameClient } from "@/lib/electronAPI"; +import { gameClient } from "@app/preload"; import log from "@/utils/logger"; import backgroundImage from "@/assets/background.jpg"; diff --git a/packages/renderer/src/components/Preloader.tsx b/packages/renderer/src/components/Preloader.tsx index 2ad121c..e25c313 100644 --- a/packages/renderer/src/components/Preloader.tsx +++ b/packages/renderer/src/components/Preloader.tsx @@ -3,7 +3,7 @@ import { Progress } from "@/components/ui/progress"; import { Button } from "@/components/ui/button"; import { StatusCard } from "@/components/ui/status-card"; import { TitleBar } from "./TitleBar"; -import { preloader, updater, ipcEvents } from "@/lib/electronAPI"; +import { preloader, updater, ipcEvents } from "@app/preload"; import log from "@/utils/logger"; import backgroundImage from "@/assets/background.jpg"; import logoImage from "@/assets/logo.webp"; diff --git a/packages/renderer/src/components/SettingsMenu.tsx b/packages/renderer/src/components/SettingsMenu.tsx index 0eae833..ef2dc5e 100644 --- a/packages/renderer/src/components/SettingsMenu.tsx +++ b/packages/renderer/src/components/SettingsMenu.tsx @@ -18,7 +18,7 @@ import { } from "lucide-react"; import { SimpleSlider, SimpleSelect } from "./common/FormControls"; import type { SettingsState } from "@/types"; -import { gameClient, system, ipcEvents } from "@/lib/electronAPI"; +import { gameClient, system, ipcEvents } from "@app/preload"; import log from "@/utils/logger"; interface SettingsMenuProps { diff --git a/packages/renderer/src/components/TitleBar.tsx b/packages/renderer/src/components/TitleBar.tsx index faa432d..20a804c 100644 --- a/packages/renderer/src/components/TitleBar.tsx +++ b/packages/renderer/src/components/TitleBar.tsx @@ -1,6 +1,7 @@ -import React, { useState, useEffect } from "react"; +import React from "react"; +import { Settings, Minus, X } from "lucide-react"; import { Button } from "@/components/ui/button"; -import { X, Minus, Settings } from "lucide-react"; +import { windowControls } from "@app/preload"; interface TitleBarProps { title?: string; @@ -8,38 +9,17 @@ interface TitleBarProps { onSettingsClick?: () => void; } -interface WindowControlsAPI { - minimize: () => Promise; - close: () => Promise; -} - export const TitleBar: React.FC = ({ title = "Arena Returns Launcher", className = "", onSettingsClick, }) => { - const [windowControls, setWindowControls] = - useState(null); - - useEffect(() => { - // Get window controls from the preload script - const controlsKey = btoa("windowControls"); - const controls = (window as unknown as Record)[ - controlsKey - ] as WindowControlsAPI; - setWindowControls(controls); - }, []); - const handleMinimize = () => { - if (windowControls) { - windowControls.minimize(); - } + windowControls.minimize(); }; const handleClose = () => { - if (windowControls) { - windowControls.close(); - } + windowControls.close(); }; return ( @@ -54,38 +34,40 @@ export const TitleBar: React.FC = ({ - {/* Window Controls */} -
+ {/* Settings Button */} +
{onSettingsClick && ( )} +
+ {/* Window Controls */} +
-
diff --git a/packages/renderer/src/components/launcher/DownloadButton.tsx b/packages/renderer/src/components/launcher/DownloadButton.tsx index 8bc4c6a..76ce9fe 100644 --- a/packages/renderer/src/components/launcher/DownloadButton.tsx +++ b/packages/renderer/src/components/launcher/DownloadButton.tsx @@ -2,7 +2,7 @@ import React, { useState, useEffect } from "react"; import { Button } from "@/components/ui/button"; import { Progress } from "@/components/ui/progress"; import { Download, Play, Pause, AlertCircle, RotateCw } from "lucide-react"; -import { gameClient, ipcEvents } from "@/lib/electronAPI"; +import { gameClient, ipcEvents } from "@app/preload"; import log from "@/utils/logger"; interface DownloadButtonProps { diff --git a/packages/renderer/src/components/launcher/GameTab.tsx b/packages/renderer/src/components/launcher/GameTab.tsx index 5b2f316..1c54df2 100644 --- a/packages/renderer/src/components/launcher/GameTab.tsx +++ b/packages/renderer/src/components/launcher/GameTab.tsx @@ -3,7 +3,7 @@ import { Button } from "@/components/ui/button"; import { Badge } from "@/components/ui/badge"; import { Globe, MessageCircle } from "lucide-react"; import { DownloadButton } from "./DownloadButton"; -import { news, gameClient, ipcEvents, system } from "@/lib/electronAPI"; +import { news, gameClient, ipcEvents, system } from "@app/preload"; import log from "@/utils/logger"; import logoImage from "@/assets/logo.webp"; diff --git a/packages/renderer/src/components/launcher/ReplaysTab.tsx b/packages/renderer/src/components/launcher/ReplaysTab.tsx index 6a4e98b..c02917a 100644 --- a/packages/renderer/src/components/launcher/ReplaysTab.tsx +++ b/packages/renderer/src/components/launcher/ReplaysTab.tsx @@ -13,7 +13,7 @@ import { ChevronLeft, ChevronRight, } from "lucide-react"; -import { gameClient, ipcEvents } from "@/lib/electronAPI"; +import { gameClient, ipcEvents } from "@app/preload"; import log from "@/utils/logger"; interface ReplaysTabProps { diff --git a/packages/renderer/src/lib/electronAPI.ts b/packages/renderer/src/lib/electronAPI.ts deleted file mode 100644 index 9fd5d0f..0000000 --- a/packages/renderer/src/lib/electronAPI.ts +++ /dev/null @@ -1,37 +0,0 @@ -// Utility to access electron APIs exposed via base64 encoded keys - -function getElectronAPI(key: string): T { - const encodedKey = btoa(key); - return (window as unknown as Record)[encodedKey] as T; -} - -// Export typed APIs -export const windowControls = getElectronAPI("windowControls"); -export const system = getElectronAPI<{ - openExternal: (url: string) => Promise; - getAppVersion: () => Promise; - getLogDirectory: () => Promise; - openLogDirectory: () => Promise; -}>("system"); -export const gameClient = getElectronAPI("gameClient"); -export const news = getElectronAPI("news"); -export const updater = getElectronAPI("updater"); -export const preloader = getElectronAPI("preloader"); -export const ipcEvents = getElectronAPI<{ - on: (channel: string, listener: (...args: unknown[]) => void) => void; - off: (channel: string, listener: (...args: unknown[]) => void) => void; - removeAllListeners: (channel: string) => void; -}>("ipcEvents"); - -// Legacy APIs -export const sha256sum = - getElectronAPI<(data: Buffer | string) => Promise>("sha256sum"); -export const versions = getElectronAPI<{ - node: string; - chrome: string; - electron: string; -}>("versions"); -export const send = - getElectronAPI<(channel: string, message: string) => Promise>( - "send" - ); diff --git a/packages/renderer/src/vite-env.d.ts b/packages/renderer/src/vite-env.d.ts index 9e514de..75a2d5a 100644 --- a/packages/renderer/src/vite-env.d.ts +++ b/packages/renderer/src/vite-env.d.ts @@ -1,12 +1,5 @@ /// -interface WindowControls { - minimize: () => Promise; - maximize: () => Promise; - close: () => Promise; - isMaximized: () => Promise; -} - interface GameStatus { isInstalled: boolean; needsUpdate: boolean; @@ -41,21 +34,6 @@ interface ReplayFile { isValidFormat: boolean; } -interface GameClient { - getStatus: () => Promise; - checkForUpdates: () => Promise; - startDownload: () => Promise; - getDownloadProgress: () => Promise; - cancelDownload: () => Promise; - launchGame: (settings?: GameSettings) => Promise; - launchReplay: (replayPath: string, settings?: GameSettings) => Promise; - repairClient: () => Promise; - openGameDirectory: () => Promise; - openReplaysFolder: () => Promise; - listReplays: () => Promise; - updateSettings: (settings: GameSettings) => Promise; -} - interface NewsArticle { id: number; documentId: string; @@ -149,11 +127,6 @@ interface NewsState { lastFetch?: number; } -interface News { - getArticles: () => Promise; - refreshArticles: () => Promise; -} - interface UpdateProgress { bytesPerSecond: number; percent: number; @@ -170,29 +143,7 @@ interface UpdateStatus { version?: string; } -interface Updater { - getStatus: () => Promise; - checkForUpdates: () => Promise; - downloadUpdate: () => Promise; - installUpdate: () => Promise; -} - interface PreloaderInitResult { gameStatus: GameStatus; updateStatus: UpdateStatus; } - -interface PreloaderAPI { - initializeApp: () => Promise; -} - -declare global { - interface Window { - [key: string]: unknown; // For the base64 encoded exports from preload - // We'll access these via the base64 encoded keys, but these interfaces help with typing - electronAPI?: { - openGameDirectory?: () => Promise; - openReplaysFolder?: () => Promise; - }; - } -} diff --git a/packages/renderer/vite.config.ts b/packages/renderer/vite.config.ts index 506e619..103a140 100644 --- a/packages/renderer/vite.config.ts +++ b/packages/renderer/vite.config.ts @@ -9,6 +9,7 @@ export default defineConfig({ resolve: { alias: { "@": path.resolve(__dirname, "./src"), + "@app/preload": path.resolve(__dirname, "../preload"), }, }, }); From dba962f75cc04fcdf09c1c2c53afc6433ecff0db Mon Sep 17 00:00:00 2001 From: Jordan Rey Date: Thu, 3 Jul 2025 02:04:10 +0200 Subject: [PATCH 2/7] initialize renderer logging --- packages/main/src/utils/logger.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/main/src/utils/logger.ts b/packages/main/src/utils/logger.ts index 5fedbe4..3136e1b 100644 --- a/packages/main/src/utils/logger.ts +++ b/packages/main/src/utils/logger.ts @@ -36,6 +36,9 @@ export function setupLogger() { "[{y}-{m}-{d} {h}:{i}:{s}.{ms}] [{level}] {text}"; log.transports.console.format = "[{h}:{i}:{s}.{ms}] [{level}] {text}"; + // Initialize renderer logging + log.initialize(); + // Log startup info log.info("=== Launcher Started ==="); log.info("App Version:", app.getVersion()); From 87c5c249a33c32085569b9ae646c23cac8be28a6 Mon Sep 17 00:00:00 2001 From: Jordan Rey Date: Thu, 3 Jul 2025 02:15:43 +0200 Subject: [PATCH 3/7] rename autoupdater --- packages/main/src/index.ts | 8 +- ...wdOrigins.ts => BlockNotAllowedOrigins.ts} | 0 .../{AutoUpdater.ts => LauncherUpdater.ts} | 96 +++++++++++-------- packages/main/src/modules/NewsModule.ts | 7 +- packages/main/src/modules/WindowManager.ts | 4 +- packages/preload/src/index.ts | 8 +- .../renderer/src/components/Preloader.tsx | 11 ++- 7 files changed, 71 insertions(+), 63 deletions(-) rename packages/main/src/modules/{BlockNotAllowdOrigins.ts => BlockNotAllowedOrigins.ts} (100%) rename packages/main/src/modules/{AutoUpdater.ts => LauncherUpdater.ts} (73%) diff --git a/packages/main/src/index.ts b/packages/main/src/index.ts index b046ffa..f332012 100644 --- a/packages/main/src/index.ts +++ b/packages/main/src/index.ts @@ -5,8 +5,8 @@ import { disallowMultipleAppInstance } from "./modules/SingleInstanceApp.js"; import { createWindowManagerModule } from "./modules/WindowManager.js"; import { terminateAppOnLastWindowClose } from "./modules/ApplicationTerminatorOnLastWindowClose.js"; import { hardwareAccelerationMode } from "./modules/HardwareAccelerationModule.js"; -import { autoUpdater } from "./modules/AutoUpdater.js"; -import { allowInternalOrigins } from "./modules/BlockNotAllowdOrigins.js"; +import { launcherUpdater } from "./modules/LauncherUpdater.js"; +import { allowInternalOrigins } from "./modules/BlockNotAllowedOrigins.js"; import { allowExternalUrls } from "./modules/ExternalUrls.js"; import { createGameClientModule } from "./modules/GameClientModule.js"; import { createNewsModule } from "./modules/NewsModule.js"; @@ -14,7 +14,7 @@ import { ALLOWED_EXTERNAL_ORIGINS } from "./config/allowedUrls.js"; export async function initApp(initConfig: AppInitConfig) { // Initialize logging first - const logger = setupLogger(); + setupLogger(); const moduleRunner = createModuleRunner() .init( @@ -26,7 +26,7 @@ export async function initApp(initConfig: AppInitConfig) { .init(disallowMultipleAppInstance()) .init(terminateAppOnLastWindowClose()) .init(hardwareAccelerationMode({ enable: true })) - .init(autoUpdater()) + .init(launcherUpdater()) .init(createGameClientModule()) .init(createNewsModule()) diff --git a/packages/main/src/modules/BlockNotAllowdOrigins.ts b/packages/main/src/modules/BlockNotAllowedOrigins.ts similarity index 100% rename from packages/main/src/modules/BlockNotAllowdOrigins.ts rename to packages/main/src/modules/BlockNotAllowedOrigins.ts diff --git a/packages/main/src/modules/AutoUpdater.ts b/packages/main/src/modules/LauncherUpdater.ts similarity index 73% rename from packages/main/src/modules/AutoUpdater.ts rename to packages/main/src/modules/LauncherUpdater.ts index 17c9f24..f192ef1 100644 --- a/packages/main/src/modules/AutoUpdater.ts +++ b/packages/main/src/modules/LauncherUpdater.ts @@ -27,7 +27,7 @@ export interface UpdateStatus { version?: string; } -export class AutoUpdater implements AppModule { +export class LauncherUpdater implements AppModule { readonly #notification: DownloadNotification; private updateStatus: UpdateStatus; private lastCheckTime: number = 0; @@ -47,20 +47,24 @@ export class AutoUpdater implements AppModule { } async enable(context: ModuleContext): Promise { - log.info("AutoUpdater: Initializing auto-updater module"); + log.info("LauncherUpdater: Initializing auto-updater module"); // Register IPC handlers - ipcMain.handle("updater:getStatus", () => this.getUpdateStatus()); - ipcMain.handle("updater:checkForUpdates", () => this.checkForUpdates()); - ipcMain.handle("updater:downloadUpdate", () => this.downloadUpdate()); - ipcMain.handle("updater:installUpdate", () => + ipcMain.handle("launcherUpdater:getStatus", () => this.getUpdateStatus()); + ipcMain.handle("launcherUpdater:checkForUpdates", () => + this.checkForUpdates() + ); + ipcMain.handle("launcherUpdater:downloadUpdate", () => + this.downloadUpdate() + ); + ipcMain.handle("launcherUpdater:installUpdate", () => this.installUpdateWithConfirmation() ); // Setup event listeners this.setupEventListeners(); - log.info("AutoUpdater: Module initialized successfully"); + log.info("LauncherUpdater: Module initialized successfully"); } getAutoUpdater(): AppUpdater { @@ -72,19 +76,19 @@ export class AutoUpdater implements AppModule { private setupEventListeners(): void { const updater = this.getAutoUpdater(); - log.info("AutoUpdater: Setting up event listeners"); + log.info("LauncherUpdater: Setting up event listeners"); // Configure electron-updater to use our electron-log instance updater.logger = log; // Configure transport level on our log instance (same object) log.transports.file.level = "info"; - log.info("AutoUpdater: Configured electron-updater logging"); + log.info("LauncherUpdater: Configured electron-updater logging"); // Force dev update config for debugging // updater.forceDevUpdateConfig = true; updater.on("update-available", (info) => { - log.info("AutoUpdater: Update available", { + log.info("LauncherUpdater: Update available", { version: info.version, releaseDate: info.releaseDate, }); @@ -97,7 +101,7 @@ export class AutoUpdater implements AppModule { }); updater.on("update-not-available", () => { - log.info("AutoUpdater: No updates available"); + log.info("LauncherUpdater: No updates available"); this.updateStatus = { ...this.updateStatus, available: false, @@ -106,7 +110,7 @@ export class AutoUpdater implements AppModule { }); updater.on("download-progress", (progress: ProgressInfo) => { - log.debug("AutoUpdater: Download progress", { + log.debug("LauncherUpdater: Download progress", { percent: Math.round(progress.percent), transferred: progress.transferred, total: progress.total, @@ -126,7 +130,7 @@ export class AutoUpdater implements AppModule { }); updater.on("update-downloaded", () => { - log.info("AutoUpdater: Update downloaded successfully"); + log.info("LauncherUpdater: Update downloaded successfully"); this.updateStatus = { ...this.updateStatus, downloading: false, @@ -136,7 +140,7 @@ export class AutoUpdater implements AppModule { }); updater.on("error", (error) => { - log.error("AutoUpdater: Error occurred", error); + log.error("LauncherUpdater: Error occurred", error); this.updateStatus = { ...this.updateStatus, downloading: false, @@ -150,7 +154,7 @@ export class AutoUpdater implements AppModule { const windows = BrowserWindow.getAllWindows(); windows.forEach((window) => { if (!window.isDestroyed()) { - window.webContents.send(`updater:${event}`, data); + window.webContents.send(`launcherUpdater:${event}`, data); } }); } @@ -164,7 +168,7 @@ export class AutoUpdater implements AppModule { const now = Date.now(); if (now - this.lastCheckTime < this.CHECK_COOLDOWN_MS) { log.debug( - "AutoUpdater: Skipping update check - too soon after last check", + "LauncherUpdater: Skipping update check - too soon after last check", { timeSinceLastCheck: now - this.lastCheckTime, cooldownMs: this.CHECK_COOLDOWN_MS, @@ -176,12 +180,12 @@ export class AutoUpdater implements AppModule { const updater = this.getAutoUpdater(); try { - log.info("AutoUpdater: Checking for updates"); + log.info("LauncherUpdater: Checking for updates"); updater.fullChangelog = true; if (import.meta.env.VITE_DISTRIBUTION_CHANNEL) { log.info( - "AutoUpdater: Using distribution channel", + "LauncherUpdater: Using distribution channel", import.meta.env.VITE_DISTRIBUTION_CHANNEL ); updater.channel = import.meta.env.VITE_DISTRIBUTION_CHANNEL; @@ -200,7 +204,7 @@ export class AutoUpdater implements AppModule { // In production, it returns an UpdateCheckResult object even when no update is available // We need to check the updateStatus.available flag that gets set by events const hasUpdate = result !== null && this.updateStatus.available; - log.info("AutoUpdater: Check for updates completed", { + log.info("LauncherUpdater: Check for updates completed", { hasUpdate, resultExists: !!result, }); @@ -209,10 +213,10 @@ export class AutoUpdater implements AppModule { } catch (error) { if (error instanceof Error) { if (error.message.includes("No published versions")) { - log.warn("AutoUpdater: No published versions available"); + log.warn("LauncherUpdater: No published versions available"); return false; } - log.error("AutoUpdater: Failed to check for updates", error); + log.error("LauncherUpdater: Failed to check for updates", error); } throw error; @@ -222,42 +226,48 @@ export class AutoUpdater implements AppModule { async downloadUpdate(): Promise { const updater = this.getAutoUpdater(); if (this.updateStatus.available) { - log.info("AutoUpdater: Starting update download"); + log.info("LauncherUpdater: Starting update download"); await updater.downloadUpdate(); } else { - log.warn("AutoUpdater: Attempted to download update but none available"); + log.warn( + "LauncherUpdater: Attempted to download update but none available" + ); } } installUpdate(): void { const updater = this.getAutoUpdater(); if (this.updateStatus.downloaded) { - log.info("AutoUpdater: Installing update and restarting application"); + log.info("LauncherUpdater: Installing update and restarting application"); updater.quitAndInstall(); } else { - log.warn("AutoUpdater: Attempted to install update but none downloaded"); + log.warn( + "LauncherUpdater: Attempted to install update but none downloaded" + ); } } async installUpdateWithConfirmation(): Promise { - log.info("AutoUpdater: installUpdateWithConfirmation called", { + log.info("LauncherUpdater: installUpdateWithConfirmation called", { downloaded: this.updateStatus.downloaded, version: this.updateStatus.version, }); if (!this.updateStatus.downloaded) { - log.warn("AutoUpdater: Attempted to install update but none downloaded"); + log.warn( + "LauncherUpdater: Attempted to install update but none downloaded" + ); return; } try { - log.info("AutoUpdater: Showing mandatory update installation dialog"); + log.info("LauncherUpdater: Showing mandatory update installation dialog"); const allWindows = BrowserWindow.getAllWindows(); const focusedWindow = BrowserWindow.getFocusedWindow(); const targetWindow = focusedWindow || allWindows[0]; - log.info("AutoUpdater: Window context", { + log.info("LauncherUpdater: Window context", { allWindowsCount: allWindows.length, hasFocusedWindow: !!focusedWindow, targetWindowExists: !!targetWindow, @@ -265,7 +275,7 @@ export class AutoUpdater implements AppModule { if (!targetWindow) { log.error( - "AutoUpdater: No window available for dialog, installing directly" + "LauncherUpdater: No window available for dialog, installing directly" ); this.installUpdate(); return; @@ -285,13 +295,15 @@ export class AutoUpdater implements AppModule { icon: undefined, // Use default icon }); - log.info("AutoUpdater: Dialog result", { response: result.response }); - log.info("AutoUpdater: Proceeding with mandatory update installation"); + log.info("LauncherUpdater: Dialog result", { response: result.response }); + log.info( + "LauncherUpdater: Proceeding with mandatory update installation" + ); this.installUpdate(); } catch (error) { - log.error("AutoUpdater: Failed to show confirmation dialog", error); + log.error("LauncherUpdater: Failed to show confirmation dialog", error); // Always install even if dialog fails - it's mandatory - log.info("AutoUpdater: Installing update anyway (mandatory)"); + log.info("LauncherUpdater: Installing update anyway (mandatory)"); this.installUpdate(); } } @@ -299,12 +311,12 @@ export class AutoUpdater implements AppModule { async runAutoUpdater(): Promise { const updater = this.getAutoUpdater(); try { - log.info("AutoUpdater: Running auto-updater with notifications"); + log.info("LauncherUpdater: Running auto-updater with notifications"); updater.fullChangelog = true; if (import.meta.env.VITE_DISTRIBUTION_CHANNEL) { log.info( - "AutoUpdater: Using distribution channel for auto-updater", + "LauncherUpdater: Using distribution channel for auto-updater", import.meta.env.VITE_DISTRIBUTION_CHANNEL ); updater.channel = import.meta.env.VITE_DISTRIBUTION_CHANNEL; @@ -312,17 +324,17 @@ export class AutoUpdater implements AppModule { const result = await updater.checkForUpdatesAndNotify(this.#notification); const hasUpdate = !!result; - log.info("AutoUpdater: Auto-updater completed", { hasUpdate }); + log.info("LauncherUpdater: Auto-updater completed", { hasUpdate }); return hasUpdate; } catch (error) { if (error instanceof Error) { if (error.message.includes("No published versions")) { log.warn( - "AutoUpdater: No published versions available for auto-updater" + "LauncherUpdater: No published versions available for auto-updater" ); return false; } - log.error("AutoUpdater: Auto-updater failed", error); + log.error("LauncherUpdater: Auto-updater failed", error); } throw error; @@ -330,8 +342,8 @@ export class AutoUpdater implements AppModule { } } -export function autoUpdater( - ...args: ConstructorParameters +export function launcherUpdater( + ...args: ConstructorParameters ) { - return new AutoUpdater(...args); + return new LauncherUpdater(...args); } diff --git a/packages/main/src/modules/NewsModule.ts b/packages/main/src/modules/NewsModule.ts index e3aed86..a3596f7 100644 --- a/packages/main/src/modules/NewsModule.ts +++ b/packages/main/src/modules/NewsModule.ts @@ -3,11 +3,6 @@ import { ModuleContext } from "../ModuleContext.js"; import { ipcMain, BrowserWindow, app } from "electron"; import log from "electron-log"; -// Get app version from Electron's app metadata (works in both dev and packaged) -function getAppVersion(): string { - return app.getVersion(); -} - export interface NewsArticle { id: number; documentId: string; @@ -135,7 +130,7 @@ export class NewsModule implements AppModule { headers: { Authorization: `Bearer ${this.apiKey}`, "Content-Type": "application/json", - "User-Agent": `ArenaReturnsLauncher/${getAppVersion()}`, + "User-Agent": `ArenaReturnsLauncher/${app.getVersion()}`, }, }); diff --git a/packages/main/src/modules/WindowManager.ts b/packages/main/src/modules/WindowManager.ts index 5b77b80..8f667d2 100644 --- a/packages/main/src/modules/WindowManager.ts +++ b/packages/main/src/modules/WindowManager.ts @@ -1,6 +1,6 @@ import type { AppModule } from "../AppModule.js"; import { ModuleContext } from "../ModuleContext.js"; -import { BrowserWindow, ipcMain, app } from "electron"; +import { BrowserWindow, ipcMain, app, shell } from "electron"; import type { AppInitConfig } from "../AppInitConfig.js"; import { isUrlAllowed } from "../config/allowedUrls.js"; import { join } from "path"; @@ -58,7 +58,6 @@ class WindowManager implements AppModule { throw new Error(error); } - const { shell } = await import("electron"); try { await shell.openExternal(url); } catch (error) { @@ -79,7 +78,6 @@ class WindowManager implements AppModule { // Handle opening log directory ipcMain.handle("system:openLogDirectory", async () => { - const { shell } = await import("electron"); const logDirectory = join(app.getPath("userData"), "logs"); try { diff --git a/packages/preload/src/index.ts b/packages/preload/src/index.ts index 51d12ef..eb5c8dc 100644 --- a/packages/preload/src/index.ts +++ b/packages/preload/src/index.ts @@ -63,10 +63,10 @@ const news = { // Auto updater functions const updater = { - getStatus: () => ipcRenderer.invoke("updater:getStatus"), - checkForUpdates: () => ipcRenderer.invoke("updater:checkForUpdates"), - downloadUpdate: () => ipcRenderer.invoke("updater:downloadUpdate"), - installUpdate: () => ipcRenderer.invoke("updater:installUpdate"), + getStatus: () => ipcRenderer.invoke("launcherUpdater:getStatus"), + checkForUpdates: () => ipcRenderer.invoke("launcherUpdater:checkForUpdates"), + downloadUpdate: () => ipcRenderer.invoke("launcherUpdater:downloadUpdate"), + installUpdate: () => ipcRenderer.invoke("launcherUpdater:installUpdate"), }; // Preloader functions for initialization diff --git a/packages/renderer/src/components/Preloader.tsx b/packages/renderer/src/components/Preloader.tsx index e25c313..16f0c80 100644 --- a/packages/renderer/src/components/Preloader.tsx +++ b/packages/renderer/src/components/Preloader.tsx @@ -48,12 +48,15 @@ export const Preloader: React.FC = ({ onComplete }) => { shouldStopRef.current = true; // Stop initialization }; - ipcEvents.on("updater:download-progress", handleDownloadProgress); - ipcEvents.on("updater:update-error", handleUpdateError); + ipcEvents.on("launcherUpdater:download-progress", handleDownloadProgress); + ipcEvents.on("launcherUpdater:update-error", handleUpdateError); return () => { - ipcEvents.off("updater:download-progress", handleDownloadProgress); - ipcEvents.off("updater:update-error", handleUpdateError); + ipcEvents.off( + "launcherUpdater:download-progress", + handleDownloadProgress + ); + ipcEvents.off("launcherUpdater:update-error", handleUpdateError); }; }, []); From 774bbe955b3603c83f84530a224401bdecf6fb95 Mon Sep 17 00:00:00 2001 From: Jordan Rey Date: Thu, 3 Jul 2025 02:22:52 +0200 Subject: [PATCH 4/7] finish renaming autoupdater & split system from window --- packages/main/src/index.ts | 2 + packages/main/src/modules/SystemIPCModule.ts | 63 +++++++++++++++++++ packages/main/src/modules/WindowManager.ts | 43 ------------- packages/preload/src/index.ts | 14 ++--- .../renderer/src/components/Preloader.tsx | 14 ++--- 5 files changed, 79 insertions(+), 57 deletions(-) create mode 100644 packages/main/src/modules/SystemIPCModule.ts diff --git a/packages/main/src/index.ts b/packages/main/src/index.ts index f332012..2440811 100644 --- a/packages/main/src/index.ts +++ b/packages/main/src/index.ts @@ -10,6 +10,7 @@ import { allowInternalOrigins } from "./modules/BlockNotAllowedOrigins.js"; import { allowExternalUrls } from "./modules/ExternalUrls.js"; import { createGameClientModule } from "./modules/GameClientModule.js"; import { createNewsModule } from "./modules/NewsModule.js"; +import { systemIpcModule } from "./modules/SystemIPCModule.js"; import { ALLOWED_EXTERNAL_ORIGINS } from "./config/allowedUrls.js"; export async function initApp(initConfig: AppInitConfig) { @@ -29,6 +30,7 @@ export async function initApp(initConfig: AppInitConfig) { .init(launcherUpdater()) .init(createGameClientModule()) .init(createNewsModule()) + .init(systemIpcModule()) // Security .init( diff --git a/packages/main/src/modules/SystemIPCModule.ts b/packages/main/src/modules/SystemIPCModule.ts new file mode 100644 index 0000000..20a0b9d --- /dev/null +++ b/packages/main/src/modules/SystemIPCModule.ts @@ -0,0 +1,63 @@ +import { AppModule } from "../AppModule.js"; +import { ModuleContext } from "../ModuleContext.js"; +import { shell, ipcMain, app } from "electron"; +import * as path from "path"; +import log from "electron-log"; +import { isUrlAllowed } from "../config/allowedUrls.js"; + +export class SystemIPCModule implements AppModule { + private registerWindowControlHandlers(): void { + // Handle external URL opening + ipcMain.handle("system:openExternal", async (event, url: string) => { + // Validate URL before opening + if (!isUrlAllowed(url)) { + const error = `URL not allowed: ${url}`; + log.warn(error); + throw new Error(error); + } + + try { + await shell.openExternal(url); + } catch (error) { + log.error("Failed to open external URL:", error); + throw new Error(`Failed to open external URL: ${url}`); + } + }); + + // Handle app version requests + ipcMain.handle("system:getAppVersion", () => { + return app.getVersion(); + }); + + // Handle log directory requests + ipcMain.handle("system:getLogDirectory", () => { + return path.join(app.getPath("userData"), "logs"); + }); + + // Handle opening log directory + ipcMain.handle("system:openLogDirectory", async () => { + const logDirectory = path.join(app.getPath("userData"), "logs"); + + try { + await shell.openPath(logDirectory); + } catch (error) { + log.error("Failed to open log directory:", error); + throw new Error( + `Failed to open log directory: ${ + error instanceof Error ? error.message : "Unknown error" + }` + ); + } + }); + } + + enable(_context: ModuleContext): Promise | void { + this.registerWindowControlHandlers(); + } +} + +export function systemIpcModule( + ...args: ConstructorParameters +) { + return new SystemIPCModule(...args); +} diff --git a/packages/main/src/modules/WindowManager.ts b/packages/main/src/modules/WindowManager.ts index 8f667d2..3d3a47c 100644 --- a/packages/main/src/modules/WindowManager.ts +++ b/packages/main/src/modules/WindowManager.ts @@ -48,49 +48,6 @@ class WindowManager implements AppModule { window.close(); } }); - - // Handle external URL opening - ipcMain.handle("system:openExternal", async (event, url: string) => { - // Validate URL before opening - if (!isUrlAllowed(url)) { - const error = `URL not allowed: ${url}`; - log.warn(error); - throw new Error(error); - } - - try { - await shell.openExternal(url); - } catch (error) { - log.error("Failed to open external URL:", error); - throw new Error(`Failed to open external URL: ${url}`); - } - }); - - // Handle app version requests - ipcMain.handle("system:getAppVersion", () => { - return app.getVersion(); - }); - - // Handle log directory requests - ipcMain.handle("system:getLogDirectory", () => { - return join(app.getPath("userData"), "logs"); - }); - - // Handle opening log directory - ipcMain.handle("system:openLogDirectory", async () => { - const logDirectory = join(app.getPath("userData"), "logs"); - - try { - await shell.openPath(logDirectory); - } catch (error) { - log.error("Failed to open log directory:", error); - throw new Error( - `Failed to open log directory: ${ - error instanceof Error ? error.message : "Unknown error" - }` - ); - } - }); } async createWindow(): Promise { diff --git a/packages/preload/src/index.ts b/packages/preload/src/index.ts index eb5c8dc..3150ad8 100644 --- a/packages/preload/src/index.ts +++ b/packages/preload/src/index.ts @@ -61,8 +61,8 @@ const news = { refreshArticles: () => ipcRenderer.invoke("news:refreshArticles"), }; -// Auto updater functions -const updater = { +// Launcher updater functions +const launcherUpdater = { getStatus: () => ipcRenderer.invoke("launcherUpdater:getStatus"), checkForUpdates: () => ipcRenderer.invoke("launcherUpdater:checkForUpdates"), downloadUpdate: () => ipcRenderer.invoke("launcherUpdater:downloadUpdate"), @@ -75,16 +75,16 @@ const preloader = { // Check game status const gameStatus = await gameClient.getStatus(); - // Check for app updates - const updateStatus = await updater.getStatus(); - await updater.checkForUpdates(); + // Check for launcher updates + const launcherUpdateStatus = await launcherUpdater.getStatus(); + await launcherUpdater.checkForUpdates(); // Preload news (non-blocking) news.getArticles().catch(() => {}); // Silently fail return { gameStatus, - updateStatus, + launcherUpdateStatus, }; }, }; @@ -98,6 +98,6 @@ export { system, gameClient, news, - updater, + launcherUpdater, preloader, }; diff --git a/packages/renderer/src/components/Preloader.tsx b/packages/renderer/src/components/Preloader.tsx index 16f0c80..1175483 100644 --- a/packages/renderer/src/components/Preloader.tsx +++ b/packages/renderer/src/components/Preloader.tsx @@ -3,7 +3,7 @@ import { Progress } from "@/components/ui/progress"; import { Button } from "@/components/ui/button"; import { StatusCard } from "@/components/ui/status-card"; import { TitleBar } from "./TitleBar"; -import { preloader, updater, ipcEvents } from "@app/preload"; +import { preloader, launcherUpdater, ipcEvents } from "@app/preload"; import log from "@/utils/logger"; import backgroundImage from "@/assets/background.jpg"; import logoImage from "@/assets/logo.webp"; @@ -71,10 +71,10 @@ export const Preloader: React.FC = ({ onComplete }) => { setCurrentMessage("Vérification des mises à jour du launcher..."); setProgress(20); - let currentUpdateStatus = await updater.getStatus(); + let currentUpdateStatus = await launcherUpdater.getStatus(); log.info("Preloader: Initial update status:", currentUpdateStatus); - const hasUpdate = await updater.checkForUpdates(); + const hasUpdate = await launcherUpdater.checkForUpdates(); log.info("Preloader: Update check result:", { hasUpdate }); if (hasUpdate) { @@ -82,7 +82,7 @@ export const Preloader: React.FC = ({ onComplete }) => { setCurrentMessage("Téléchargement de la mise à jour..."); setProgress(25); - await updater.downloadUpdate(); + await launcherUpdater.downloadUpdate(); // BLOCKING: Wait for download to complete by polling status log.info("Preloader: Waiting for update download to complete"); @@ -90,7 +90,7 @@ export const Preloader: React.FC = ({ onComplete }) => { while (!downloadComplete) { await new Promise((resolve) => setTimeout(resolve, 500)); - currentUpdateStatus = await updater.getStatus(); + currentUpdateStatus = await launcherUpdater.getStatus(); if (currentUpdateStatus.downloaded) { downloadComplete = true; @@ -117,8 +117,8 @@ export const Preloader: React.FC = ({ onComplete }) => { ); try { - log.info("Preloader: Calling updater.installUpdate()"); - await updater.installUpdate(); + log.info("Preloader: Calling launcherUpdater.installUpdate()"); + await launcherUpdater.installUpdate(); // This should quit and restart - code after this won't execute log.info("Preloader: Install called, app should restart"); From b7d13ea904019e4263f65084edbfdb0a081bdaaf Mon Sep 17 00:00:00 2001 From: Jordan Rey Date: Thu, 3 Jul 2025 02:30:08 +0200 Subject: [PATCH 5/7] fix tab switch issue --- .../renderer/src/components/MainLauncher.tsx | 34 +++++++++++++++++-- 1 file changed, 32 insertions(+), 2 deletions(-) diff --git a/packages/renderer/src/components/MainLauncher.tsx b/packages/renderer/src/components/MainLauncher.tsx index bb2aeb5..d36377d 100644 --- a/packages/renderer/src/components/MainLauncher.tsx +++ b/packages/renderer/src/components/MainLauncher.tsx @@ -13,7 +13,7 @@ import { } from "@/components/ui/dialog"; import { Button } from "@/components/ui/button"; import type { SettingsState } from "@/types"; -import { gameClient } from "@app/preload"; +import { gameClient, ipcEvents } from "@app/preload"; import log from "@/utils/logger"; import backgroundImage from "@/assets/background.jpg"; @@ -23,12 +23,16 @@ interface MainLauncherProps { } export const MainLauncher: React.FC = ({ - gameStatus, + gameStatus: initialGameStatus, updateStatus, }) => { const [activeTab, setActiveTab] = useState("game"); const [isSettingsOpen, setIsSettingsOpen] = useState(false); const [showDevModeDialog, setShowDevModeDialog] = useState(false); + // Local state for gameStatus to handle updates after tab switches + const [gameStatus, setGameStatus] = useState( + initialGameStatus + ); const [settings, setSettings] = useState({ gameRamAllocation: 2, devModeEnabled: false, @@ -41,6 +45,32 @@ export const MainLauncher: React.FC = ({ const [konamiSequence, setKonamiSequence] = useState(""); const targetSequence = "devmode"; + // Update local gameStatus when initialGameStatus changes + useEffect(() => { + setGameStatus(initialGameStatus); + }, [initialGameStatus]); + + // Listen for download completion events to update game status + useEffect(() => { + const handleDownloadComplete = async () => { + try { + // Refresh game status after download completes + const updatedStatus = await gameClient.checkForUpdates(); + setGameStatus(updatedStatus); + } catch (error) { + log.error("Failed to refresh game status after download:", error); + } + }; + + // Register event listener + ipcEvents.on("game:download-complete", handleDownloadComplete); + + // Cleanup event listener on unmount + return () => { + ipcEvents.off("game:download-complete", handleDownloadComplete); + }; + }, []); + // Load settings from localStorage on mount useEffect(() => { const savedSettings = localStorage.getItem("arenaReturnsSettings"); From 347012245d62064ef57a9072d3ddbdd64a8c7dcd Mon Sep 17 00:00:00 2001 From: Jordan Rey Date: Thu, 3 Jul 2025 04:21:56 +0200 Subject: [PATCH 6/7] huge logic refactor, way better now --- package.json | 2 +- packages/main/src/index.ts | 4 +- packages/main/src/modules/GameClient.ts | 332 ++++++++ .../{GameClientModule.ts => GameUpdater.ts} | 753 +++--------------- packages/main/src/modules/LauncherUpdater.ts | 2 +- packages/main/src/modules/WindowManager.ts | 1 + packages/preload/src/index.ts | 36 +- packages/renderer/eslint.config.js | 26 +- packages/renderer/src/App.tsx | 17 +- .../renderer/src/components/MainLauncher.tsx | 48 +- .../renderer/src/components/Preloader.tsx | 476 +++++------ .../renderer/src/components/SettingsMenu.tsx | 87 +- .../components/launcher/DownloadButton.tsx | 248 ++---- .../src/components/launcher/GameTab.tsx | 101 +-- .../src/components/launcher/ReplaysTab.tsx | 465 +++++------ .../src/contexts/GameStateContext.tsx | 77 ++ packages/renderer/src/hooks/useGameState.ts | 414 ++++++++++ 17 files changed, 1533 insertions(+), 1556 deletions(-) create mode 100644 packages/main/src/modules/GameClient.ts rename packages/main/src/modules/{GameClientModule.ts => GameUpdater.ts} (50%) create mode 100644 packages/renderer/src/contexts/GameStateContext.tsx create mode 100644 packages/renderer/src/hooks/useGameState.ts diff --git a/package.json b/package.json index 4b94df1..bdfeaa0 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "arena-returns-launcher", "description": "Le launcher officiel d'Arena Returns - Dofus Arena", - "version": "3.1.0", + "version": "3.2.0", "private": true, "type": "module", "author": { diff --git a/packages/main/src/index.ts b/packages/main/src/index.ts index 2440811..b4f589d 100644 --- a/packages/main/src/index.ts +++ b/packages/main/src/index.ts @@ -8,7 +8,8 @@ import { hardwareAccelerationMode } from "./modules/HardwareAccelerationModule.j import { launcherUpdater } from "./modules/LauncherUpdater.js"; import { allowInternalOrigins } from "./modules/BlockNotAllowedOrigins.js"; import { allowExternalUrls } from "./modules/ExternalUrls.js"; -import { createGameClientModule } from "./modules/GameClientModule.js"; +import { createGameUpdaterModule } from "./modules/GameUpdater.js"; +import { createGameClientModule } from "./modules/GameClient.js"; import { createNewsModule } from "./modules/NewsModule.js"; import { systemIpcModule } from "./modules/SystemIPCModule.js"; import { ALLOWED_EXTERNAL_ORIGINS } from "./config/allowedUrls.js"; @@ -28,6 +29,7 @@ export async function initApp(initConfig: AppInitConfig) { .init(terminateAppOnLastWindowClose()) .init(hardwareAccelerationMode({ enable: true })) .init(launcherUpdater()) + .init(createGameUpdaterModule()) .init(createGameClientModule()) .init(createNewsModule()) .init(systemIpcModule()) diff --git a/packages/main/src/modules/GameClient.ts b/packages/main/src/modules/GameClient.ts new file mode 100644 index 0000000..fa0a11a --- /dev/null +++ b/packages/main/src/modules/GameClient.ts @@ -0,0 +1,332 @@ +import { ipcMain } from "electron"; +import { join } from "path"; +import { existsSync, mkdirSync } from "fs"; +import { chmodSync } from "fs"; +import { stat, chmod, readdir } 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"; + +export class GameClient extends GameUpdater implements AppModule { + async enable(_context: ModuleContext): Promise { + ipcMain.handle("gameClient:launchGame", (_e, settings) => + this.launchGame(settings) + ); + ipcMain.handle("gameClient:updateSettings", (_e, settings) => + this.updateSettings(settings) + ); + ipcMain.handle("gameClient:openReplaysFolder", () => + this.openReplaysFolder() + ); + ipcMain.handle("gameClient:listReplays", () => this.listReplays()); + ipcMain.handle("gameClient:launchReplayOffline", (_e, path, settings) => + this.launchReplayOffline(path, settings) + ); + } + + async launchGame(settings?: GameSettings): Promise { + const gameStatus = await this.getGameStatus(); + + if (!gameStatus.isInstalled) { + throw new Error("Game is not installed"); + } + + if (gameStatus.needsUpdate) { + throw new Error("Game needs to be updated before launching"); + } + + if (gameStatus.error) { + throw new Error(`Cannot launch game: ${gameStatus.error}`); + } + + await this.startJavaProcess({ + mainClass: "com.ankamagames.dofusarena.client.DofusArenaClient", + settings, + }); + } + + async openReplaysFolder(): Promise { + const { shell } = await import("electron"); + const replaysPath = join(this.gameClientPath, "game", "replays"); + + mkdirSync(replaysPath, { recursive: true }); + await shell.openPath(replaysPath); + } + + async listReplays(): Promise { + const replaysPath = join(this.gameClientPath, "game", "replays"); + const { readdir } = await import("fs/promises"); + mkdirSync(replaysPath, { recursive: true }); + + const files = await readdir(replaysPath); + const replayFiles: ReplayFile[] = []; + + for (const filename of files) { + if (!filename.toLowerCase().endsWith(".rda")) continue; + const fullPath = join(replaysPath, filename); + replayFiles.push(this.parseReplayFilename(filename, fullPath)); + } + + replayFiles.sort((a, b) => { + if (a.date && b.date) return b.date.getTime() - a.date.getTime(); + if (a.date) return -1; + if (b.date) return 1; + return a.filename.localeCompare(b.filename); + }); + + return replayFiles; + } + + async launchReplayOffline( + replayPath: string, + settings?: GameSettings + ): Promise { + // Only check if game is installed locally, no CDN check + const gameDir = join(this.gameClientPath, "game"); + const coreJarPath = join(gameDir, "core.jar"); + + if (!existsSync(gameDir) || !existsSync(coreJarPath)) { + throw new Error("Game is not installed"); + } + + if (!existsSync(replayPath)) { + throw new Error("Replay file not found"); + } + + await this.startJavaProcess({ + mainClass: "com.ankamagames.dofusarena.client.DofusArenaReplayPlayer", + settings, + extraArgs: [`-REPLAY_FILE_PATH=${replayPath}`], + }); + } + + // ---------------- Internal helpers ---------------- + private parseReplayFilename(filename: string, fullPath: string): ReplayFile { + const replayFile: ReplayFile = { + filename, + fullPath, + isValidFormat: false, + } as ReplayFile; + + try { + const nameWithoutExt = filename.replace(/\.rda$/i, ""); + const parts = nameWithoutExt.split("_"); + + if (parts.length >= 3) { + let datePartIndex = -1; + let datePart = ""; + for (let i = 0; i < parts.length; i++) { + if (parts[i].length === 10 && /^\d{10}$/.test(parts[i])) { + datePartIndex = i; + datePart = parts[i]; + break; + } + } + if (datePartIndex >= 0 && datePart.length === 10) { + const year = 2000 + parseInt(datePart.substring(0, 2)); + const month = parseInt(datePart.substring(2, 4)) - 1; + const day = parseInt(datePart.substring(4, 6)); + const hour = parseInt(datePart.substring(6, 8)); + const minute = parseInt(datePart.substring(8, 10)); + const date = new Date(year, month, day, hour, minute); + const now = new Date(); + if (!isNaN(date.getTime()) && date <= now) { + replayFile.date = date; + if (datePartIndex + 1 < parts.length) { + const playersPart = parts.slice(datePartIndex + 1).join("_"); + const playerMatch = playersPart.match(/^(.+)_VS_(.+)$/); + if (playerMatch) { + replayFile.player1 = playerMatch[1].replace(/-/g, " "); + replayFile.player2 = playerMatch[2].replace(/-/g, " "); + replayFile.isValidFormat = true; + } + } + } + } + } + } catch (error) { + log.warn(`Failed to parse replay filename ${filename}:`, error); + } + return replayFile; + } + + private async startJavaProcess(options: { + mainClass: string; + settings?: GameSettings; + extraArgs?: string[]; + }): Promise { + const { mainClass, settings, extraArgs = [] } = options; + 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")); + const classpath = libFiles + .map((jar) => join(libDir, jar)) + .join(process.platform === "win32" ? ";" : ":"); + const coreJarPath = join(gameDir, "core.jar"); + const fullClasspath = + classpath + (process.platform === "win32" ? ";" : ":") + coreJarPath; + + let nativesPath: string; + switch (process.platform) { + case "win32": + nativesPath = join(nativesDir, "win32", "x64"); + break; + case "darwin": + nativesPath = join(nativesDir, "darwin", "x64"); + break; + case "linux": + nativesPath = join(nativesDir, "linux", "x64"); + break; + default: + throw new Error(`Unsupported platform: ${process.platform}`); + } + + const javaExecutable = + process.platform === "win32" + ? join(jreDir, "bin", "java.exe") + : join(jreDir, "bin", "java"); + + if (!existsSync(javaExecutable)) { + throw new Error(`Java executable not found at ${javaExecutable}`); + } + + await this.ensureExecutablePermissions(javaExecutable); + await this.ensureJrePermissions(jreDir); + + const ramAllocation = settings?.gameRamAllocation || 2; + const maxHeap = `${ramAllocation * 1024}m`; + const minHeap = Math.min(512, ramAllocation * 512) + "m"; + + const javaArgs = [ + "-noverify", + `-Xms${minHeap}`, + `-Xmx${maxHeap}`, + "-XX:+UnlockExperimentalVMOptions", + "-XX:+UseG1GC", + "-XX:G1NewSizePercent=20", + "-XX:G1ReservePercent=20", + "--add-exports", + "java.desktop/sun.awt=ALL-UNNAMED", + "-Djava.net.preferIPv4Stack=true", + "-Dsun.awt.noerasebackground=true", + "-Dsun.java2d.noddraw=true", + "-Djogl.disable.openglarbcontext", + `-Djava.library.path=${nativesPath}`, + ]; + + if (settings?.devModeEnabled && settings?.devExtraJavaArgs) { + javaArgs.push( + ...settings.devExtraJavaArgs + .split(" ") + .map((arg) => arg.trim()) + .filter((arg) => arg.length > 0) + ); + } + + javaArgs.push("-cp", fullClasspath, mainClass, ...extraArgs); + + switch (process.platform) { + case "win32": + await this.launchJavaProcessWindows(javaExecutable, javaArgs, gameDir); + break; + case "linux": + await this.launchJavaProcessLinux(javaExecutable, javaArgs, gameDir); + break; + default: + throw new Error(`Unsupported platform: ${process.platform}`); + } + } + + private async ensureExecutablePermissions(filePath: string): Promise { + try { + await chmod(filePath, 0o755); + } catch { + /* ignore */ + } + } + + private async ensureJrePermissions(jreDir: string): Promise { + const binDir = join(jreDir, "bin"); + if (!existsSync(binDir)) return; + const binFiles = await readdir(binDir); + for (const file of binFiles) { + const filePath = join(binDir, file); + const fileStat = await stat(filePath); + if (fileStat.isFile()) { + try { + await this.ensureExecutablePermissions(filePath); + } catch (error) { + log.warn(`Failed to set permissions on ${filePath}:`, error); + } + } + } + } + + private async launchJavaProcessWindows( + javaExecutable: string, + args: 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")); + } + }); + } + + private async launchJavaProcessLinux( + javaExecutable: string, + args: 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); + } + } + ); + if (child.pid) { + resolve(); + } else { + reject(new Error("Failed to start Java process")); + } + }); + } +} + +export function createGameClientModule(environment?: string) { + return new GameClient(environment); +} diff --git a/packages/main/src/modules/GameClientModule.ts b/packages/main/src/modules/GameUpdater.ts similarity index 50% rename from packages/main/src/modules/GameClientModule.ts rename to packages/main/src/modules/GameUpdater.ts index a5769e0..33e0ebd 100644 --- a/packages/main/src/modules/GameClientModule.ts +++ b/packages/main/src/modules/GameUpdater.ts @@ -9,15 +9,12 @@ import { access, readdir, stat, - chmod, unlink, rmdir, } from "fs/promises"; -import { chmodSync } from "fs"; import { existsSync, mkdirSync, writeFileSync, readFileSync } from "fs"; import { constants } from "fs"; import { createHash } from "crypto"; -import { exec } from "child_process"; import PQueue from "p-queue"; import log from "electron-log"; @@ -77,14 +74,8 @@ export interface ReplayFile { player2?: string; } -interface JavaProcessOptions { - mainClass: string; - settings?: GameSettings; - extraArgs?: string[]; -} - -export class GameClientModule implements AppModule { - private gameClientPath: string; +export class GameUpdater implements AppModule { + protected gameClientPath: string; private versionFilePath: string; private environment: string; private cdnUrl: string; @@ -116,29 +107,21 @@ export class GameClientModule implements AppModule { } async enable(context: ModuleContext): Promise { - // Register IPC handlers - ipcMain.handle("game:getStatus", () => this.getGameStatus()); - ipcMain.handle("game:checkForUpdates", () => this.checkForUpdates()); - ipcMain.handle("game:startDownload", () => this.startDownload()); - ipcMain.handle("game:getDownloadProgress", () => + // Register IPC handlers for updating/downloading related actions + ipcMain.handle("gameUpdater:getStatus", () => this.getGameStatus()); + ipcMain.handle("gameUpdater:checkForUpdates", () => this.checkForUpdates()); + ipcMain.handle("gameUpdater:startDownload", () => this.startDownload()); + ipcMain.handle("gameUpdater:getDownloadProgress", () => this.getDownloadProgress() ); - ipcMain.handle("game:cancelDownload", () => this.cancelDownload()); - ipcMain.handle("game:launchGame", (event, settings) => - this.launchGame(settings) - ); - ipcMain.handle("game:repairClient", () => this.repairClient()); - ipcMain.handle("game:openGameDirectory", () => this.openGameDirectory()); - ipcMain.handle("game:openReplaysFolder", () => this.openReplaysFolder()); - ipcMain.handle("game:updateSettings", (event, settings) => - this.updateSettings(settings) - ); - ipcMain.handle("game:listReplays", () => this.listReplays()); - ipcMain.handle("game:launchReplay", (event, replayPath, settings) => - this.launchReplay(replayPath, settings) + ipcMain.handle("gameUpdater:cancelDownload", () => this.cancelDownload()); + ipcMain.handle("gameUpdater:repairClient", () => this.repairClient()); + ipcMain.handle("gameUpdater:openGameDirectory", () => + this.openGameDirectory() ); } + // ---------------- Version helpers ---------------- async getGameStatus(): Promise { try { const [localVersion, remoteVersion] = await Promise.all([ @@ -176,12 +159,8 @@ export class GameClientModule implements AppModule { private async getLocalVersion(): Promise { try { - // Check if game client folder exists await access(this.gameClientPath, constants.F_OK); - - // Check if version.dat exists await access(this.versionFilePath, constants.F_OK); - const versionData = await readFile(this.versionFilePath, "utf-8"); return versionData.trim(); } catch { @@ -190,18 +169,16 @@ export class GameClientModule implements AppModule { } private async getRemoteVersion(settings?: GameSettings): Promise { - // Use dev environment if specified in settings const environment = settings?.devModeEnabled && settings?.devCdnEnvironment ? settings.devCdnEnvironment : this.environment; - // Use forced version if specified in dev mode if (settings?.devModeEnabled && settings?.devForceVersion) { return settings.devForceVersion; } - const cdnUrl = `https://launcher.cdn.arenareturns.com/${environment}.json`; + const cdnUrl = `${this.cdnUrl}/${environment}.json`; const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), 5000); @@ -232,12 +209,10 @@ export class GameClientModule implements AppModule { } } + // ---------------- Version file write ---------------- async updateLocalVersion(version: string): Promise { try { - // Create directory if it doesn't exist await mkdir(this.gameClientPath, { recursive: true }); - - // Write version to file await writeFile(this.versionFilePath, version, "utf-8"); } catch (error) { throw new Error( @@ -248,6 +223,7 @@ export class GameClientModule implements AppModule { } } + // ---------------- Download helpers ---------------- getDownloadProgress(): DownloadProgress { return this.downloadProgress; } @@ -266,7 +242,6 @@ export class GameClientModule implements AppModule { this.notifyRenderer("download-started", this.downloadProgress); - // Get the latest version manifest const remoteVersion = await this.getRemoteVersion(this.currentSettings); const manifestResponse = await fetch( `${this.cdnUrl}/versions/${remoteVersion}.json` @@ -280,10 +255,7 @@ export class GameClientModule implements AppModule { this.versionManifest = (await manifestResponse.json()) as VersionManifest; - // Get platform-specific files const filesToCheck = this.getPlatformFiles(this.versionManifest); - - // Check which files need to be downloaded await this.checkFiles(filesToCheck); } catch (error) { this.downloadProgress = { @@ -297,7 +269,6 @@ export class GameClientModule implements AppModule { } async cancelDownload(): Promise { - // Stop the current download queue if it exists if (this.currentDownloadQueue) { this.currentDownloadQueue.clear(); this.currentDownloadQueue = null; @@ -313,37 +284,6 @@ export class GameClientModule implements AppModule { this.notifyRenderer("download-cancelled", this.downloadProgress); } - async launchGame(settings?: GameSettings): Promise { - try { - // First, check for updates before launching - const gameStatus = await this.getGameStatus(); - - if (!gameStatus.isInstalled) { - throw new Error("Game is not installed"); - } - - if (gameStatus.needsUpdate) { - throw new Error("Game needs to be updated before launching"); - } - - if (gameStatus.error) { - throw new Error(`Cannot launch game: ${gameStatus.error}`); - } - - // Launch the game with settings - await this.startJavaProcess({ - mainClass: "com.ankamagames.dofusarena.client.DofusArenaClient", - settings, - }); - } catch (error) { - throw new Error( - `Failed to launch game: ${ - error instanceof Error ? error.message : "Unknown error" - }` - ); - } - } - async repairClient(): Promise { if (this.downloadProgress.isDownloading) { throw new Error("Download or repair already in progress"); @@ -358,10 +298,8 @@ export class GameClientModule implements AppModule { currentFile: "Initialisation de la réparation...", }; - // Notify immediately to provide instant feedback this.notifyRenderer("download-started", this.downloadProgress); - // Get the latest version manifest const remoteVersion = await this.getRemoteVersion(this.currentSettings); const manifestResponse = await fetch( `${this.cdnUrl}/versions/${remoteVersion}.json` @@ -375,14 +313,12 @@ export class GameClientModule implements AppModule { this.versionManifest = (await manifestResponse.json()) as VersionManifest; - // Get platform-specific files const filesToCheck = this.getPlatformFiles(this.versionManifest); this.downloadProgress.currentFile = "Vérification de l'intégrité des fichiers..."; this.notifyRenderer("download-progress", this.downloadProgress); - // Check all files and repair any missing or corrupted ones await this.checkFiles(filesToCheck, true); } catch (error) { this.downloadProgress = { @@ -409,525 +345,7 @@ export class GameClientModule implements AppModule { } } - async openReplaysFolder(): Promise { - const { shell } = await import("electron"); - const replaysPath = join(this.gameClientPath, "game", "replays"); - - try { - // Create replays directory if it doesn't exist - await mkdir(replaysPath, { recursive: true }); - await shell.openPath(replaysPath); - } catch (error) { - throw new Error( - `Failed to open replays folder: ${ - error instanceof Error ? error.message : "Unknown error" - }` - ); - } - } - - async listReplays(): Promise { - const replaysPath = join(this.gameClientPath, "game", "replays"); - - try { - // Create replays directory if it doesn't exist - await mkdir(replaysPath, { recursive: true }); - - // Read directory contents - const files = await readdir(replaysPath); - - // Filter for .rda files and parse them - const replayFiles: ReplayFile[] = []; - - for (const filename of files) { - if (!filename.toLowerCase().endsWith(".rda")) { - continue; - } - - const fullPath = join(replaysPath, filename); - const replayInfo = this.parseReplayFilename(filename, fullPath); - replayFiles.push(replayInfo); - } - - // Sort by date (newest first) if valid, otherwise by filename - replayFiles.sort((a, b) => { - if (a.date && b.date) { - return b.date.getTime() - a.date.getTime(); - } else if (a.date && !b.date) { - return -1; - } else if (!a.date && b.date) { - return 1; - } else { - return a.filename.localeCompare(b.filename); - } - }); - - return replayFiles; - } catch (error) { - throw new Error( - `Failed to list replays: ${ - error instanceof Error ? error.message : "Unknown error" - }` - ); - } - } - - async launchReplay( - replayPath: string, - settings?: GameSettings - ): Promise { - try { - // First check if game is installed and up to date - const gameStatus = await this.getGameStatus(); - - if (!gameStatus.isInstalled) { - throw new Error("Game is not installed"); - } - - if (gameStatus.needsUpdate) { - throw new Error("Game needs to be updated before launching replays"); - } - - if (gameStatus.error) { - throw new Error(`Cannot launch replay: ${gameStatus.error}`); - } - - // Check if replay file exists - if (!existsSync(replayPath)) { - throw new Error("Replay file not found"); - } - - // Launch the replay with the replay player - await this.startJavaProcess({ - mainClass: "com.ankamagames.dofusarena.client.DofusArenaReplayPlayer", - settings, - extraArgs: [`-REPLAY_FILE_PATH=${replayPath}`], - }); - } catch (error) { - throw new Error( - `Failed to launch replay: ${ - error instanceof Error ? error.message : "Unknown error" - }` - ); - } - } - - private parseReplayFilename(filename: string, fullPath: string): ReplayFile { - // Expected formats: - // 1. 56_1211222137_Wanted-Trash_VS_RaidenThePro.rda (with version prefix) - // 2. 1211222137_Wanted-Trash_VS_RaidenThePro.rda (without version prefix) - // Where: [gameVersion_]yyMMddHHmm_Player1_VS_Player2.rda - - const replayFile: ReplayFile = { - filename, - fullPath, - isValidFormat: false, - }; - - try { - // Remove .rda extension - const nameWithoutExt = filename.replace(/\.rda$/i, ""); - - // Split by underscores - const parts = nameWithoutExt.split("_"); - - if (parts.length >= 3) { - // Try to find the date part (yyMMddHHmm format) - let datePartIndex = -1; - let datePart = ""; - - // Check each part to see if it matches the date format - for (let i = 0; i < parts.length; i++) { - if (parts[i].length === 10 && /^\d{10}$/.test(parts[i])) { - // This looks like a date part (10 digits) - datePartIndex = i; - datePart = parts[i]; - break; - } - } - - if (datePartIndex >= 0 && datePart.length === 10) { - // Parse date: yyMMddHHmm - const year = 2000 + parseInt(datePart.substring(0, 2)); - const month = parseInt(datePart.substring(2, 4)) - 1; // Month is 0-indexed - const day = parseInt(datePart.substring(4, 6)); - const hour = parseInt(datePart.substring(6, 8)); - const minute = parseInt(datePart.substring(8, 10)); - - const date = new Date(year, month, day, hour, minute); - - // Check if date is valid and reasonable (not in future) - const now = new Date(); - - if (!isNaN(date.getTime()) && date <= now) { - replayFile.date = date; - - // Extract player names from parts after the date - if (datePartIndex + 1 < parts.length) { - const playersPart = parts.slice(datePartIndex + 1).join("_"); - const playerMatch = playersPart.match(/^(.+)_VS_(.+)$/); - - if (playerMatch) { - replayFile.player1 = playerMatch[1].replace(/-/g, " "); - replayFile.player2 = playerMatch[2].replace(/-/g, " "); - replayFile.isValidFormat = true; - } - } - } - } - } - } catch (error) { - // If parsing fails, isValidFormat remains false - log.warn(`Failed to parse replay filename ${filename}:`, error); - } - - return replayFile; - } - - private async startJavaProcess(options: JavaProcessOptions): Promise { - const { mainClass, settings, extraArgs = [] } = options; - - const gameDir = join(this.gameClientPath, "game"); - const libDir = join(this.gameClientPath, "lib"); - const jreDir = join(this.gameClientPath, "jre"); - const nativesDir = join(this.gameClientPath, "natives"); - - // Check if required directories exist - 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"); - } - - // Build classpath from lib directory - const libFiles = await readdir(libDir); - const jarFiles = libFiles.filter((file) => file.endsWith(".jar")); - const classpath = jarFiles - .map((jar) => join(libDir, jar)) - .join(process.platform === "win32" ? ";" : ":"); - - // Add core.jar to classpath - const coreJarPath = join(gameDir, "core.jar"); - const fullClasspath = - classpath + (process.platform === "win32" ? ";" : ":") + coreJarPath; - - // Determine platform-specific natives path - let nativesPath: string; - switch (process.platform) { - case "win32": - nativesPath = join(nativesDir, "win32", "x64"); - break; - case "darwin": - nativesPath = join(nativesDir, "darwin", "x64"); - break; - case "linux": - nativesPath = join(nativesDir, "linux", "x64"); - break; - default: - throw new Error(`Unsupported platform: ${process.platform}`); - } - - // Find Java executable - const javaExecutable = - process.platform === "win32" - ? join(jreDir, "bin", "java.exe") - : join(jreDir, "bin", "java"); - - if (!existsSync(javaExecutable)) { - throw new Error(`Java executable not found at ${javaExecutable}`); - } - - // Ensure Java executable has proper permissions - await this.ensureExecutablePermissions(javaExecutable); - await this.ensureJrePermissions(jreDir); - - // Calculate RAM allocation (default to 2GB if not specified) - const ramAllocation = settings?.gameRamAllocation || 2; - const maxHeap = `${ramAllocation * 1024}m`; // Convert GB to MB - const minHeap = Math.min(512, ramAllocation * 512) + "m"; // Proportional min heap - - // Base Java arguments - const javaArgs = [ - "-noverify", - `-Xms${minHeap}`, - `-Xmx${maxHeap}`, - "-XX:+UnlockExperimentalVMOptions", - "-XX:+UseG1GC", - "-XX:G1NewSizePercent=20", - "-XX:G1ReservePercent=20", - "--add-exports", - "java.desktop/sun.awt=ALL-UNNAMED", - "-Djava.net.preferIPv4Stack=true", - "-Dsun.awt.noerasebackground=true", - "-Dsun.java2d.noddraw=true", - "-Djogl.disable.openglarbcontext", - `-Djava.library.path=${nativesPath}`, - ]; - - // Add developer mode extra arguments if provided - if (settings?.devModeEnabled && settings?.devExtraJavaArgs) { - const extraDevArgs = settings.devExtraJavaArgs - .split(" ") - .map((arg: string) => arg.trim()) - .filter((arg: string) => arg.length > 0); - javaArgs.push(...extraDevArgs); - } - - // Add classpath, main class, and any extra arguments - javaArgs.push("-cp", fullClasspath, mainClass, ...extraArgs); - - // Use platform-specific launch approach - switch (process.platform) { - case "win32": - await this.launchJavaProcessWindows(javaExecutable, javaArgs, gameDir); - break; - case "linux": - await this.launchJavaProcessLinux(javaExecutable, javaArgs, gameDir); - break; - default: - throw new Error(`Unsupported platform: ${process.platform}`); - } - } - - private async launchJavaProcessWindows( - javaExecutable: string, - args: string[], - cwd: string - ): Promise { - return new Promise((resolve, reject) => { - const javaCommand = `"${javaExecutable}" ${args.join(" ")}`; - - // Launch the Java process and immediately resolve (don't wait for process to exit) - const childProcess = exec(javaCommand, { cwd }, (error) => { - // Only log errors, don't reject on process exit - if (error && !error.killed) { - log.error("Java process error:", error); - } - }); - - // Check if process started successfully - if (childProcess.pid) { - log.info( - "Java process started successfully with PID:", - childProcess.pid - ); - resolve(); // Resolve immediately after successful start - } else { - reject(new Error("Failed to start Java process")); - } - }); - } - - private async launchJavaProcessLinux( - javaExecutable: string, - args: string[], - cwd: string - ): Promise { - return new Promise((resolve, reject) => { - // Ensure Java executable has proper permissions - try { - chmodSync(javaExecutable, 0o755); - } catch (error) { - log.warn( - `Failed to set permissions on Java executable ${javaExecutable}:`, - error - ); - } - - const javaCommand = `"${javaExecutable}" ${args.join(" ")}`; - - // Launch the Java process and immediately resolve (don't wait for process to exit) - const childProcess = exec(javaCommand, { cwd }, (error) => { - // Only log errors, don't reject on process exit - if (error && !error.killed) { - log.error("Java process error:", error); - } - }); - - // Check if process started successfully - if (childProcess.pid) { - log.info( - "Java process started successfully with PID:", - childProcess.pid - ); - resolve(); // Resolve immediately after successful start - } else { - reject(new Error("Failed to start Java process")); - } - }); - } - - private async ensureExecutablePermissions(filePath: string): Promise { - try { - // Check if file has execute permissions - await access(filePath, constants.F_OK | constants.X_OK); - } catch (error) { - // File doesn't have execute permissions, set them - try { - // Add execute permissions for owner, group, and others - await chmod(filePath, 0o755); - } catch (chmodError) { - throw new Error( - `Failed to set execute permissions on ${filePath}: ${ - chmodError instanceof Error ? chmodError.message : "Unknown error" - }` - ); - } - } - } - - private async ensureJrePermissions(jreDir: string): Promise { - try { - const binDir = join(jreDir, "bin"); - if (!existsSync(binDir)) { - return; // No bin directory, skip - } - - const binFiles = await readdir(binDir); - - // Set execute permissions on all files in bin directory - for (const file of binFiles) { - const filePath = join(binDir, file); - const fileStat = await stat(filePath); - - if (fileStat.isFile()) { - try { - await this.ensureExecutablePermissions(filePath); - } catch (error) { - // Log error but don't fail the whole process for non-critical executables - log.warn(`Failed to set permissions on ${filePath}:`, error); - } - } - } - } catch (error) { - // Log error but don't fail the launch process - log.warn("Failed to set JRE permissions:", error); - } - } - - private async cleanupObsoleteFiles(manifest: VersionManifest): Promise { - if (!existsSync(this.gameClientPath)) { - return; // Nothing to clean up - } - - try { - // Get all files that should exist according to manifest - const expectedFiles = this.getPlatformFiles(manifest); - const expectedFilePaths = new Set(expectedFiles.map((f) => f.path)); - - // Get all local files - const localFiles = await this.getAllLocalFiles(); - - // Find files to remove (local files not in manifest and not excluded) - const filesToRemove: string[] = []; - const foldersToCheck: string[] = []; - - for (const localFile of localFiles) { - if ( - !expectedFilePaths.has(localFile) && - !this.isExcludedFromCleanup(localFile) - ) { - const fullPath = join(this.gameClientPath, localFile); - const fileStat = await stat(fullPath).catch(() => null); - - if (fileStat?.isFile()) { - filesToRemove.push(localFile); - } else if (fileStat?.isDirectory()) { - foldersToCheck.push(localFile); - } - } - } - - // Remove obsolete files - for (const fileToRemove of filesToRemove) { - try { - const fullPath = join(this.gameClientPath, fileToRemove); - await unlink(fullPath); - log.info(`Removed obsolete file: ${fileToRemove}`); - } catch (error) { - log.warn(`Failed to remove file ${fileToRemove}:`, error); - } - } - - // Remove empty directories (but not excluded ones) - for (const folder of foldersToCheck) { - try { - const fullPath = join(this.gameClientPath, folder); - const folderContents = await readdir(fullPath); - - if (folderContents.length === 0) { - await rmdir(fullPath); - log.info(`Removed empty directory: ${folder}`); - } - } catch (error) { - // Directory might not be empty or other issues - ignore - } - } - } catch (error) { - log.warn("Failed to cleanup obsolete files:", error); - // Don't throw - cleanup failure shouldn't stop the download - } - } - - private async getAllLocalFiles( - dirPath: string = this.gameClientPath, - relativeTo: string = this.gameClientPath, - files: string[] = [] - ): Promise { - try { - const entries = await readdir(dirPath); - - for (const entry of entries) { - const fullPath = join(dirPath, entry); - const relativePath = fullPath.substring(relativeTo.length + 1); - // Normalize path separators to match manifest format (forward slashes) - const normalizedPath = relativePath.replace(/\\/g, "/"); - - const entryStat = await stat(fullPath); - - if (entryStat.isDirectory()) { - files.push(normalizedPath); - // Recursively scan subdirectories - await this.getAllLocalFiles(fullPath, relativeTo, files); - } else if (entryStat.isFile()) { - files.push(normalizedPath); - } - } - } catch (error) { - // Directory might not exist or be accessible - ignore - } - - return files; - } - - private isExcludedFromCleanup(filePath: string): boolean { - // Normalize path separators - const normalizedPath = filePath.replace(/\\/g, "/"); - - for (const excluded of this.excludedFromCleanup) { - const normalizedExcluded = excluded.replace(/\\/g, "/"); - - // Exact match - if (normalizedPath === normalizedExcluded) { - return true; - } - - // Check if file is inside an excluded directory - if (normalizedPath.startsWith(normalizedExcluded + "/")) { - return true; - } - } - - return false; - } - + // ---------------- Internal helpers ---------------- private getPlatformFiles(manifest: VersionManifest): FileManifest[] { const files: FileManifest[] = [...manifest.base]; @@ -963,15 +381,12 @@ export class GameClientModule implements AppModule { const filesToDownload: FileManifest[] = []; for (let i = 0; i < files.length; i++) { - // Check if download was cancelled during file checking if (!this.downloadProgress.isDownloading) { return; } - // Only update progress every 10 files to reduce UI spam if (i % 10 === 0) { this.downloadProgress.filesLoaded = i; - // Show verification status instead of file path during checking this.downloadProgress.currentFile = this.downloadProgress.isRepairing ? `Vérification de l'intégrité: ${files[i].path.split("/").pop()}` : `Vérification: ${files[i].path.split("/").pop()}`; @@ -996,7 +411,6 @@ export class GameClientModule implements AppModule { filesToDownload.push(file); } - // Yield control back to event loop every 5 files to prevent system hang if (i % 5 === 0) { await new Promise((resolve) => setImmediate(resolve)); } @@ -1005,7 +419,6 @@ export class GameClientModule implements AppModule { if (filesToDownload.length > 0) { await this.downloadFiles(filesToDownload); } else { - // Even if no files to download, clean up obsolete files if (this.versionManifest) { this.downloadProgress.currentFile = "Nettoyage des fichiers obsolètes..."; @@ -1023,45 +436,33 @@ export class GameClientModule implements AppModule { this.downloadProgress.filesLoaded = 0; this.notifyRenderer("download-progress", this.downloadProgress); - // Use p-queue for optimized concurrent downloads const queue = new PQueue({ concurrency: 3 }); this.currentDownloadQueue = queue; - // Set up queue event handlers queue.on("active", () => { - // Update progress when a new download starts this.notifyRenderer("download-progress", this.downloadProgress); }); - queue.on("completed", () => { - // This fires after each individual file completes - // The actual file count is updated in downloadAndSaveFile - }); - queue.on("error", (error) => { log.error("Download queue error:", error); this.downloadProgress.error = error.message; this.notifyRenderer("download-error", this.downloadProgress); }); - // Add all download tasks to the queue const downloadPromises = files.map((file) => queue.add(() => this.downloadAndSaveFile(file), { - priority: 1, // All files have same priority + priority: 1, }) ); try { - // Wait for all downloads to complete await Promise.all(downloadPromises); - // Check if download was cancelled during execution if (!this.downloadProgress.isDownloading) { this.currentDownloadQueue = null; return; } - // Clean up obsolete files after successful download if (this.versionManifest) { this.downloadProgress.currentFile = "Nettoyage des fichiers obsolètes..."; @@ -1082,7 +483,6 @@ export class GameClientModule implements AppModule { } private async downloadAndSaveFile(file: FileManifest): Promise { - // Check if download was cancelled before starting if (!this.downloadProgress.isDownloading) { throw new Error("Download cancelled"); } @@ -1103,12 +503,10 @@ export class GameClientModule implements AppModule { const fileBuffer = await response.arrayBuffer(); const buffer = Buffer.from(fileBuffer); - // Check if download was cancelled after fetch if (!this.downloadProgress.isDownloading) { throw new Error("Download cancelled"); } - // Ensure directory exists const dir = join( this.gameClientPath, file.path.split("/").slice(0, -1).join("/") @@ -1122,7 +520,6 @@ export class GameClientModule implements AppModule { this.downloadProgress.filesLoaded++; this.downloadProgress.currentFile = file.path; - // Only notify every few files to reduce UI spam (throttling is also applied in notifyRenderer) if ( this.downloadProgress.filesLoaded % 3 === 0 || this.downloadProgress.filesLoaded === this.downloadProgress.filesTotal @@ -1143,7 +540,6 @@ export class GameClientModule implements AppModule { throw new Error("Version manifest not available"); } - // Write version.dat file writeFileSync(this.versionFilePath, this.versionManifest.version); this.downloadProgress = { @@ -1156,12 +552,117 @@ export class GameClientModule implements AppModule { this.notifyRenderer("download-complete", this.downloadProgress); } + private async cleanupObsoleteFiles(manifest: VersionManifest): Promise { + if (!existsSync(this.gameClientPath)) { + return; + } + + try { + const expectedFiles = this.getPlatformFiles(manifest); + const expectedFilePaths = new Set(expectedFiles.map((f) => f.path)); + + const localFiles = await this.getAllLocalFiles(); + + const filesToRemove: string[] = []; + const foldersToCheck: string[] = []; + + for (const localFile of localFiles) { + if ( + !expectedFilePaths.has(localFile) && + !this.isExcludedFromCleanup(localFile) + ) { + const fullPath = join(this.gameClientPath, localFile); + const fileStat = await stat(fullPath).catch(() => null); + + if (fileStat?.isFile()) { + filesToRemove.push(localFile); + } else if (fileStat?.isDirectory()) { + foldersToCheck.push(localFile); + } + } + } + + for (const fileToRemove of filesToRemove) { + try { + const fullPath = join(this.gameClientPath, fileToRemove); + await unlink(fullPath); + log.info(`Removed obsolete file: ${fileToRemove}`); + } catch (error) { + log.warn(`Failed to remove file ${fileToRemove}:`, error); + } + } + + for (const folder of foldersToCheck) { + try { + const fullPath = join(this.gameClientPath, folder); + const folderContents = await readdir(fullPath); + + if (folderContents.length === 0) { + await rmdir(fullPath); + log.info(`Removed empty directory: ${folder}`); + } + } catch (error) { + // Ignore non-critical errors + } + } + } catch (error) { + log.warn("Failed to cleanup obsolete files:", error); + } + } + + private async getAllLocalFiles( + dirPath: string = this.gameClientPath, + relativeTo: string = this.gameClientPath, + files: string[] = [] + ): Promise { + try { + const entries = await readdir(dirPath); + + for (const entry of entries) { + const fullPath = join(dirPath, entry); + const relativePath = fullPath.substring(relativeTo.length + 1); + const normalizedPath = relativePath.replace(/\\/g, "/"); + + const entryStat = await stat(fullPath); + + if (entryStat.isDirectory()) { + files.push(normalizedPath); + await this.getAllLocalFiles(fullPath, relativeTo, files); + } else if (entryStat.isFile()) { + files.push(normalizedPath); + } + } + } catch { + /* ignore */ + } + + return files; + } + + private isExcludedFromCleanup(filePath: string): boolean { + const normalizedPath = filePath.replace(/\\/g, "/"); + + for (const excluded of this.excludedFromCleanup) { + const normalizedExcluded = excluded.replace(/\\/g, "/"); + + if (normalizedPath === normalizedExcluded) { + return true; + } + + if (normalizedPath.startsWith(normalizedExcluded + "/")) { + return true; + } + } + + return false; + } + + // ---------------- Renderer notification helper ---------------- private notifyRenderer(event: string, data: any): void { - // Throttle progress updates to prevent UI spam (max once per 100ms) if (event === "download-progress") { const now = Date.now(); if (now - this.lastProgressNotification < 100) { - return; // Skip this update + return; } this.lastProgressNotification = now; } @@ -1169,12 +670,12 @@ export class GameClientModule implements AppModule { const windows = BrowserWindow.getAllWindows(); windows.forEach((window) => { if (!window.isDestroyed()) { - window.webContents.send(`game:${event}`, data); + window.webContents.send(`gameUpdater:${event}`, data); } }); } } -export function createGameClientModule(environment?: string) { - return new GameClientModule(environment); +export function createGameUpdaterModule(environment?: string) { + return new GameUpdater(environment); } diff --git a/packages/main/src/modules/LauncherUpdater.ts b/packages/main/src/modules/LauncherUpdater.ts index f192ef1..cb64a95 100644 --- a/packages/main/src/modules/LauncherUpdater.ts +++ b/packages/main/src/modules/LauncherUpdater.ts @@ -84,7 +84,7 @@ export class LauncherUpdater implements AppModule { log.transports.file.level = "info"; log.info("LauncherUpdater: Configured electron-updater logging"); - // Force dev update config for debugging + // DEBUG: Force dev update config for debugging // updater.forceDevUpdateConfig = true; updater.on("update-available", (info) => { diff --git a/packages/main/src/modules/WindowManager.ts b/packages/main/src/modules/WindowManager.ts index 3d3a47c..fc2d817 100644 --- a/packages/main/src/modules/WindowManager.ts +++ b/packages/main/src/modules/WindowManager.ts @@ -55,6 +55,7 @@ class WindowManager implements AppModule { show: false, // Use the 'ready-to-show' event to show the instantiated BrowserWindow. frame: false, // Remove native window decorations backgroundColor: "#000000", // Black background to prevent white flash during resize + maximizable: false, // Prevent window maximizing minWidth: 1280, minHeight: 720, maxWidth: 1440, diff --git a/packages/preload/src/index.ts b/packages/preload/src/index.ts index 3150ad8..104c3bb 100644 --- a/packages/preload/src/index.ts +++ b/packages/preload/src/index.ts @@ -36,23 +36,28 @@ const system = { openLogDirectory: () => ipcRenderer.invoke("system:openLogDirectory"), }; -// Game client functions +// Game updater functions (install / update / download) +const gameUpdater = { + getStatus: () => ipcRenderer.invoke("gameUpdater:getStatus"), + checkForUpdates: () => ipcRenderer.invoke("gameUpdater:checkForUpdates"), + startDownload: () => ipcRenderer.invoke("gameUpdater:startDownload"), + getDownloadProgress: () => + ipcRenderer.invoke("gameUpdater:getDownloadProgress"), + cancelDownload: () => ipcRenderer.invoke("gameUpdater:cancelDownload"), + repairClient: () => ipcRenderer.invoke("gameUpdater:repairClient"), + openGameDirectory: () => ipcRenderer.invoke("gameUpdater:openGameDirectory"), +}; + +// Game client functions (launching, replays) const gameClient = { - getStatus: () => ipcRenderer.invoke("game:getStatus"), - checkForUpdates: () => ipcRenderer.invoke("game:checkForUpdates"), - startDownload: () => ipcRenderer.invoke("game:startDownload"), - getDownloadProgress: () => ipcRenderer.invoke("game:getDownloadProgress"), - cancelDownload: () => ipcRenderer.invoke("game:cancelDownload"), launchGame: (settings?: any) => - ipcRenderer.invoke("game:launchGame", settings), - repairClient: () => ipcRenderer.invoke("game:repairClient"), - openGameDirectory: () => ipcRenderer.invoke("game:openGameDirectory"), - openReplaysFolder: () => ipcRenderer.invoke("game:openReplaysFolder"), - listReplays: () => ipcRenderer.invoke("game:listReplays"), - launchReplay: (replayPath: string, settings?: any) => - ipcRenderer.invoke("game:launchReplay", replayPath, settings), + ipcRenderer.invoke("gameClient:launchGame", settings), + openReplaysFolder: () => ipcRenderer.invoke("gameClient:openReplaysFolder"), + listReplays: () => ipcRenderer.invoke("gameClient:listReplays"), + launchReplayOffline: (replayPath: string, settings?: any) => + ipcRenderer.invoke("gameClient:launchReplayOffline", replayPath, settings), updateSettings: (settings: any) => - ipcRenderer.invoke("game:updateSettings", settings), + ipcRenderer.invoke("gameClient:updateSettings", settings), }; // News functions @@ -73,7 +78,7 @@ const launcherUpdater = { const preloader = { initializeApp: async () => { // Check game status - const gameStatus = await gameClient.getStatus(); + const gameStatus = await gameUpdater.getStatus(); // Check for launcher updates const launcherUpdateStatus = await launcherUpdater.getStatus(); @@ -96,6 +101,7 @@ export { ipcEvents, windowControls, system, + gameUpdater, gameClient, news, launcherUpdater, diff --git a/packages/renderer/eslint.config.js b/packages/renderer/eslint.config.js index 092408a..5ce46f2 100644 --- a/packages/renderer/eslint.config.js +++ b/packages/renderer/eslint.config.js @@ -1,28 +1,24 @@ -import js from '@eslint/js' -import globals from 'globals' -import reactHooks from 'eslint-plugin-react-hooks' -import reactRefresh from 'eslint-plugin-react-refresh' -import tseslint from 'typescript-eslint' +import js from "@eslint/js"; +import globals from "globals"; +import reactHooks from "eslint-plugin-react-hooks"; +import reactRefresh from "eslint-plugin-react-refresh"; +import tseslint from "typescript-eslint"; export default tseslint.config( - { ignores: ['dist'] }, + { ignores: ["dist"] }, { extends: [js.configs.recommended, ...tseslint.configs.recommended], - files: ['**/*.{ts,tsx}'], + files: ["**/*.{ts,tsx}"], languageOptions: { ecmaVersion: 2020, globals: globals.browser, }, plugins: { - 'react-hooks': reactHooks, - 'react-refresh': reactRefresh, + "react-hooks": reactHooks, + "react-refresh": reactRefresh, }, rules: { ...reactHooks.configs.recommended.rules, - 'react-refresh/only-export-components': [ - 'warn', - { allowConstantExport: true }, - ], }, - }, -) + } +); diff --git a/packages/renderer/src/App.tsx b/packages/renderer/src/App.tsx index 60dadac..5b823ba 100644 --- a/packages/renderer/src/App.tsx +++ b/packages/renderer/src/App.tsx @@ -1,19 +1,19 @@ import React, { useState } from "react"; import { Preloader } from "./components/Preloader"; import { MainLauncher } from "./components/MainLauncher"; +import { GameStateProvider } from "./contexts/GameStateContext"; import "./App.css"; -interface AppData { - gameStatus: GameStatus; +interface LauncherData { updateStatus: UpdateStatus; } const App: React.FC = () => { const [isLoading, setIsLoading] = useState(true); - const [appData, setAppData] = useState(null); + const [launcherData, setLauncherData] = useState(null); - const handlePreloaderComplete = (data: AppData) => { - setAppData(data); + const handlePreloaderComplete = (data: LauncherData) => { + setLauncherData(data); setIsLoading(false); }; @@ -22,10 +22,9 @@ const App: React.FC = () => { } return ( - + + + ); }; diff --git a/packages/renderer/src/components/MainLauncher.tsx b/packages/renderer/src/components/MainLauncher.tsx index d36377d..d3abd9d 100644 --- a/packages/renderer/src/components/MainLauncher.tsx +++ b/packages/renderer/src/components/MainLauncher.tsx @@ -13,26 +13,18 @@ import { } from "@/components/ui/dialog"; import { Button } from "@/components/ui/button"; import type { SettingsState } from "@/types"; -import { gameClient, ipcEvents } from "@app/preload"; +import { gameClient } from "@app/preload"; import log from "@/utils/logger"; import backgroundImage from "@/assets/background.jpg"; interface MainLauncherProps { - gameStatus?: GameStatus; updateStatus?: UpdateStatus; } -export const MainLauncher: React.FC = ({ - gameStatus: initialGameStatus, - updateStatus, -}) => { +export const MainLauncher: React.FC = ({ updateStatus }) => { const [activeTab, setActiveTab] = useState("game"); const [isSettingsOpen, setIsSettingsOpen] = useState(false); const [showDevModeDialog, setShowDevModeDialog] = useState(false); - // Local state for gameStatus to handle updates after tab switches - const [gameStatus, setGameStatus] = useState( - initialGameStatus - ); const [settings, setSettings] = useState({ gameRamAllocation: 2, devModeEnabled: false, @@ -45,35 +37,9 @@ export const MainLauncher: React.FC = ({ const [konamiSequence, setKonamiSequence] = useState(""); const targetSequence = "devmode"; - // Update local gameStatus when initialGameStatus changes - useEffect(() => { - setGameStatus(initialGameStatus); - }, [initialGameStatus]); - - // Listen for download completion events to update game status - useEffect(() => { - const handleDownloadComplete = async () => { - try { - // Refresh game status after download completes - const updatedStatus = await gameClient.checkForUpdates(); - setGameStatus(updatedStatus); - } catch (error) { - log.error("Failed to refresh game status after download:", error); - } - }; - - // Register event listener - ipcEvents.on("game:download-complete", handleDownloadComplete); - - // Cleanup event listener on unmount - return () => { - ipcEvents.off("game:download-complete", handleDownloadComplete); - }; - }, []); - // Load settings from localStorage on mount useEffect(() => { - const savedSettings = localStorage.getItem("arenaReturnsSettings"); + const savedSettings = localStorage.getItem("launcherSettings"); if (savedSettings) { try { const parsed = JSON.parse(savedSettings); @@ -86,7 +52,7 @@ export const MainLauncher: React.FC = ({ // Save settings to localStorage and update backend when changed useEffect(() => { - localStorage.setItem("arenaReturnsSettings", JSON.stringify(settings)); + localStorage.setItem("launcherSettings", JSON.stringify(settings)); // Update the backend with current settings gameClient @@ -141,10 +107,8 @@ export const MainLauncher: React.FC = ({ {/* Main Content */}
- {activeTab === "game" && ( - - )} - {activeTab === "replays" && } + {activeTab === "game" && } + {activeTab === "replays" && } {activeTab === "twitch" && }
diff --git a/packages/renderer/src/components/Preloader.tsx b/packages/renderer/src/components/Preloader.tsx index 1175483..e83f9ff 100644 --- a/packages/renderer/src/components/Preloader.tsx +++ b/packages/renderer/src/components/Preloader.tsx @@ -1,276 +1,270 @@ -import React, { useState, useEffect, useRef } from "react"; +import React, { useState, useEffect, useCallback } from "react"; import { Progress } from "@/components/ui/progress"; import { Button } from "@/components/ui/button"; import { StatusCard } from "@/components/ui/status-card"; import { TitleBar } from "./TitleBar"; -import { preloader, launcherUpdater, ipcEvents } from "@app/preload"; +import { launcherUpdater, ipcEvents, gameUpdater } from "@app/preload"; import log from "@/utils/logger"; import backgroundImage from "@/assets/background.jpg"; import logoImage from "@/assets/logo.webp"; interface PreloaderProps { - onComplete: (data: { - gameStatus: GameStatus; - updateStatus: UpdateStatus; - }) => void; + onComplete: (data: { updateStatus: UpdateStatus }) => void; } +interface PreloaderState { + step: + | "launcher-check" + | "launcher-download" + | "launcher-install" + | "game-init" + | "complete"; + progress: number; + message: string; + error: string | null; + canContinue: boolean; + isComplete: boolean; +} + +const STEPS = { + "launcher-check": { + progress: 20, + message: "Vérification des mises à jour du launcher...", + }, + "launcher-download": { + progress: 60, + message: "Téléchargement de la mise à jour...", + }, + "launcher-install": { + progress: 80, + message: "Installation de la mise à jour...", + }, + "game-init": { + progress: 95, + message: "Vérification des mises à jour du jeu...", + }, + complete: { progress: 100, message: "Initialisation terminée" }, +} as const; + export const Preloader: React.FC = ({ onComplete }) => { - const [progress, setProgress] = useState(0); - const [currentMessage, setCurrentMessage] = useState( - "Initialisation du launcher..." + const [state, setState] = useState({ + step: "launcher-check", + progress: 10, + message: "Initialisation du launcher...", + error: null, + canContinue: false, + isComplete: false, + }); + + const updateState = useCallback((updates: Partial) => { + setState((prevState) => ({ ...prevState, ...updates })); + }, []); + + const setStep = useCallback( + (step: PreloaderState["step"], customMessage?: string) => { + const stepConfig = STEPS[step]; + updateState({ + step, + progress: stepConfig.progress, + message: customMessage || stepConfig.message, + error: null, + }); + }, + [updateState] ); - const [error, setError] = useState(null); - const [canContinue, setCanContinue] = useState(false); - const shouldStopRef = useRef(false); - // Listen to update events for progress tracking and errors - useEffect(() => { - const handleDownloadProgress = (status: UpdateStatus) => { - if (status.progress) { - const progressPercent = Math.round(status.progress.percent); - setProgress(20 + progressPercent * 0.3); // Map 0-100% to 20-50% of preloader - setCurrentMessage( - `Téléchargement de la mise à jour... ${progressPercent}%` - ); + const setError = useCallback( + (error: string, canContinue = true) => { + log.error("Preloader error:", error); + updateState({ + error, + canContinue, + }); + }, + [updateState] + ); + + const handleLauncherUpdateProgress = useCallback( + (updateStatus: UpdateStatus) => { + if (updateStatus.progress) { + const progressPercent = Math.round(updateStatus.progress.percent); + const downloadProgress = 20 + progressPercent * 0.4; // Map to 20-60% + updateState({ + progress: downloadProgress, + message: `Téléchargement de la mise à jour... ${progressPercent}%`, + }); } - }; + }, + [updateState] + ); - const handleUpdateError = (status: UpdateStatus) => { - log.error("Preloader: Update error event received:", status); + const handleLauncherUpdateError = useCallback( + (updateStatus: UpdateStatus) => { + const errorMessage = `Erreur de mise à jour: ${ + updateStatus.error || "Erreur inconnue" + }`; setError( - `Erreur de mise à jour: ${ - status.error || "Erreur inconnue" - }\n\nLa mise à jour sera tentée au prochain démarrage.\nVous pouvez continuer à utiliser le launcher.` + `${errorMessage}\n\nLa mise à jour sera tentée au prochain démarrage.\nVous pouvez continuer à utiliser le launcher.`, + true ); - setCanContinue(true); - setCurrentMessage("Erreur lors de la mise à jour"); - shouldStopRef.current = true; // Stop initialization - }; + }, + [setError] + ); - ipcEvents.on("launcherUpdater:download-progress", handleDownloadProgress); - ipcEvents.on("launcherUpdater:update-error", handleUpdateError); + // Set up event listeners + useEffect(() => { + ipcEvents.on( + "launcherUpdater:download-progress", + handleLauncherUpdateProgress + ); + ipcEvents.on("launcherUpdater:update-error", handleLauncherUpdateError); return () => { ipcEvents.off( "launcherUpdater:download-progress", - handleDownloadProgress + handleLauncherUpdateProgress ); - ipcEvents.off("launcherUpdater:update-error", handleUpdateError); + ipcEvents.off("launcherUpdater:update-error", handleLauncherUpdateError); }; - }, []); + }, [handleLauncherUpdateProgress, handleLauncherUpdateError]); + + const initializeLauncher = useCallback(async () => { + try { + // Step 1: Check for launcher updates + setStep("launcher-check"); + await new Promise((resolve) => setTimeout(resolve, 500)); // Small delay for UX + + const initialUpdateStatus = await launcherUpdater.getStatus(); + log.info( + "Preloader: Initial launcher update status:", + initialUpdateStatus + ); - useEffect(() => { - const initializeApp = async () => { - try { - // Step 1: Initialize launcher - setCurrentMessage("Initialisation du launcher..."); - setProgress(10); - - // Step 2: Check for app updates (BLOCKING) - setCurrentMessage("Vérification des mises à jour du launcher..."); - setProgress(20); - - let currentUpdateStatus = await launcherUpdater.getStatus(); - log.info("Preloader: Initial update status:", currentUpdateStatus); - - const hasUpdate = await launcherUpdater.checkForUpdates(); - log.info("Preloader: Update check result:", { hasUpdate }); - - if (hasUpdate) { - // BLOCKING: Wait for update to be available and start download - setCurrentMessage("Téléchargement de la mise à jour..."); - setProgress(25); - - await launcherUpdater.downloadUpdate(); - - // BLOCKING: Wait for download to complete by polling status - log.info("Preloader: Waiting for update download to complete"); - let downloadComplete = false; - - while (!downloadComplete) { - await new Promise((resolve) => setTimeout(resolve, 500)); - currentUpdateStatus = await launcherUpdater.getStatus(); - - if (currentUpdateStatus.downloaded) { - downloadComplete = true; - log.info("Preloader: Update download completed successfully"); - break; - } - - if (currentUpdateStatus.error) { - log.error( - "Preloader: Update download failed:", - currentUpdateStatus.error - ); - break; - } - } + const hasUpdate = await launcherUpdater.checkForUpdates(); + log.info("Preloader: Update check result:", { hasUpdate }); + + if (hasUpdate) { + // Step 2: Download update + setStep("launcher-download"); + await launcherUpdater.downloadUpdate(); + + // Wait for download completion + let downloadComplete = false; + let currentUpdateStatus = initialUpdateStatus; - setProgress(50); + while (!downloadComplete) { + await new Promise((resolve) => setTimeout(resolve, 500)); + currentUpdateStatus = await launcherUpdater.getStatus(); - // If update downloaded successfully, show popup and install if (currentUpdateStatus.downloaded) { - setCurrentMessage("Installation de la mise à jour..."); - log.info( - "Preloader: Update downloaded, showing installation dialog" - ); + downloadComplete = true; + log.info("Preloader: Update download completed successfully"); + break; + } - try { - log.info("Preloader: Calling launcherUpdater.installUpdate()"); - await launcherUpdater.installUpdate(); - - // This should quit and restart - code after this won't execute - log.info("Preloader: Install called, app should restart"); - } catch (installError) { - log.error("Preloader: Failed to install update:", installError); - const errorMessage = - installError instanceof Error - ? installError.message - : "Erreur inconnue"; - - setError( - `Échec de l'installation de la mise à jour: ${errorMessage}\n\nLa mise à jour sera tentée au prochain démarrage.\nVous pouvez continuer à utiliser le launcher.` - ); - setCanContinue(true); - setCurrentMessage("Erreur lors de l'installation"); - - log.info( - "Preloader: Install error handled, STOPPING initialization" - ); - return; // STOP COMPLETELY HERE - } - } else if (currentUpdateStatus.error) { - log.warn( - "Preloader: Update failed, continuing with launcher initialization" + if (currentUpdateStatus.error) { + log.error( + "Preloader: Update download failed:", + currentUpdateStatus.error ); - // Continue with initialization + throw new Error(currentUpdateStatus.error); } } - // Step 3: Continue with normal initialization (only if no errors occurred) - if (shouldStopRef.current || error) { - log.info("Preloader: Stopping initialization due to update error"); - return; - } - - setCurrentMessage("Téléchargement des métadonnées du jeu..."); - setProgress(80); - - const initResult = await preloader.initializeApp(); - - // Check again after game init in case error occurred during it - if (shouldStopRef.current || error) { - log.info( - "Preloader: Stopping initialization after game init due to error" - ); - return; - } - - if (initResult.gameStatus.error) { - log.warn( - "Preloader: Game status has error, but continuing", - initResult.gameStatus.error - ); - } - - setProgress(95); - setCurrentMessage("Finalisation de la configuration..."); - await new Promise((resolve) => setTimeout(resolve, 500)); - - // Final check before completing - if (shouldStopRef.current || error) { - log.info( - "Preloader: Stopping initialization before completion due to error" - ); - return; + // Step 3: Install update + if (currentUpdateStatus.downloaded) { + setStep("launcher-install"); + log.info("Preloader: Installing update"); + + try { + await launcherUpdater.installUpdate(); + // This should quit and restart - code after this won't execute + log.info("Preloader: Install called, app should restart"); + return; // Early return - app will restart + } catch (installError) { + const errorMessage = + installError instanceof Error + ? installError.message + : "Erreur inconnue"; + + throw new Error(`Échec de l'installation: ${errorMessage}`); + } } - - setProgress(100); - - // Complete initialization - const finalResult = { - ...initResult, - updateStatus: currentUpdateStatus, - }; - - log.info("Preloader: Initialization complete, proceeding to main app"); - onComplete(finalResult); - } catch (error) { - const errorMessage = - error instanceof Error - ? error.message - : "Une erreur inconnue s'est produite"; - log.error("Preloader: Fatal initialization error", error); - setError(errorMessage); - setCurrentMessage("Erreur lors de l'initialisation"); - setCanContinue(true); } - }; - - initializeApp(); - }, [onComplete, error]); - - const handleContinueAnyway = () => { - log.info( - "Preloader: User chose to continue despite update errors, resetting state" - ); - // Reset error states so initialization can continue - setError(null); - setCanContinue(false); - shouldStopRef.current = false; - - // Restart the initialization from Step 3 (game metadata) - setCurrentMessage("Continuation de l'initialisation..."); - setProgress(80); - - const continueInitialization = async () => { - try { - const initResult = await preloader.initializeApp(); - - if (initResult.gameStatus.error) { - log.warn( - "Preloader: Game status has error, but continuing", - initResult.gameStatus.error - ); - } - - setProgress(95); - setCurrentMessage("Finalisation de la configuration..."); - await new Promise((resolve) => setTimeout(resolve, 500)); - setProgress(100); - - // Complete initialization with update error noted - const finalResult = { - ...initResult, + // Step 4: Initialize game state + setStep("game-init"); + await new Promise((resolve) => setTimeout(resolve, 500)); // Let UI update + + // Initialize game state to prevent flickering later + log.info("Preloader: Initializing game state"); + await gameUpdater.getStatus(); // This will trigger the game state initialization + + // Step 5: Complete + setStep("complete"); + await new Promise((resolve) => setTimeout(resolve, 300)); // Brief pause before completion + + // Finalize with current update status + const finalUpdateStatus = await launcherUpdater.getStatus(); + + updateState({ isComplete: true }); + + // Complete initialization after a brief delay + setTimeout(() => { + onComplete({ updateStatus: finalUpdateStatus }); + }, 200); + } catch (error) { + const errorMessage = + error instanceof Error + ? error.message + : "Une erreur inconnue s'est produite"; + setError(errorMessage, true); + } + }, [setStep, setError, updateState, onComplete]); + + const handleContinueAnyway = useCallback(async () => { + log.info("Preloader: User chose to continue despite errors"); + + try { + updateState({ + error: null, + canContinue: false, + step: "game-init", + progress: 95, + message: "Continuation de l'initialisation...", + }); + + // Initialize game state + await gameUpdater.getStatus(); + + setStep("complete"); + await new Promise((resolve) => setTimeout(resolve, 300)); + + updateState({ isComplete: true }); + + setTimeout(() => { + onComplete({ updateStatus: { available: false, downloading: false, downloaded: false, error: "Update installation failed, but launcher continued", }, - }; - - log.info("Preloader: Initialization complete after continue anyway"); - onComplete(finalResult); - } catch (error) { - const errorMessage = - error instanceof Error - ? error.message - : "Une erreur inconnue s'est produite"; - log.error( - "Preloader: Error during continue anyway initialization", - error - ); - setError(errorMessage); - setCurrentMessage("Erreur lors de l'initialisation"); - setCanContinue(true); - } - }; - - continueInitialization(); - }; + }); + }, 200); + } catch (error) { + const errorMessage = + error instanceof Error + ? error.message + : "Une erreur inconnue s'est produite"; + setError(errorMessage, true); + } + }, [updateState, setStep, onComplete, setError]); + + // Initialize on mount + useEffect(() => { + initializeLauncher(); + }, [initializeLauncher]); return (
= ({ onComplete }) => { {/* Preloader Content */}
+ {/* Logo */}
= ({ onComplete }) => { />
+ {/* Progress Section */}

- {currentMessage} + {state.message}

- +
-

{Math.round(progress)}%

- {error && ( - -

{error}

- {canContinue && ( +

+ {Math.round(state.progress)}% +

+ + {/* Error Card */} + {state.error && ( + +

+ {state.error} +

+ {state.canContinue && ( diff --git a/packages/renderer/src/components/launcher/DownloadButton.tsx b/packages/renderer/src/components/launcher/DownloadButton.tsx index 76ce9fe..4a7bf31 100644 --- a/packages/renderer/src/components/launcher/DownloadButton.tsx +++ b/packages/renderer/src/components/launcher/DownloadButton.tsx @@ -2,29 +2,20 @@ import React, { useState, useEffect } from "react"; import { Button } from "@/components/ui/button"; import { Progress } from "@/components/ui/progress"; import { Download, Play, Pause, AlertCircle, RotateCw } from "lucide-react"; -import { gameClient, ipcEvents } from "@app/preload"; +import { useGameStateContext } from "@/contexts/GameStateContext"; import log from "@/utils/logger"; interface DownloadButtonProps { - gameStatus?: GameStatus; onDownloadComplete?: () => void; onDownloadStateChange?: (isDownloading: boolean) => void; } export const DownloadButton: React.FC = ({ - gameStatus, onDownloadComplete, onDownloadStateChange, }) => { - const [downloadProgress, setDownloadProgress] = useState({ - filesTotal: 0, - filesLoaded: 0, - isDownloading: false, - }); - const [isLoading, setIsLoading] = useState(false); - const [isCheckingForUpdates, setIsCheckingForUpdates] = useState(false); + const { gameState, gameActions } = useGameStateContext(); const [recentlyLaunched, setRecentlyLaunched] = useState(false); - const [stateConfirmed, setStateConfirmed] = useState(false); // Cleanup timeout on unmount useEffect(() => { @@ -34,143 +25,55 @@ export const DownloadButton: React.FC = ({ }; }, []); - // Confirm state when gameStatus is received - useEffect(() => { - if (gameStatus !== undefined) { - setStateConfirmed(true); - } - }, [gameStatus]); - // Notify parent of download state changes useEffect(() => { - const isCurrentlyDownloading = - downloadProgress.isDownloading || (isLoading && !isCheckingForUpdates); if (onDownloadStateChange) { - onDownloadStateChange(isCurrentlyDownloading); + onDownloadStateChange(gameState.isDownloading); } - }, [ - downloadProgress.isDownloading, - isLoading, - isCheckingForUpdates, - onDownloadStateChange, - ]); + }, [gameState.isDownloading, onDownloadStateChange]); - // Event-driven approach: Listen to IPC events and fetch initial state + // Notify parent when download completes useEffect(() => { - // Fetch initial download progress state immediately - const fetchInitialState = async () => { - try { - const progress = await gameClient.getDownloadProgress(); - setDownloadProgress(progress); - } catch (error) { - log.error("Failed to get initial download progress:", error); - } - }; - - fetchInitialState(); - - // Event handlers for download progress updates - const handleDownloadStarted = (progress: DownloadProgress) => { - setDownloadProgress(progress); - }; - - const handleDownloadProgress = (progress: DownloadProgress) => { - setDownloadProgress(progress); - }; - - const handleDownloadComplete = (progress: DownloadProgress) => { - setDownloadProgress(progress); - if (onDownloadComplete) { - onDownloadComplete(); - } - }; - - const handleDownloadCancelled = (progress: DownloadProgress) => { - setDownloadProgress(progress); - }; - - const handleDownloadError = (progress: DownloadProgress) => { - setDownloadProgress(progress); - }; - - // Register event listeners - ipcEvents.on("game:download-started", handleDownloadStarted); - ipcEvents.on("game:download-progress", handleDownloadProgress); - ipcEvents.on("game:download-complete", handleDownloadComplete); - ipcEvents.on("game:download-cancelled", handleDownloadCancelled); - ipcEvents.on("game:download-error", handleDownloadError); - - // Cleanup event listeners on unmount - return () => { - ipcEvents.off("game:download-started", handleDownloadStarted); - ipcEvents.off("game:download-progress", handleDownloadProgress); - ipcEvents.off("game:download-complete", handleDownloadComplete); - ipcEvents.off("game:download-cancelled", handleDownloadCancelled); - ipcEvents.off("game:download-error", handleDownloadError); - }; - }, [onDownloadComplete]); + if (onDownloadComplete && !gameState.isDownloading && gameState.isReady) { + onDownloadComplete(); + } + }, [gameState.isDownloading, gameState.isReady, onDownloadComplete]); const handleStartDownload = async () => { try { - setIsLoading(true); - await gameClient.startDownload(); + await gameActions.startDownload(); } catch (error) { log.error("Failed to start download:", error); - setDownloadProgress({ - ...downloadProgress, - error: - error instanceof Error ? error.message : "Failed to start download", - }); - } finally { - setIsLoading(false); } }; const handleCancelDownload = async () => { try { - await gameClient.cancelDownload(); + await gameActions.cancelDownload(); } catch (error) { log.error("Failed to cancel download:", error); } }; const handleRetryDownload = async () => { - setDownloadProgress({ - filesTotal: 0, - filesLoaded: 0, - isDownloading: false, - error: undefined, - }); + gameActions.clearError(); await handleStartDownload(); }; const handleLaunchGame = async () => { try { - setIsLoading(true); - setIsCheckingForUpdates(true); - - // Check for updates before launching - const updatedStatus = await gameClient.checkForUpdates(); - - if (updatedStatus.needsUpdate) { - // Reset loading states before returning - setIsLoading(false); - setIsCheckingForUpdates(false); - - // If update is needed, refresh the parent component - if (onDownloadComplete) { - onDownloadComplete(); + // Get current settings from localStorage + const savedSettings = localStorage.getItem("launcherSettings"); + let settings; + if (savedSettings) { + try { + settings = JSON.parse(savedSettings); + } catch (error) { + log.error("Failed to parse settings:", error); } - return; } - // Keep checking state true during launch to prevent progress bar flash - // Launch the game - await gameClient.launchGame(); - - // Reset loading states immediately after successful launch - setIsLoading(false); - setIsCheckingForUpdates(false); + await gameActions.launchGame(settings); // Set recently launched state to prevent double-clicking setRecentlyLaunched(true); @@ -181,60 +84,50 @@ export const DownloadButton: React.FC = ({ }, 1500); } catch (error) { log.error("Failed to launch game:", error); - setDownloadProgress({ - ...downloadProgress, - error: error instanceof Error ? error.message : "Failed to launch game", - }); - } finally { - setIsLoading(false); - setIsCheckingForUpdates(false); } }; const getButtonContent = () => { // If download is in progress or loading (but not just checking for updates) - if ( - downloadProgress.isDownloading || - (isLoading && !isCheckingForUpdates) - ) { + if (gameState.isDownloading) { const progressPercent = - downloadProgress.filesTotal > 0 - ? (downloadProgress.filesLoaded / downloadProgress.filesTotal) * 100 + gameState.downloadProgress.filesTotal > 0 + ? (gameState.downloadProgress.filesLoaded / + gameState.downloadProgress.filesTotal) * + 100 : 0; const getStatusText = () => { - if (downloadProgress.currentFile) { + if (gameState.downloadProgress.currentFile) { // If it's a verification phase or repair verification if ( - downloadProgress.currentFile.includes("Vérification") || - downloadProgress.currentFile.includes("vérification") || - downloadProgress.currentFile.includes("intégrité") + gameState.downloadProgress.currentFile.includes("Vérification") || + gameState.downloadProgress.currentFile.includes("vérification") || + gameState.downloadProgress.currentFile.includes("intégrité") ) { - return downloadProgress.currentFile; + return gameState.downloadProgress.currentFile; } // If we're at the beginning with 0 progress and no specific verification text, // we're likely verifying files - if (progressPercent === 0 && downloadProgress.filesTotal > 0) { + if ( + progressPercent === 0 && + gameState.downloadProgress.filesTotal > 0 + ) { return "Vérification des fichiers..."; } // Regular file download/repair - const fileName = downloadProgress.currentFile.split("/").pop(); - return downloadProgress.isRepairing + const fileName = gameState.downloadProgress.currentFile + .split("/") + .pop(); + return gameState.isRepairing ? `Réparation: ${fileName}` : `Téléchargement: ${fileName}`; } - // Loading states - if (isLoading) { - return downloadProgress.isRepairing - ? "Préparation de la réparation..." - : "Préparation du téléchargement..."; - } - // Default states - return downloadProgress.isRepairing + return gameState.isRepairing ? "Réparation en cours..." : "Téléchargement en cours..."; }; @@ -244,13 +137,15 @@ export const DownloadButton: React.FC = ({
{getStatusText()} - {downloadProgress.filesTotal > 0 - ? `${downloadProgress.filesLoaded}/${downloadProgress.filesTotal}` + {gameState.downloadProgress.filesTotal > 0 + ? `${gameState.downloadProgress.filesLoaded}/${gameState.downloadProgress.filesTotal}` : "Initialisation..."}
0 ? progressPercent : 0} + value={ + gameState.downloadProgress.filesTotal > 0 ? progressPercent : 0 + } className="w-full h-3" />
); } // If there's a download error - if (downloadProgress.error) { + if (gameState.error) { return (
- )} -
- -
-

- {displayInfo.title} -

-

- {displayInfo.subtitle} -

- - {displayInfo.isValid && - replay.player1 && - replay.player2 && ( -
- - Combat 1vs1 -
- )} - - -
-
- ); - })} - - + + ); + })} + + - {/* Pagination - Now outside the scrollable area */} - {totalPages > 1 && ( -
- - -
- {Array.from({ length: Math.min(5, totalPages) }, (_, i) => { - const pageNum = - Math.max(1, Math.min(totalPages - 4, currentPage - 2)) + - i; - if (pageNum > totalPages) return null; - - return ( - + +
+ {Array.from( + { length: Math.min(5, totalPages) }, + (_, i) => { + const pageNum = + Math.max( + 1, + Math.min(totalPages - 4, currentPage - 2) + ) + i; + if (pageNum > totalPages) return null; + + return ( + + ); } - onClick={() => handlePageChange(pageNum)} - > - {pageNum} - - ); - })} -
+ )} +
- + +
+ )} + + {/* Page Info */} + {replays.length > 0 && ( +
+ {totalPages > 1 + ? `Page ${currentPage} sur ${totalPages} • ${replays.length} replays au total` + : `${replays.length} replay${ + replays.length > 1 ? "s" : "" + }`} +
+ )} - )} - - {/* Page Info */} - {replays.length > 0 && ( -
- {totalPages > 1 - ? `Page ${currentPage} sur ${totalPages} • ${replays.length} replays au total` - : `${replays.length} replay${replays.length > 1 ? "s" : ""}`} + ) : ( +
+
+
)} -
- )} - - {/* No Replays State */} - {!isLoading && replays.length === 0 && ( -
-
-
-
+ )} diff --git a/packages/renderer/src/contexts/GameStateContext.tsx b/packages/renderer/src/contexts/GameStateContext.tsx new file mode 100644 index 0000000..9fc7e27 --- /dev/null +++ b/packages/renderer/src/contexts/GameStateContext.tsx @@ -0,0 +1,77 @@ +import React, { createContext, useContext } from "react"; +import type { ReactNode } from "react"; +import { useGameState } from "@/hooks/useGameState"; +import type { GameState, GameStateActions } from "@/hooks/useGameState"; + +interface GameStateContextType { + gameState: GameState; + gameActions: GameStateActions; +} + +const GameStateContext = createContext( + undefined +); + +interface GameStateProviderProps { + children: ReactNode; +} + +export const GameStateProvider: React.FC = ({ + children, +}) => { + const [gameState, gameActions] = useGameState(); + + const value: GameStateContextType = { + gameState, + gameActions, + }; + + return ( + + {children} + + ); +}; + +export const useGameStateContext = (): GameStateContextType => { + const context = useContext(GameStateContext); + if (context === undefined) { + throw new Error( + "useGameStateContext must be used within a GameStateProvider" + ); + } + return context; +}; + +// Convenience hooks for specific parts of the state +export const useGameReady = () => { + const { gameState } = useGameStateContext(); + return gameState.isReady; +}; + +export const useCanLaunch = () => { + const { gameState } = useGameStateContext(); + return gameState.canLaunch; +}; + +export const useGameStatus = () => { + const { gameState } = useGameStateContext(); + return { + isInstalled: gameState.isInstalled, + needsUpdate: gameState.needsUpdate, + localVersion: gameState.localVersion, + remoteVersion: gameState.remoteVersion, + statusText: gameState.statusText, + error: gameState.error, + }; +}; + +export const useDownloadState = () => { + const { gameState } = useGameStateContext(); + return { + isDownloading: gameState.isDownloading, + isRepairing: gameState.isRepairing, + progress: gameState.downloadProgress, + canStart: gameState.canStartDownload, + }; +}; diff --git a/packages/renderer/src/hooks/useGameState.ts b/packages/renderer/src/hooks/useGameState.ts new file mode 100644 index 0000000..e251854 --- /dev/null +++ b/packages/renderer/src/hooks/useGameState.ts @@ -0,0 +1,414 @@ +import { useState, useEffect, useCallback, useRef } from "react"; +import { gameUpdater, gameClient, ipcEvents } from "@app/preload"; +import log from "@/utils/logger"; + +export interface GameState { + // Core status + isInstalled: boolean; + needsUpdate: boolean; + localVersion: string | null; + remoteVersion: string | null; + + // Download/update progress + isDownloading: boolean; + isRepairing: boolean; + downloadProgress: { + filesTotal: number; + filesLoaded: number; + currentFile?: string; + }; + + // Current operations + isLaunching: boolean; + isCheckingStatus: boolean; + + // Errors + error: string | null; + + // Computed properties + isReady: boolean; + canLaunch: boolean; + canStartDownload: boolean; + statusText: string; +} + +export interface GameStateActions { + refreshStatus: () => Promise; + startDownload: () => Promise; + cancelDownload: () => Promise; + repairClient: () => Promise; + launchGame: (settings?: unknown) => Promise; + launchReplayOffline: ( + replayPath: string, + settings?: unknown + ) => Promise; + clearError: () => void; +} + +const initialState: GameState = { + isInstalled: false, + needsUpdate: false, + localVersion: null, + remoteVersion: null, + isDownloading: false, + isRepairing: false, + downloadProgress: { + filesTotal: 0, + filesLoaded: 0, + }, + isLaunching: false, + isCheckingStatus: false, + error: null, + isReady: false, + canLaunch: false, + canStartDownload: false, + statusText: "Initialisation...", +}; + +export function useGameState(): [GameState, GameStateActions] { + const [state, setState] = useState(initialState); + const isInitialized = useRef(false); + const statusCheckInterval = useRef(null); + + // Compute derived properties + const computeState = useCallback( + (baseState: Partial): GameState => { + const computed = { ...state, ...baseState }; + + // Core computed properties + computed.isReady = + computed.isInstalled && + !computed.needsUpdate && + !computed.isDownloading && + !computed.isLaunching && + !computed.isCheckingStatus && + !computed.error; + + computed.canLaunch = + computed.isInstalled && + !computed.needsUpdate && + !computed.isDownloading && + !computed.isLaunching && + !computed.error; + + computed.canStartDownload = + !computed.isDownloading && + !computed.isLaunching && + !computed.isCheckingStatus; + + // Status text + if (computed.error) { + computed.statusText = "Erreur"; + } else if (computed.isLaunching) { + computed.statusText = "Lancement du jeu..."; + } else if (computed.isDownloading) { + if (computed.isRepairing) { + computed.statusText = "Réparation en cours..."; + } else if (computed.downloadProgress.currentFile) { + const fileName = computed.downloadProgress.currentFile + .split("/") + .pop(); + computed.statusText = `Téléchargement: ${fileName}`; + } else { + computed.statusText = "Téléchargement..."; + } + } else if (computed.isCheckingStatus) { + computed.statusText = "Vérification..."; + } else if (!computed.isInstalled) { + computed.statusText = "Jeu non installé"; + } else if (computed.needsUpdate) { + computed.statusText = "Mise à jour disponible"; + } else { + computed.statusText = "Prêt à jouer"; + } + + return computed; + }, + [state] + ); + + // Update state with computed properties + const updateState = useCallback( + (updates: Partial) => { + setState((prevState) => computeState({ ...prevState, ...updates })); + }, + [computeState] + ); + + // Refresh game status + const refreshStatus = useCallback(async () => { + if (state.isCheckingStatus) return; + + try { + updateState({ isCheckingStatus: true, error: null }); + + const [gameStatus, downloadProgress] = await Promise.all([ + gameUpdater.getStatus(), + gameUpdater.getDownloadProgress(), + ]); + + updateState({ + isInstalled: gameStatus.isInstalled, + needsUpdate: gameStatus.needsUpdate, + localVersion: gameStatus.localVersion, + remoteVersion: gameStatus.remoteVersion, + isDownloading: downloadProgress.isDownloading, + isRepairing: downloadProgress.isRepairing || false, + downloadProgress: { + filesTotal: downloadProgress.filesTotal, + filesLoaded: downloadProgress.filesLoaded, + currentFile: downloadProgress.currentFile, + }, + error: gameStatus.error || downloadProgress.error || null, + isCheckingStatus: false, + }); + } catch (error) { + log.error("Failed to refresh game status:", error); + updateState({ + error: error instanceof Error ? error.message : "Erreur inconnue", + isCheckingStatus: false, + }); + } + }, [state.isCheckingStatus, updateState]); + + // Start download + const startDownload = useCallback(async () => { + if (!state.canStartDownload) return; + + try { + updateState({ error: null }); + await gameUpdater.startDownload(); + // Status will be updated via IPC events + } catch (error) { + log.error("Failed to start download:", error); + updateState({ + error: + error instanceof Error ? error.message : "Échec du téléchargement", + }); + } + }, [state.canStartDownload, updateState]); + + // Cancel download + const cancelDownload = useCallback(async () => { + try { + await gameUpdater.cancelDownload(); + // Status will be updated via IPC events + } catch (error) { + log.error("Failed to cancel download:", error); + updateState({ + error: error instanceof Error ? error.message : "Échec de l'annulation", + }); + } + }, [updateState]); + + // Repair client + const repairClient = useCallback(async () => { + if (!state.canStartDownload) return; + + try { + updateState({ error: null }); + await gameUpdater.repairClient(); + // Status will be updated via IPC events + } catch (error) { + log.error("Failed to repair client:", error); + updateState({ + error: + error instanceof Error ? error.message : "Échec de la réparation", + }); + } + }, [state.canStartDownload, updateState]); + + // Launch game + const launchGame = useCallback( + async (settings?: unknown) => { + if (!state.canLaunch) return; + + try { + updateState({ isLaunching: true, error: null }); + + // Double-check status before launching + const currentStatus = await gameUpdater.checkForUpdates(); + if (currentStatus.needsUpdate) { + updateState({ + needsUpdate: true, + isLaunching: false, + error: "Mise à jour requise avant le lancement", + }); + return; + } + + await gameClient.launchGame(settings); + + // Keep launching state for a moment to prevent double-clicks + setTimeout(() => { + updateState({ isLaunching: false }); + }, 2000); + } catch (error) { + log.error("Failed to launch game:", error); + updateState({ + isLaunching: false, + error: error instanceof Error ? error.message : "Échec du lancement", + }); + } + }, + [state.canLaunch, updateState] + ); + + // Launch replay offline (no CDN check) + const launchReplayOffline = useCallback( + async (replayPath: string, settings?: unknown) => { + // Only check if game is locally installed, no network check + if (!state.isInstalled) return; + + try { + updateState({ isLaunching: true, error: null }); + await gameClient.launchReplayOffline(replayPath, settings); + + setTimeout(() => { + updateState({ isLaunching: false }); + }, 2000); + } catch (error) { + log.error("Failed to launch replay offline:", error); + updateState({ + isLaunching: false, + error: + error instanceof Error + ? error.message + : "Échec du lancement du replay", + }); + } + }, + [state.isInstalled, updateState] + ); + + // Clear error + const clearError = useCallback(() => { + updateState({ error: null }); + }, [updateState]); + + // Set up IPC event listeners + useEffect(() => { + const handleDownloadStarted = (progress: { + isRepairing?: boolean; + filesTotal: number; + filesLoaded: number; + currentFile?: string; + }) => { + updateState({ + isDownloading: true, + isRepairing: progress.isRepairing || false, + downloadProgress: { + filesTotal: progress.filesTotal, + filesLoaded: progress.filesLoaded, + currentFile: progress.currentFile, + }, + error: null, + }); + }; + + const handleDownloadProgress = (progress: { + filesTotal: number; + filesLoaded: number; + currentFile?: string; + }) => { + updateState({ + downloadProgress: { + filesTotal: progress.filesTotal, + filesLoaded: progress.filesLoaded, + currentFile: progress.currentFile, + }, + }); + }; + + const handleDownloadComplete = (progress: { + filesTotal: number; + filesLoaded: number; + currentFile?: string; + }) => { + updateState({ + isDownloading: false, + isRepairing: false, + downloadProgress: { + filesTotal: progress.filesTotal, + filesLoaded: progress.filesLoaded, + currentFile: progress.currentFile, + }, + }); + // Refresh status after download completion + setTimeout(refreshStatus, 500); + }; + + const handleDownloadCancelled = () => { + updateState({ + isDownloading: false, + isRepairing: false, + }); + }; + + const handleDownloadError = (progress: { error?: string }) => { + updateState({ + isDownloading: false, + isRepairing: false, + error: progress.error || "Erreur de téléchargement", + }); + }; + + // Register event listeners + ipcEvents.on("gameUpdater:download-started", handleDownloadStarted); + ipcEvents.on("gameUpdater:download-progress", handleDownloadProgress); + ipcEvents.on("gameUpdater:download-complete", handleDownloadComplete); + ipcEvents.on("gameUpdater:download-cancelled", handleDownloadCancelled); + ipcEvents.on("gameUpdater:download-error", handleDownloadError); + + return () => { + ipcEvents.off("gameUpdater:download-started", handleDownloadStarted); + ipcEvents.off("gameUpdater:download-progress", handleDownloadProgress); + ipcEvents.off("gameUpdater:download-complete", handleDownloadComplete); + ipcEvents.off("gameUpdater:download-cancelled", handleDownloadCancelled); + ipcEvents.off("gameUpdater:download-error", handleDownloadError); + }; + }, [updateState, refreshStatus]); + + // Initialize and set up periodic status checks + useEffect(() => { + if (!isInitialized.current) { + isInitialized.current = true; + refreshStatus(); + + // Set up periodic status checks (every 10 minutes when idle) + statusCheckInterval.current = setInterval(() => { + if ( + !state.isDownloading && + !state.isLaunching && + !state.isCheckingStatus + ) { + refreshStatus(); + } + }, 600000); + } + + return () => { + if (statusCheckInterval.current) { + clearInterval(statusCheckInterval.current); + statusCheckInterval.current = null; + } + }; + }, [ + refreshStatus, + state.isDownloading, + state.isLaunching, + state.isCheckingStatus, + ]); + + const actions: GameStateActions = { + refreshStatus, + startDownload, + cancelDownload, + repairClient, + launchGame, + launchReplayOffline, + clearError, + }; + + return [state, actions]; +} From 4943b7f156948eeb46e9c8b94f7042de193f3c33 Mon Sep 17 00:00:00 2001 From: Jordan Rey Date: Thu, 3 Jul 2025 04:23:23 +0200 Subject: [PATCH 7/7] update deps --- package-lock.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/package-lock.json b/package-lock.json index d9c63b8..8241aaa 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "arena-returns-launcher", - "version": "3.1.0", + "version": "3.2.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "arena-returns-launcher", - "version": "3.1.0", + "version": "3.2.0", "workspaces": [ "packages/*" ],