mirror of
https://github.com/hydralauncher/hydra.git
synced 2026-01-30 14:21:04 +00:00
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.
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
};
|
||||
|
||||
8
src/main/events/misc/reset-common-redist-preflight.ts
Normal file
8
src/main/events/misc/reset-common-redist-preflight.ts
Normal file
@@ -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);
|
||||
@@ -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<void> => {
|
||||
|
||||
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) {
|
||||
|
||||
@@ -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
|
||||
};
|
||||
|
||||
@@ -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<boolean> {
|
||||
try {
|
||||
const passed = await db.get<string, boolean>(
|
||||
levelKeys.commonRedistPassed,
|
||||
{ valueEncoding: "json" }
|
||||
);
|
||||
return passed === true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public static async markPreflightPassed(): Promise<void> {
|
||||
await db.put(levelKeys.commonRedistPassed, true, { valueEncoding: "json" });
|
||||
logger.log("Common redistributables preflight marked as passed");
|
||||
}
|
||||
|
||||
public static async resetPreflightStatus(): Promise<void> {
|
||||
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<boolean> {
|
||||
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<boolean> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"),
|
||||
|
||||
|
||||
4
src/renderer/src/declaration.d.ts
vendored
4
src/renderer/src/declaration.d.ts
vendored
@@ -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<void>;
|
||||
saveTempFile: (fileName: string, fileData: Uint8Array) => Promise<string>;
|
||||
deleteTempFile: (filePath: string) => Promise<void>;
|
||||
platform: NodeJS.Platform;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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<PreflightStatus>("idle");
|
||||
const [preflightDetail, setPreflightDetail] = useState<string | null>(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() {
|
||||
<h1 className="game-launcher__title">{gameTitle}</h1>
|
||||
|
||||
<p className="game-launcher__status">
|
||||
{t("launching_base")}
|
||||
{isPreflightRunning && (
|
||||
<span className="game-launcher__spinner" />
|
||||
)}
|
||||
{getStatusMessage()}
|
||||
<span className="game-launcher__dots" />
|
||||
</p>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user