mirror of
https://github.com/hydralauncher/hydra.git
synced 2026-01-23 10:51:02 +00:00
Merge branch 'main' into feat/LBX-452
This commit is contained in:
@@ -24,6 +24,7 @@ import "./remove-game-from-favorites";
|
||||
import "./remove-game-from-library";
|
||||
import "./remove-game";
|
||||
import "./reset-game-achievements";
|
||||
import "./scan-installed-games";
|
||||
import "./select-game-wine-prefix";
|
||||
import "./toggle-automatic-cloud-sync";
|
||||
import "./toggle-game-pin";
|
||||
|
||||
143
src/main/events/library/scan-installed-games.ts
Normal file
143
src/main/events/library/scan-installed-games.ts
Normal file
@@ -0,0 +1,143 @@
|
||||
import path from "node:path";
|
||||
import fs from "node:fs";
|
||||
import { t } from "i18next";
|
||||
import { registerEvent } from "../register-event";
|
||||
import { gamesSublevel } from "@main/level";
|
||||
import {
|
||||
GameExecutables,
|
||||
LocalNotificationManager,
|
||||
logger,
|
||||
WindowManager,
|
||||
} from "@main/services";
|
||||
|
||||
const SCAN_DIRECTORIES = [
|
||||
String.raw`C:\Games`,
|
||||
String.raw`D:\Games`,
|
||||
String.raw`C:\Program Files (x86)\Steam\steamapps\common`,
|
||||
String.raw`C:\Program Files\Steam\steamapps\common`,
|
||||
String.raw`C:\Program Files (x86)\DODI-Repacks`,
|
||||
];
|
||||
|
||||
interface FoundGame {
|
||||
title: string;
|
||||
executablePath: string;
|
||||
}
|
||||
|
||||
interface ScanResult {
|
||||
foundGames: FoundGame[];
|
||||
total: number;
|
||||
}
|
||||
|
||||
async function searchInDirectories(
|
||||
executableNames: Set<string>
|
||||
): Promise<string | null> {
|
||||
for (const scanDir of SCAN_DIRECTORIES) {
|
||||
if (!fs.existsSync(scanDir)) continue;
|
||||
|
||||
const foundPath = await findExecutableInFolder(scanDir, executableNames);
|
||||
if (foundPath) return foundPath;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function publishScanNotification(foundCount: number): Promise<void> {
|
||||
const hasFoundGames = foundCount > 0;
|
||||
|
||||
await LocalNotificationManager.createNotification(
|
||||
"SCAN_GAMES_COMPLETE",
|
||||
t(
|
||||
hasFoundGames
|
||||
? "scan_games_complete_title"
|
||||
: "scan_games_no_results_title",
|
||||
{ ns: "notifications" }
|
||||
),
|
||||
t(
|
||||
hasFoundGames
|
||||
? "scan_games_complete_description"
|
||||
: "scan_games_no_results_description",
|
||||
{ ns: "notifications", count: foundCount }
|
||||
),
|
||||
{ url: "/library?openScanModal=true" }
|
||||
);
|
||||
}
|
||||
|
||||
const scanInstalledGames = async (
|
||||
_event: Electron.IpcMainInvokeEvent
|
||||
): Promise<ScanResult> => {
|
||||
const games = await gamesSublevel
|
||||
.iterator()
|
||||
.all()
|
||||
.then((results) =>
|
||||
results
|
||||
.filter(
|
||||
([_key, game]) => game.isDeleted === false && game.shop !== "custom"
|
||||
)
|
||||
.map(([key, game]) => ({ key, game }))
|
||||
);
|
||||
|
||||
const foundGames: FoundGame[] = [];
|
||||
const gamesToScan = games.filter((g) => !g.game.executablePath);
|
||||
|
||||
for (const { key, game } of gamesToScan) {
|
||||
const executableNames = GameExecutables.getExecutablesForGame(
|
||||
game.objectId
|
||||
);
|
||||
|
||||
if (!executableNames || executableNames.length === 0) continue;
|
||||
|
||||
const normalizedNames = new Set(
|
||||
executableNames.map((name) => name.toLowerCase())
|
||||
);
|
||||
|
||||
const foundPath = await searchInDirectories(normalizedNames);
|
||||
|
||||
if (foundPath) {
|
||||
await gamesSublevel.put(key, { ...game, executablePath: foundPath });
|
||||
|
||||
logger.info(
|
||||
`[ScanInstalledGames] Found executable for ${game.objectId}: ${foundPath}`
|
||||
);
|
||||
|
||||
foundGames.push({ title: game.title, executablePath: foundPath });
|
||||
}
|
||||
}
|
||||
|
||||
WindowManager.mainWindow?.webContents.send("on-library-batch-complete");
|
||||
await publishScanNotification(foundGames.length);
|
||||
|
||||
return { foundGames, total: gamesToScan.length };
|
||||
};
|
||||
|
||||
async function findExecutableInFolder(
|
||||
folderPath: string,
|
||||
executableNames: Set<string>
|
||||
): Promise<string | null> {
|
||||
try {
|
||||
const entries = await fs.promises.readdir(folderPath, {
|
||||
withFileTypes: true,
|
||||
recursive: true,
|
||||
});
|
||||
|
||||
for (const entry of entries) {
|
||||
if (!entry.isFile()) continue;
|
||||
|
||||
const fileName = entry.name.toLowerCase();
|
||||
|
||||
if (executableNames.has(fileName)) {
|
||||
const parentPath =
|
||||
"parentPath" in entry ? entry.parentPath : folderPath;
|
||||
|
||||
return path.join(parentPath, entry.name);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error(
|
||||
`[ScanInstalledGames] Error reading folder ${folderPath}:`,
|
||||
err
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
registerEvent("scanInstalledGames", scanInstalledGames);
|
||||
@@ -362,6 +362,11 @@ export class DownloadManager {
|
||||
|
||||
if (download.automaticallyExtract) {
|
||||
this.handleExtraction(download, game);
|
||||
} else {
|
||||
// For downloads without extraction (e.g., torrents with ready-to-play files),
|
||||
// search for executable in the download folder
|
||||
const gameFilesManager = new GameFilesManager(game.shop, game.objectId);
|
||||
gameFilesManager.searchAndBindExecutable();
|
||||
}
|
||||
|
||||
await this.processNextQueuedDownload();
|
||||
|
||||
13
src/main/services/game-executables.ts
Normal file
13
src/main/services/game-executables.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { gameExecutables } from "./process-watcher";
|
||||
|
||||
export class GameExecutables {
|
||||
static getExecutablesForGame(objectId: string): string[] | null {
|
||||
const executables = gameExecutables[objectId];
|
||||
|
||||
if (!executables || executables.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return executables.map((exe) => exe.exe);
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import { SevenZip, ExtractionProgress } from "./7zip";
|
||||
import { WindowManager } from "./window-manager";
|
||||
import { publishExtractionCompleteNotification } from "./notifications";
|
||||
import { logger } from "./logger";
|
||||
import { GameExecutables } from "./game-executables";
|
||||
|
||||
const PROGRESS_THROTTLE_MS = 1000;
|
||||
|
||||
@@ -151,6 +152,100 @@ export class GameFilesManager {
|
||||
if (publishNotification && game) {
|
||||
publishExtractionCompleteNotification(game);
|
||||
}
|
||||
|
||||
await this.searchAndBindExecutable();
|
||||
}
|
||||
|
||||
async searchAndBindExecutable(): Promise<void> {
|
||||
try {
|
||||
const [download, game] = await Promise.all([
|
||||
downloadsSublevel.get(this.gameKey),
|
||||
gamesSublevel.get(this.gameKey),
|
||||
]);
|
||||
|
||||
if (!download || !game || game.executablePath) {
|
||||
return;
|
||||
}
|
||||
|
||||
const executableNames = GameExecutables.getExecutablesForGame(
|
||||
this.objectId
|
||||
);
|
||||
|
||||
if (!executableNames || executableNames.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!download.folderName) {
|
||||
return;
|
||||
}
|
||||
|
||||
const gameFolderPath = path.join(
|
||||
download.downloadPath,
|
||||
download.folderName
|
||||
);
|
||||
|
||||
if (!fs.existsSync(gameFolderPath)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const foundExePath = await this.findExecutableInFolder(
|
||||
gameFolderPath,
|
||||
executableNames
|
||||
);
|
||||
|
||||
if (foundExePath) {
|
||||
logger.info(
|
||||
`[GameFilesManager] Auto-detected executable for ${this.objectId}: ${foundExePath}`
|
||||
);
|
||||
|
||||
await gamesSublevel.put(this.gameKey, {
|
||||
...game,
|
||||
executablePath: foundExePath,
|
||||
});
|
||||
|
||||
WindowManager.mainWindow?.webContents.send("on-library-batch-complete");
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error(
|
||||
`[GameFilesManager] Error searching for executable: ${this.objectId}`,
|
||||
err
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private async findExecutableInFolder(
|
||||
folderPath: string,
|
||||
executableNames: string[]
|
||||
): Promise<string | null> {
|
||||
const normalizedNames = new Set(
|
||||
executableNames.map((name) => name.toLowerCase())
|
||||
);
|
||||
|
||||
try {
|
||||
const entries = await fs.promises.readdir(folderPath, {
|
||||
withFileTypes: true,
|
||||
recursive: true,
|
||||
});
|
||||
|
||||
for (const entry of entries) {
|
||||
if (!entry.isFile()) continue;
|
||||
|
||||
const fileName = entry.name.toLowerCase();
|
||||
|
||||
if (normalizedNames.has(fileName)) {
|
||||
const parentPath =
|
||||
"parentPath" in entry
|
||||
? entry.parentPath
|
||||
: (entry as unknown as { path?: string }).path || folderPath;
|
||||
|
||||
return path.join(parentPath, entry.name);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Silently fail if folder cannot be read
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
async extractDownloadedFile() {
|
||||
|
||||
@@ -10,6 +10,7 @@ export * from "./ludusavi";
|
||||
export * from "./cloud-sync";
|
||||
export * from "./7zip";
|
||||
export * from "./game-files-manager";
|
||||
export * from "./game-executables";
|
||||
export * from "./common-redist-manager";
|
||||
export * from "./aria2";
|
||||
export * from "./ws";
|
||||
|
||||
@@ -69,7 +69,7 @@ const getGameExecutables = async () => {
|
||||
return gameExecutables;
|
||||
};
|
||||
|
||||
const gameExecutables = await getGameExecutables();
|
||||
export const gameExecutables = await getGameExecutables();
|
||||
|
||||
const findGamePathByProcess = async (
|
||||
processMap: Map<string, Set<string>>,
|
||||
|
||||
Reference in New Issue
Block a user