From 85a4bdb7b1cf5fb259960590c028c0f6e57fe4f6 Mon Sep 17 00:00:00 2001 From: Moyasee Date: Thu, 29 Jan 2026 18:23:40 +0200 Subject: [PATCH] feat: implement common redistributables preflight checks and UI updates - Added preflight check for common redistributables during game launch. - Introduced new translation keys for preflight status messages. - Updated GameLauncher component to handle preflight progress and display status. - Enhanced CommonRedistManager with methods to reset and check preflight status. - Integrated logging for preflight checks and redistributable installations. - Added spinner animation to indicate loading state in the UI. --- src/locales/en/translation.json | 6 +- src/main/events/misc/index.ts | 1 + src/main/events/misc/install-common-redist.ts | 2 + .../misc/reset-common-redist-preflight.ts | 8 + src/main/helpers/launch-game.ts | 21 +- src/main/level/sublevels/keys.ts | 1 + src/main/services/common-redist-manager.ts | 319 +++++++++++++++++- src/preload/index.ts | 12 + src/renderer/src/declaration.d.ts | 4 + .../pages/game-launcher/game-launcher.scss | 21 ++ .../src/pages/game-launcher/game-launcher.tsx | 82 ++++- 11 files changed, 468 insertions(+), 9 deletions(-) create mode 100644 src/main/events/misc/reset-common-redist-preflight.ts diff --git a/src/locales/en/translation.json b/src/locales/en/translation.json index 3a9cd44d..2f48433b 100755 --- a/src/locales/en/translation.json +++ b/src/locales/en/translation.json @@ -833,7 +833,11 @@ "amount_hours": "{{amount}} hours", "amount_minutes": "{{amount}} minutes", "amount_hours_short": "{{amount}}h", - "amount_minutes_short": "{{amount}}m" + "amount_minutes_short": "{{amount}}m", + "preflight_checking": "Checking dependencies", + "preflight_downloading": "Downloading dependencies", + "preflight_installing": "Installing dependencies", + "preflight_installing_detail": "{{detail}}" }, "notifications_page": { "title": "Notifications", diff --git a/src/main/events/misc/index.ts b/src/main/events/misc/index.ts index 65ae8dd5..92446f76 100644 --- a/src/main/events/misc/index.ts +++ b/src/main/events/misc/index.ts @@ -11,6 +11,7 @@ import "./is-main-window-open"; import "./open-checkout"; import "./open-external"; import "./open-main-window"; +import "./reset-common-redist-preflight"; import "./save-temp-file"; import "./show-item-in-folder"; import "./show-open-dialog"; diff --git a/src/main/events/misc/install-common-redist.ts b/src/main/events/misc/install-common-redist.ts index 34e609ec..d0f96e0a 100644 --- a/src/main/events/misc/install-common-redist.ts +++ b/src/main/events/misc/install-common-redist.ts @@ -3,6 +3,8 @@ import { CommonRedistManager } from "@main/services/common-redist-manager"; const installCommonRedist = async (_event: Electron.IpcMainInvokeEvent) => { if (await CommonRedistManager.canInstallCommonRedist()) { + // Reset preflight status so the user can force a re-run + await CommonRedistManager.resetPreflightStatus(); CommonRedistManager.installCommonRedist(); } }; diff --git a/src/main/events/misc/reset-common-redist-preflight.ts b/src/main/events/misc/reset-common-redist-preflight.ts new file mode 100644 index 00000000..8ff4df6c --- /dev/null +++ b/src/main/events/misc/reset-common-redist-preflight.ts @@ -0,0 +1,8 @@ +import { registerEvent } from "../register-event"; +import { CommonRedistManager } from "@main/services/common-redist-manager"; + +const resetCommonRedistPreflight = async ( + _event: Electron.IpcMainInvokeEvent +) => CommonRedistManager.resetPreflightStatus(); + +registerEvent("resetCommonRedistPreflight", resetCommonRedistPreflight); diff --git a/src/main/helpers/launch-game.ts b/src/main/helpers/launch-game.ts index 63a4f7e5..41ecc330 100644 --- a/src/main/helpers/launch-game.ts +++ b/src/main/helpers/launch-game.ts @@ -2,7 +2,8 @@ import { shell } from "electron"; import { spawn } from "node:child_process"; import { GameShop } from "@types"; import { gamesSublevel, levelKeys } from "@main/level"; -import { WindowManager } from "@main/services"; +import { WindowManager, logger } from "@main/services"; +import { CommonRedistManager } from "@main/services/common-redist-manager"; import { parseExecutablePath } from "../events/helpers/parse-executable-path"; import { parseLaunchOptions } from "../events/helpers/parse-launch-options"; @@ -36,6 +37,24 @@ export const launchGame = async (options: LaunchGameOptions): Promise => { await WindowManager.createGameLauncherWindow(shop, objectId); + // Run preflight check for common redistributables (Windows only) + // Wrapped in try/catch to ensure game launch is never blocked + if (process.platform === "win32") { + try { + logger.log("Starting preflight check for game launch", { + shop, + objectId, + }); + const preflightPassed = await CommonRedistManager.runPreflight(); + logger.log("Preflight check result", { passed: preflightPassed }); + } catch (error) { + logger.error( + "Preflight check failed with error, continuing with launch", + error + ); + } + } + await new Promise((resolve) => setTimeout(resolve, 2000)); if (parsedParams.length === 0) { diff --git a/src/main/level/sublevels/keys.ts b/src/main/level/sublevels/keys.ts index d055d1e6..cbf222f4 100644 --- a/src/main/level/sublevels/keys.ts +++ b/src/main/level/sublevels/keys.ts @@ -21,4 +21,5 @@ export const levelKeys = { downloadSourcesCheckBaseline: "downloadSourcesCheckBaseline", // When we last started the app downloadSourcesSinceValue: "downloadSourcesSinceValue", // The 'since' value API used (for modal comparison) localNotifications: "localNotifications", + commonRedistPassed: "commonRedistPassed", // Whether common redistributables preflight has passed }; diff --git a/src/main/services/common-redist-manager.ts b/src/main/services/common-redist-manager.ts index 862a005f..e53fd63f 100644 --- a/src/main/services/common-redist-manager.ts +++ b/src/main/services/common-redist-manager.ts @@ -6,6 +6,12 @@ import path from "node:path"; import { logger } from "./logger"; import { WindowManager } from "./window-manager"; import { SystemPath } from "./system-path"; +import { db, levelKeys } from "@main/level"; + +interface RedistCheck { + name: string; + check: () => boolean; +} export class CommonRedistManager { private static readonly redistributables = [ @@ -22,6 +28,87 @@ export class CommonRedistManager { "common_redist_install.log" ); + private static readonly system32Path = process.env.SystemRoot + ? path.join(process.env.SystemRoot, "System32") + : path.join("C:", "Windows", "System32"); + + private static readonly systemChecks: RedistCheck[] = [ + { + name: "Visual C++ Runtime", + check: () => { + // Check for VS 2015-2022 runtime DLLs + const vcRuntime140 = path.join( + CommonRedistManager.system32Path, + "vcruntime140.dll" + ); + const msvcp140 = path.join( + CommonRedistManager.system32Path, + "msvcp140.dll" + ); + return fs.existsSync(vcRuntime140) && fs.existsSync(msvcp140); + }, + }, + { + name: "DirectX June 2010", + check: () => { + // Check for DirectX June 2010 DLLs + const d3dx9_43 = path.join( + CommonRedistManager.system32Path, + "d3dx9_43.dll" + ); + return fs.existsSync(d3dx9_43); + }, + }, + { + name: "OpenAL", + check: () => { + const openAL = path.join( + CommonRedistManager.system32Path, + "OpenAL32.dll" + ); + return fs.existsSync(openAL); + }, + }, + { + name: ".NET Framework 4.0", + check: () => { + // Check for .NET 4.x runtime + const dotNetPath = path.join( + process.env.SystemRoot || path.join("C:", "Windows"), + "Microsoft.NET", + "Framework", + "v4.0.30319", + "clr.dll" + ); + return fs.existsSync(dotNetPath); + }, + }, + { + name: "XNA Framework 4.0", + check: () => { + // XNA Framework installs to GAC - check for the assembly folder + const windowsDir = process.env.SystemRoot || path.join("C:", "Windows"); + const xnaGacPath = path.join( + windowsDir, + "Microsoft.NET", + "assembly", + "GAC_32", + "Microsoft.Xna.Framework" + ); + const xnaGacPath64 = path.join( + windowsDir, + "Microsoft.NET", + "assembly", + "GAC_MSIL", + "Microsoft.Xna.Framework" + ); + // XNA is rare - most modern games don't need it + // Consider it installed if either GAC path exists + return fs.existsSync(xnaGacPath) || fs.existsSync(xnaGacPath64); + }, + }, + ]; + public static async installCommonRedist() { const abortController = new AbortController(); const timeout = setTimeout(() => { @@ -75,26 +162,85 @@ export class CommonRedistManager { ); } + /** + * Checks if all installer files are present in the CommonRedist folder + */ public static async canInstallCommonRedist() { - return this.redistributables.every((redist) => { - const filePath = path.join(commonRedistPath, redist); + const missingFiles: string[] = []; - return fs.existsSync(filePath); - }); + for (const redist of this.redistributables) { + const filePath = path.join(commonRedistPath, redist); + const exists = fs.existsSync(filePath); + + if (!exists) { + missingFiles.push(redist); + } + } + + if (missingFiles.length > 0) { + logger.log("Missing redistributable installer files:", missingFiles); + logger.log("CommonRedist path:", commonRedistPath); + return false; + } + + logger.log("All redistributable installer files present"); + return true; + } + + /** + * Checks if redistributables are actually installed on the Windows system + * by checking for DLLs in System32 and other locations + */ + public static checkSystemRedistributables(): { + allInstalled: boolean; + missing: string[]; + } { + const missing: string[] = []; + + for (const redistCheck of this.systemChecks) { + try { + const isInstalled = redistCheck.check(); + if (!isInstalled) { + missing.push(redistCheck.name); + } + logger.log( + `System check: ${redistCheck.name} - ${isInstalled ? "installed" : "MISSING"}` + ); + } catch (error) { + logger.error(`Error checking ${redistCheck.name}:`, error); + missing.push(redistCheck.name); + } + } + + const allInstalled = missing.length === 0; + + if (allInstalled) { + logger.log("All system redistributables are installed"); + } else { + logger.log("Missing system redistributables:", missing); + } + + return { allInstalled, missing }; } public static async downloadCommonRedist() { + logger.log("Starting download of redistributables to:", commonRedistPath); + if (!fs.existsSync(commonRedistPath)) { await fs.promises.mkdir(commonRedistPath, { recursive: true }); + logger.log("Created CommonRedist directory"); } for (const redist of this.redistributables) { const filePath = path.join(commonRedistPath, redist); if (fs.existsSync(filePath) && redist !== "install.bat") { + logger.log(`Skipping ${redist} - already exists`); continue; } + logger.log(`Downloading ${redist}...`); + const response = await axios.get( `https://github.com/hydralauncher/hydra-common-redist/raw/refs/heads/main/${redist}`, { @@ -103,6 +249,171 @@ export class CommonRedistManager { ); await fs.promises.writeFile(filePath, response.data); + logger.log(`Downloaded ${redist} successfully`); + } + + logger.log("All redistributables downloaded"); + } + + public static async hasPreflightPassed(): Promise { + try { + const passed = await db.get( + levelKeys.commonRedistPassed, + { valueEncoding: "json" } + ); + return passed === true; + } catch { + return false; } } + + public static async markPreflightPassed(): Promise { + await db.put(levelKeys.commonRedistPassed, true, { valueEncoding: "json" }); + logger.log("Common redistributables preflight marked as passed"); + } + + public static async resetPreflightStatus(): Promise { + try { + await db.del(levelKeys.commonRedistPassed); + logger.log("Common redistributables preflight status reset"); + } catch { + // Key might not exist, ignore + } + } + + /** + * Run preflight check for game launch + * Returns true if preflight succeeded, false if it failed + * Note: Game launch proceeds regardless of return value + */ + public static async runPreflight(): Promise { + logger.log("Running common redistributables preflight check"); + + // Send initial status to game launcher + this.sendPreflightProgress("checking", null); + + // First, ensure installer files are downloaded (quick check) + const canInstall = await this.canInstallCommonRedist(); + + if (!canInstall) { + logger.log("Installer files not downloaded, downloading now"); + this.sendPreflightProgress("downloading", null); + + try { + await this.downloadCommonRedist(); + logger.log("Installer files downloaded successfully"); + } catch (error) { + logger.error("Failed to download installer files", error); + this.sendPreflightProgress("error", "download_failed"); + return false; + } + } + + // Always check if redistributables are actually installed on the system + const systemCheck = this.checkSystemRedistributables(); + + if (systemCheck.allInstalled) { + logger.log("All redistributables are installed on the system"); + this.sendPreflightProgress("complete", null); + return true; + } + + logger.log( + "Some redistributables are missing on the system:", + systemCheck.missing + ); + + // Install redistributables + logger.log("Installing common redistributables"); + this.sendPreflightProgress("installing", null); + + try { + const success = await this.installCommonRedistForPreflight(); + + if (success) { + this.sendPreflightProgress("complete", null); + logger.log("Preflight completed successfully"); + return true; + } + + logger.error("Preflight installation did not complete successfully"); + this.sendPreflightProgress("error", "install_failed"); + return false; + } catch (error) { + logger.error("Preflight installation error", error); + this.sendPreflightProgress("error", "install_failed"); + return false; + } + } + + private static sendPreflightProgress( + status: "checking" | "downloading" | "installing" | "complete" | "error", + detail: string | null + ) { + WindowManager.gameLauncherWindow?.webContents.send("preflight-progress", { + status, + detail, + }); + } + + /** + * Install common redistributables with preflight-specific handling + * Returns a promise that resolves when installation completes + */ + private static async installCommonRedistForPreflight(): Promise { + return new Promise((resolve) => { + const abortController = new AbortController(); + const timeout = setTimeout(() => { + abortController.abort(); + logger.error("Preflight installation timed out"); + resolve(false); + }, this.installationTimeout); + + const installationCompleteMessage = "Installation complete"; + + if (!fs.existsSync(this.installationLog)) { + fs.writeFileSync(this.installationLog, ""); + } + + fs.watch(this.installationLog, { signal: abortController.signal }, () => { + fs.readFile(this.installationLog, "utf-8", (err, data) => { + if (err) { + logger.error("Error reading preflight log file:", err); + return; + } + + const tail = data.split("\n").at(-2)?.trim(); + + if (tail) { + this.sendPreflightProgress("installing", tail); + } + + if (tail?.includes(installationCompleteMessage)) { + clearTimeout(timeout); + if (!abortController.signal.aborted) { + abortController.abort(); + } + resolve(true); + } + }); + }); + + cp.exec( + path.join(commonRedistPath, "install.bat"), + { + windowsHide: true, + }, + (error) => { + if (error) { + logger.error("Failed to run preflight install.bat", error); + clearTimeout(timeout); + if (!abortController.signal.aborted) { + abortController.abort(); + } + resolve(false); + } + } + ); + }); + } } diff --git a/src/preload/index.ts b/src/preload/index.ts index 82a390d9..5bcbb940 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -505,6 +505,18 @@ contextBridge.exposeInMainWorld("electron", { ipcRenderer.on("common-redist-progress", listener); return () => ipcRenderer.removeListener("common-redist-progress", listener); }, + onPreflightProgress: ( + cb: (value: { status: string; detail: string | null }) => void + ) => { + const listener = ( + _event: Electron.IpcRendererEvent, + value: { status: string; detail: string | null } + ) => cb(value); + ipcRenderer.on("preflight-progress", listener); + return () => ipcRenderer.removeListener("preflight-progress", listener); + }, + resetCommonRedistPreflight: () => + ipcRenderer.invoke("resetCommonRedistPreflight"), checkForUpdates: () => ipcRenderer.invoke("checkForUpdates"), restartAndInstallUpdate: () => ipcRenderer.invoke("restartAndInstallUpdate"), diff --git a/src/renderer/src/declaration.d.ts b/src/renderer/src/declaration.d.ts index 079669de..d3d69aca 100644 --- a/src/renderer/src/declaration.d.ts +++ b/src/renderer/src/declaration.d.ts @@ -365,6 +365,10 @@ declare global { onCommonRedistProgress: ( cb: (value: { log: string; complete: boolean }) => void ) => () => Electron.IpcRenderer; + onPreflightProgress: ( + cb: (value: { status: string; detail: string | null }) => void + ) => () => Electron.IpcRenderer; + resetCommonRedistPreflight: () => Promise; saveTempFile: (fileName: string, fileData: Uint8Array) => Promise; deleteTempFile: (filePath: string) => Promise; platform: NodeJS.Platform; diff --git a/src/renderer/src/pages/game-launcher/game-launcher.scss b/src/renderer/src/pages/game-launcher/game-launcher.scss index 549e1b5e..8a5d7b89 100644 --- a/src/renderer/src/pages/game-launcher/game-launcher.scss +++ b/src/renderer/src/pages/game-launcher/game-launcher.scss @@ -136,6 +136,27 @@ margin: 0; display: flex; align-items: center; + gap: calc(globals.$spacing-unit / 2); + } + + &__spinner { + width: 14px; + height: 14px; + border: 2px solid transparent; + border-top-color: globals.$muted-color; + border-right-color: globals.$muted-color; + border-radius: 50%; + animation: spinner-rotate 0.8s cubic-bezier(0.4, 0, 0.2, 1) infinite; + flex-shrink: 0; + } + + @keyframes spinner-rotate { + from { + transform: rotate(0deg); + } + to { + transform: rotate(360deg); + } } &__dots { diff --git a/src/renderer/src/pages/game-launcher/game-launcher.tsx b/src/renderer/src/pages/game-launcher/game-launcher.tsx index 2a9fc870..63dc7e59 100644 --- a/src/renderer/src/pages/game-launcher/game-launcher.tsx +++ b/src/renderer/src/pages/game-launcher/game-launcher.tsx @@ -10,6 +10,14 @@ import { average } from "color.js"; import type { Game, GameShop, ShopAssets } from "@types"; import "./game-launcher.scss"; +type PreflightStatus = + | "idle" + | "checking" + | "downloading" + | "installing" + | "complete" + | "error"; + export default function GameLauncher() { const { t } = useTranslation("game_launcher"); const [searchParams] = useSearchParams(); @@ -26,6 +34,10 @@ export default function GameLauncher() { const [colorError, setColorError] = useState(false); const [windowShown, setWindowShown] = useState(false); const [isMainWindowOpen, setIsMainWindowOpen] = useState(false); + const [preflightStatus, setPreflightStatus] = + useState("idle"); + const [preflightDetail, setPreflightDetail] = useState(null); + const [preflightStarted, setPreflightStarted] = useState(false); const formatPlayTime = useCallback( (playTimeInMilliseconds = 0) => { @@ -58,14 +70,52 @@ export default function GameLauncher() { }, [shop, objectId]); useEffect(() => { - if (!windowShown) return; + if (!window.electron.onPreflightProgress) { + return; + } + + const unsubscribe = window.electron.onPreflightProgress( + ({ status, detail }) => { + setPreflightStarted(true); + setPreflightStatus(status as PreflightStatus); + setPreflightDetail(detail); + } + ); + + return () => unsubscribe(); + }, []); + + // Auto-close timer - only starts after preflight completes + // Preflight is "done" when: it completed/errored, OR it never started (non-Windows or no preflight needed) + const isPreflightDone = + preflightStatus === "complete" || preflightStatus === "error"; + + // If preflight hasn't started after 3 seconds, assume it's not running (e.g., non-Windows) + const [preflightTimeout, setPreflightTimeout] = useState(false); + + useEffect(() => { + if (preflightStarted) return; + + const timer = setTimeout(() => { + setPreflightTimeout(true); + }, 3000); + + return () => clearTimeout(timer); + }, [preflightStarted]); + + const canAutoClose = + isPreflightDone || (!preflightStarted && preflightTimeout); + + useEffect(() => { + // Don't start timer until window is shown AND preflight is done + if (!windowShown || !canAutoClose) return; const timer = setTimeout(() => { window.electron.closeGameLauncherWindow(); }, 5000); return () => clearTimeout(timer); - }, [windowShown]); + }, [windowShown, canAutoClose]); const handleOpenHydra = () => { window.electron.openMainWindow(); @@ -95,6 +145,29 @@ export default function GameLauncher() { } }, []); + const getStatusMessage = useCallback(() => { + switch (preflightStatus) { + case "checking": + return t("preflight_checking"); + case "downloading": + return t("preflight_downloading"); + case "installing": + return preflightDetail + ? t("preflight_installing_detail", { detail: preflightDetail }) + : t("preflight_installing"); + case "complete": + case "error": + case "idle": + default: + return t("launching_base"); + } + }, [preflightStatus, preflightDetail, t]); + + const isPreflightRunning = + preflightStatus === "checking" || + preflightStatus === "downloading" || + preflightStatus === "installing"; + useEffect(() => { if (coverImage && !colorExtracted) { extractAccentColor(coverImage); @@ -174,7 +247,10 @@ export default function GameLauncher() {

{gameTitle}

- {t("launching_base")} + {isPreflightRunning && ( + + )} + {getStatusMessage()}