Merge branch 'main' into feat/LBX-452

This commit is contained in:
Moyase
2026-01-21 18:09:16 +02:00
committed by GitHub
19 changed files with 672 additions and 7 deletions

View File

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

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

View File

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

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

View File

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

View File

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

View File

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