diff --git a/src/main/events/helpers/find-game-root.ts b/src/main/events/helpers/find-game-root.ts new file mode 100644 index 00000000..2a097e55 --- /dev/null +++ b/src/main/events/helpers/find-game-root.ts @@ -0,0 +1,247 @@ +import path from "node:path"; +import fs from "node:fs"; + +const NESTED_EXECUTABLE_DIRS = new Set([ + "bin", + "bin32", + "bin64", + "binaries", + "win32", + "win64", + "x64", + "x86", + "game", + "runtime", + "engine", +]); + +const GAME_ROOT_INDICATORS = new Set([ + "data", + "assets", + "content", + "paks", + "pak", + "resources", + "localization", + "languages", + "saves", + "mods", + "dlc", + "music", + "sound", + "sounds", + "audio", + "videos", + "movies", + "cinematics", + "textures", + "shaders", + "configs", + "config", + "settings", + "plugins", + "native", + "managed", + "mono", + "dotnet", + "engine", + "launcher", +]); + +const UNITY_DATA_SUFFIX = "_data"; + +const GAME_DATA_EXTENSIONS = new Set([ + ".pak", + ".dat", + ".bundle", + ".assets", + ".forge", + ".arc", + ".pck", + ".vpk", + ".wad", + ".bsa", + ".ba2", + ".big", + ".cpk", + ".fsb", + ".bank", +]); + +const MAX_UPWARD_LEVELS = 3; + +const UNSAFE_ROOTS = new Set([ + "program files", + "program files (x86)", + "users", + "windows", + "system32", + "appdata", + "programdata", + "steamapps", + "common", + "desktop", + "documents", + "downloads", +]); + +interface DirectoryScore { + path: string; + score: number; + hasExecutable: boolean; +} + +const isNestedExeDir = (dirName: string): boolean => { + return NESTED_EXECUTABLE_DIRS.has(dirName.toLowerCase()); +}; + +const isUnsafePath = (dirPath: string): boolean => { + const normalized = dirPath.toLowerCase(); + const parts = normalized.split(path.sep); + const lastPart = parts.at(-1) ?? ""; + + if (UNSAFE_ROOTS.has(lastPart)) { + return true; + } + + const parsed = path.parse(dirPath); + return parsed.dir === parsed.root || dirPath === parsed.root; +}; + +const GAME_ROOT_FILES = new Set([ + "steam_api.dll", + "steam_api64.dll", + "version.txt", + "readme.txt", + "eula.txt", + "unins000.exe", + "uninstall.exe", +]); + +const scoreEntry = ( + entry: fs.Dirent +): { score: number; hasExecutable: boolean } => { + const nameLower = entry.name.toLowerCase(); + let score = 0; + let hasExecutable = false; + + if (entry.isDirectory()) { + if (GAME_ROOT_INDICATORS.has(nameLower)) score += 2; + if (nameLower.endsWith(UNITY_DATA_SUFFIX)) score += 3; + if (nameLower === "binaries" || nameLower === "content") score += 2; + } else if (entry.isFile()) { + if (nameLower.endsWith(".exe")) { + hasExecutable = true; + score += 1; + } + if (GAME_DATA_EXTENSIONS.has(path.extname(nameLower))) score += 2; + if (GAME_ROOT_FILES.has(nameLower)) score += 1; + } + + return { score, hasExecutable }; +}; + +const scoreDirectory = async (dirPath: string): Promise => { + try { + const entries = await fs.promises.readdir(dirPath, { withFileTypes: true }); + + let totalScore = 0; + let hasExecutable = false; + + for (const entry of entries) { + const result = scoreEntry(entry); + totalScore += result.score; + hasExecutable = hasExecutable || result.hasExecutable; + } + + return { path: dirPath, score: totalScore, hasExecutable }; + } catch { + return { path: dirPath, score: 0, hasExecutable: false }; + } +}; + +const collectCandidates = async (exeDir: string): Promise => { + const candidates: DirectoryScore[] = []; + let currentDir = exeDir; + let levelsUp = 0; + + while (levelsUp <= MAX_UPWARD_LEVELS) { + if (isUnsafePath(currentDir)) break; + + const score = await scoreDirectory(currentDir); + candidates.push(score); + + const dirName = path.basename(currentDir); + + if (levelsUp === 0 && isNestedExeDir(dirName)) { + levelsUp++; + currentDir = path.dirname(currentDir); + continue; + } + + if (score.score >= 3 && score.hasExecutable) break; + + const parentDir = path.dirname(currentDir); + if (parentDir === currentDir) break; + + currentDir = parentDir; + levelsUp++; + } + + return candidates; +}; + +const selectBestCandidate = (candidates: DirectoryScore[]): DirectoryScore => { + let best = candidates[0]; + + for (const candidate of candidates) { + const isBetterWithExe = + candidate.score >= 3 && + candidate.hasExecutable && + (!best.hasExecutable || candidate.score > best.score); + + const isBetterWithoutExe = + !best.hasExecutable && candidate.score > best.score; + + if (isBetterWithExe || isBetterWithoutExe) { + best = candidate; + } + } + + return best; +}; + +const getFallbackPath = (exeDir: string): string => { + const exeDirName = path.basename(exeDir); + + if (isNestedExeDir(exeDirName)) { + const parentDir = path.dirname(exeDir); + if (!isUnsafePath(parentDir)) return parentDir; + } + + return exeDir; +}; + +export const findGameRootFromExe = async ( + exePath: string +): Promise => { + try { + const exeDir = path.dirname(exePath); + + if (isUnsafePath(exeDir)) return null; + + const candidates = await collectCandidates(exeDir); + + if (candidates.length === 0) return exeDir; + + const bestCandidate = selectBestCandidate(candidates); + + if (bestCandidate.score < 2) { + return getFallbackPath(exeDir); + } + + return bestCandidate.path; + } catch { + return null; + } +}; diff --git a/src/main/events/library/update-executable-path.ts b/src/main/events/library/update-executable-path.ts index 841ca3b9..d9a42689 100644 --- a/src/main/events/library/update-executable-path.ts +++ b/src/main/events/library/update-executable-path.ts @@ -1,9 +1,9 @@ -import path from "node:path"; - import { registerEvent } from "../register-event"; import { parseExecutablePath } from "../helpers/parse-executable-path"; import { getDirectorySize } from "../helpers/get-directory-size"; +import { findGameRootFromExe } from "../helpers/find-game-root"; import { gamesSublevel, levelKeys } from "@main/level"; +import { logger } from "@main/services"; import type { GameShop } from "@types"; const updateExecutablePath = async ( @@ -32,17 +32,28 @@ const updateExecutablePath = async ( // Calculate size in background and update later if (parsedPath) { - const executableDir = path.dirname(parsedPath); + findGameRootFromExe(parsedPath) + .then(async (gameRoot) => { + if (!gameRoot) { + logger.warn(`Could not determine game root for: ${parsedPath}`); + return; + } - getDirectorySize(executableDir).then(async (installedSizeInBytes) => { - const currentGame = await gamesSublevel.get(gameKey); - if (!currentGame) return; + logger.log(`Game root detected: ${gameRoot} (exe: ${parsedPath})`); - await gamesSublevel.put(gameKey, { - ...currentGame, - installedSizeInBytes, + const installedSizeInBytes = await getDirectorySize(gameRoot); + + const currentGame = await gamesSublevel.get(gameKey); + if (!currentGame) return; + + await gamesSublevel.put(gameKey, { + ...currentGame, + installedSizeInBytes, + }); + }) + .catch((err) => { + logger.error(`Failed to calculate game size: ${err}`); }); - }); } };