diff --git a/src/locales/en/translation.json b/src/locales/en/translation.json index 022696be..452cc86b 100755 --- a/src/locales/en/translation.json +++ b/src/locales/en/translation.json @@ -200,7 +200,6 @@ "downloader_not_configured": "Available but not configured", "downloader_offline": "Link is offline", "downloader_not_available": "Not available", - "recommended": "Recommended", "go_to_settings": "Go to Settings", "select_executable": "Select", "no_executable_selected": "No executable selected", @@ -794,7 +793,10 @@ "manual_playtime_tooltip": "This playtime has been manually updated", "all_games": "All Games", "recently_played": "Recently Played", - "favorites": "Favorites" + "favorites": "Favorites", + "disk_usage": "Disk usage", + "disk_usage_tooltip": "Installed size on disk", + "installer_size_tooltip": "Installer size" }, "achievement": { "achievement_unlocked": "Achievement unlocked", 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/helpers/get-directory-size.ts b/src/main/events/helpers/get-directory-size.ts new file mode 100644 index 00000000..5169bc2c --- /dev/null +++ b/src/main/events/helpers/get-directory-size.ts @@ -0,0 +1,39 @@ +import path from "node:path"; +import fs from "node:fs"; + +export const getDirectorySize = async (dirPath: string): Promise => { + let totalSize = 0; + + try { + const stat = await fs.promises.stat(dirPath); + + if (stat.isFile()) { + return stat.size; + } + + if (!stat.isDirectory()) { + return 0; + } + + const entries = await fs.promises.readdir(dirPath, { withFileTypes: true }); + + for (const entry of entries) { + const fullPath = path.join(dirPath, entry.name); + + try { + if (entry.isDirectory()) { + totalSize += await getDirectorySize(fullPath); + } else if (entry.isFile()) { + const fileStat = await fs.promises.stat(fullPath); + totalSize += fileStat.size; + } + } catch { + // Skip files that can't be accessed + } + } + } catch { + // Path doesn't exist or can't be read + } + + return totalSize; +}; diff --git a/src/main/events/library/delete-archive.ts b/src/main/events/library/delete-archive.ts index 9cf64a63..10843505 100644 --- a/src/main/events/library/delete-archive.ts +++ b/src/main/events/library/delete-archive.ts @@ -1,7 +1,9 @@ +import path from "node:path"; import fs from "node:fs"; import { registerEvent } from "../register-event"; import { logger } from "@main/services"; +import { downloadsSublevel, gamesSublevel, levelKeys } from "@main/level"; const deleteArchive = async ( _event: Electron.IpcMainInvokeEvent, @@ -11,8 +13,33 @@ const deleteArchive = async ( if (fs.existsSync(filePath)) { await fs.promises.unlink(filePath); logger.info(`Deleted archive: ${filePath}`); - return true; } + + // Find the game that has this archive and clear installer size + const normalizedPath = path.normalize(filePath); + const downloads = await downloadsSublevel.values().all(); + + for (const download of downloads) { + if (!download.folderName) continue; + + const downloadPath = path.normalize( + path.join(download.downloadPath, download.folderName) + ); + + if (downloadPath === normalizedPath) { + const gameKey = levelKeys.game(download.shop, download.objectId); + const game = await gamesSublevel.get(gameKey); + + if (game) { + await gamesSublevel.put(gameKey, { + ...game, + installerSizeInBytes: null, + }); + } + break; + } + } + return true; } catch (err) { logger.error(`Failed to delete archive: ${filePath}`, err); diff --git a/src/main/events/library/delete-game-folder.ts b/src/main/events/library/delete-game-folder.ts index 1bfa36e0..9cd458d6 100644 --- a/src/main/events/library/delete-game-folder.ts +++ b/src/main/events/library/delete-game-folder.ts @@ -5,15 +5,15 @@ import { getDownloadsPath } from "../helpers/get-downloads-path"; import { logger } from "@main/services"; import { registerEvent } from "../register-event"; import { GameShop } from "@types"; -import { downloadsSublevel, levelKeys } from "@main/level"; +import { downloadsSublevel, gamesSublevel, levelKeys } from "@main/level"; const deleteGameFolder = async ( _event: Electron.IpcMainInvokeEvent, shop: GameShop, objectId: string ): Promise => { - const downloadKey = levelKeys.game(shop, objectId); - const download = await downloadsSublevel.get(downloadKey); + const gameKey = levelKeys.game(shop, objectId); + const download = await downloadsSublevel.get(gameKey); if (!download) return; @@ -52,7 +52,16 @@ const deleteGameFolder = async ( await deleteFile(metaPath); } - await downloadsSublevel.del(downloadKey); + await downloadsSublevel.del(gameKey); + + // Clear installer size from game record + const game = await gamesSublevel.get(gameKey); + if (game) { + await gamesSublevel.put(gameKey, { + ...game, + installerSizeInBytes: null, + }); + } }; registerEvent("deleteGameFolder", deleteGameFolder); diff --git a/src/main/events/library/get-library.ts b/src/main/events/library/get-library.ts index 13a21422..b06ef88c 100644 --- a/src/main/events/library/get-library.ts +++ b/src/main/events/library/get-library.ts @@ -1,3 +1,6 @@ +import path from "node:path"; +import fs from "node:fs"; + import type { LibraryGame } from "@types"; import { registerEvent } from "../register-event"; import { @@ -28,9 +31,40 @@ const getLibrary = async (): Promise => { achievements?.unlockedAchievements?.length ?? 0; } + // Verify installer still exists, clear if deleted externally + let installerSizeInBytes = game.installerSizeInBytes; + if (installerSizeInBytes && download?.folderName) { + const installerPath = path.join( + download.downloadPath, + download.folderName + ); + + if (!fs.existsSync(installerPath)) { + installerSizeInBytes = null; + gamesSublevel.put(key, { ...game, installerSizeInBytes: null }); + } + } + + // Verify installed folder still exists, clear if deleted externally + let installedSizeInBytes = game.installedSizeInBytes; + if (installedSizeInBytes && game.executablePath) { + const executableDir = path.dirname(game.executablePath); + + if (!fs.existsSync(executableDir)) { + installedSizeInBytes = null; + gamesSublevel.put(key, { + ...game, + installerSizeInBytes, + installedSizeInBytes: null, + }); + } + } + return { id: key, ...game, + installerSizeInBytes, + installedSizeInBytes, download: download ?? null, unlockedAchievementCount, achievementCount: game.achievementCount ?? 0, diff --git a/src/main/events/library/update-executable-path.ts b/src/main/events/library/update-executable-path.ts index c60638d7..d9a42689 100644 --- a/src/main/events/library/update-executable-path.ts +++ b/src/main/events/library/update-executable-path.ts @@ -1,6 +1,9 @@ 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 ( @@ -18,12 +21,40 @@ const updateExecutablePath = async ( const game = await gamesSublevel.get(gameKey); if (!game) return; + // Update immediately without size so UI responds fast await gamesSublevel.put(gameKey, { ...game, executablePath: parsedPath, + installedSizeInBytes: parsedPath ? game.installedSizeInBytes : null, automaticCloudSync: executablePath === null ? false : game.automaticCloudSync, }); + + // Calculate size in background and update later + if (parsedPath) { + findGameRootFromExe(parsedPath) + .then(async (gameRoot) => { + if (!gameRoot) { + logger.warn(`Could not determine game root for: ${parsedPath}`); + return; + } + + logger.log(`Game root detected: ${gameRoot} (exe: ${parsedPath})`); + + 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}`); + }); + } }; registerEvent("updateExecutablePath", updateExecutablePath); diff --git a/src/main/services/download/download-manager.ts b/src/main/services/download/download-manager.ts index 71daf2d6..cc40889a 100644 --- a/src/main/services/download/download-manager.ts +++ b/src/main/services/download/download-manager.ts @@ -27,6 +27,7 @@ import { GameFilesManager } from "../game-files-manager"; import { HydraDebridClient } from "./hydra-debrid"; import { BuzzheavierApi, FuckingFastApi } from "@main/services/hosters"; import { JsHttpDownloader } from "./js-http-downloader"; +import { getDirectorySize } from "@main/events/helpers/get-directory-size"; export class DownloadManager { private static downloadingGameId: string | null = null; @@ -361,6 +362,24 @@ export class DownloadManager { userPreferences?.seedAfterDownloadComplete ); + // Calculate installer size in background + if (download.folderName) { + const installerPath = path.join( + download.downloadPath, + download.folderName + ); + + getDirectorySize(installerPath).then(async (installerSizeInBytes) => { + const currentGame = await gamesSublevel.get(gameId); + if (!currentGame) return; + + await gamesSublevel.put(gameId, { + ...currentGame, + installerSizeInBytes, + }); + }); + } + if (download.automaticallyExtract) { this.handleExtraction(download, game); } else { diff --git a/src/main/services/game-files-manager.ts b/src/main/services/game-files-manager.ts index 6b700986..2478de28 100644 --- a/src/main/services/game-files-manager.ts +++ b/src/main/services/game-files-manager.ts @@ -7,6 +7,7 @@ import { SevenZip, ExtractionProgress } from "./7zip"; import { WindowManager } from "./window-manager"; import { publishExtractionCompleteNotification } from "./notifications"; import { logger } from "./logger"; +import { getDirectorySize } from "@main/events/helpers/get-directory-size"; import { GameExecutables } from "./game-executables"; import createDesktopShortcut from "create-desktop-shortcuts"; import { app } from "electron"; @@ -146,6 +147,17 @@ export class GameFilesManager { extractionProgress: 0, }); + // Calculate and store the installed size + if (game && download.folderName) { + const gamePath = path.join(download.downloadPath, download.folderName); + const installedSizeInBytes = await getDirectorySize(gamePath); + + await gamesSublevel.put(this.gameKey, { + ...game, + installedSizeInBytes, + }); + } + WindowManager.mainWindow?.webContents.send( "on-extraction-complete", this.shop, diff --git a/src/renderer/src/pages/library/library-game-card-large.scss b/src/renderer/src/pages/library/library-game-card-large.scss index 8ac59112..094a4cc0 100644 --- a/src/renderer/src/pages/library/library-game-card-large.scss +++ b/src/renderer/src/pages/library/library-game-card-large.scss @@ -84,6 +84,45 @@ gap: calc(globals.$spacing-unit); } + &__size-badges { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 4px; + background: rgba(0, 0, 0, 0.4); + backdrop-filter: blur(8px); + -webkit-backdrop-filter: blur(8px); + border: solid 1px rgba(255, 255, 255, 0.15); + border-radius: 4px; + padding: 6px 12px; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2); + min-height: 28px; + box-sizing: border-box; + } + + &__size-bar { + display: flex; + align-items: center; + gap: 6px; + color: rgba(255, 255, 255, 0.95); + } + + &__size-bar-line { + height: 4px; + border-radius: 2px; + transition: width 0.3s ease; + background: linear-gradient( + 90deg, + rgba(255, 255, 255, 0.5), + rgba(255, 255, 255, 0.8) + ); + } + + &__size-bar-text { + font-size: 12px; + font-weight: 500; + } + &__logo-container { flex: 1; display: flex; diff --git a/src/renderer/src/pages/library/library-game-card-large.tsx b/src/renderer/src/pages/library/library-game-card-large.tsx index dd998c59..2a0b7cea 100644 --- a/src/renderer/src/pages/library/library-game-card-large.tsx +++ b/src/renderer/src/pages/library/library-game-card-large.tsx @@ -1,7 +1,15 @@ import { LibraryGame } from "@types"; import { useGameCard } from "@renderer/hooks"; -import { ClockIcon, AlertFillIcon, TrophyIcon } from "@primer/octicons-react"; +import { formatBytes } from "@shared"; +import { + ClockIcon, + AlertFillIcon, + TrophyIcon, + DatabaseIcon, + FileZipIcon, +} from "@primer/octicons-react"; import { memo, useEffect, useMemo, useState } from "react"; +import { useTranslation } from "react-i18next"; import "./library-game-card-large.scss"; interface LibraryGameCardLargeProps { @@ -30,9 +38,53 @@ export const LibraryGameCardLarge = memo(function LibraryGameCardLarge({ game, onContextMenu, }: Readonly) { + const { t } = useTranslation("library"); const { formatPlayTime, handleCardClick, handleContextMenuClick } = useGameCard(game, onContextMenu); + const sizeBars = useMemo(() => { + const items: { + type: "installer" | "installed"; + bytes: number; + formatted: string; + icon: typeof FileZipIcon; + tooltipKey: string; + }[] = []; + + if (game.installerSizeInBytes) { + items.push({ + type: "installer", + bytes: game.installerSizeInBytes, + formatted: formatBytes(game.installerSizeInBytes), + icon: FileZipIcon, + tooltipKey: "installer_size_tooltip", + }); + } + + if (game.installedSizeInBytes) { + items.push({ + type: "installed", + bytes: game.installedSizeInBytes, + formatted: formatBytes(game.installedSizeInBytes), + icon: DatabaseIcon, + tooltipKey: "disk_usage_tooltip", + }); + } + + if (items.length === 0) return []; + + // Sort by size descending (larger first) + items.sort((a, b) => b.bytes - a.bytes); + + // Calculate proportional widths in pixels (max bar is 80px) + const maxBytes = items[0].bytes; + const maxWidth = 80; + return items.map((item) => ({ + ...item, + widthPx: Math.round((item.bytes / maxBytes) * maxWidth), + })); + }, [game.installerSizeInBytes, game.installedSizeInBytes]); + const backgroundImage = useMemo( () => getImageWithCustomPriority( @@ -94,6 +146,27 @@ export const LibraryGameCardLarge = memo(function LibraryGameCardLarge({
+ {sizeBars.length > 0 && ( +
+ {sizeBars.map((bar) => ( +
+ +
+ + {bar.formatted} + +
+ ))} +
+ )} +
{game.hasManuallyUpdatedPlaytime ? (