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:
Moyasee
2026-01-29 18:23:40 +02:00
parent ae0f1ce355
commit 85a4bdb7b1
11 changed files with 468 additions and 9 deletions

View File

@@ -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",

View File

@@ -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";

View File

@@ -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();
}
};

View 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);

View File

@@ -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) {

View File

@@ -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
};

View File

@@ -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);
}
}
);
});
}
}

View File

@@ -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"),

View File

@@ -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;

View File

@@ -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 {

View File

@@ -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>