From f9678ece1b8cd748ff81c268c06c10edd40c5113 Mon Sep 17 00:00:00 2001 From: Moyasee Date: Thu, 15 Jan 2026 01:51:36 +0200 Subject: [PATCH] refactor: remove game disk usage functionality and update related components --- src/locales/en/translation.json | 3 +- .../events/hardware/get-game-disk-usage.ts | 145 ------------------ src/main/events/hardware/index.ts | 1 - src/main/events/helpers/get-directory-size.ts | 39 +++++ src/main/events/library/delete-archive.ts | 29 +++- src/main/events/library/delete-game-folder.ts | 17 +- src/main/events/library/get-library.ts | 34 ++++ .../events/library/update-executable-path.ts | 20 +++ .../services/download/download-manager.ts | 19 +++ src/main/services/game-files-manager.ts | 12 ++ src/preload/index.ts | 2 - src/renderer/src/declaration.d.ts | 7 - src/renderer/src/hooks/index.ts | 1 - src/renderer/src/hooks/use-game-disk-usage.ts | 63 -------- .../library/library-game-card-large.scss | 58 ++++--- .../pages/library/library-game-card-large.tsx | 77 ++++++++-- src/types/level.types.ts | 2 + 17 files changed, 271 insertions(+), 258 deletions(-) delete mode 100644 src/main/events/hardware/get-game-disk-usage.ts create mode 100644 src/main/events/helpers/get-directory-size.ts delete mode 100644 src/renderer/src/hooks/use-game-disk-usage.ts diff --git a/src/locales/en/translation.json b/src/locales/en/translation.json index e22a5026..f10c708a 100755 --- a/src/locales/en/translation.json +++ b/src/locales/en/translation.json @@ -775,7 +775,8 @@ "recently_played": "Recently Played", "favorites": "Favorites", "disk_usage": "Disk usage", - "disk_usage_tooltip": "Installed size on disk" + "disk_usage_tooltip": "Installed size on disk", + "installer_size_tooltip": "Installer size" }, "achievement": { "achievement_unlocked": "Achievement unlocked", diff --git a/src/main/events/hardware/get-game-disk-usage.ts b/src/main/events/hardware/get-game-disk-usage.ts deleted file mode 100644 index bbab6b38..00000000 --- a/src/main/events/hardware/get-game-disk-usage.ts +++ /dev/null @@ -1,145 +0,0 @@ -import path from "node:path"; -import fs from "node:fs"; - -import { registerEvent } from "../register-event"; -import { downloadsSublevel, gamesSublevel, levelKeys } from "@main/level"; -import type { GameShop, Game, Download } from "@types"; -import { logger } from "@main/services"; - -// Directories that should never be scanned (too large or system-critical) -const UNSAFE_DIRECTORIES = [ - "C:\\", - "C:\\Users", - "C:\\Program Files", - "C:\\Program Files (x86)", - "C:\\Windows", - "C:\\ProgramData", - "/", - "/home", - "/usr", - "/var", - "/opt", -]; - -const isUnsafeDirectory = (dirPath: string): boolean => { - const normalized = path.normalize(dirPath).replace(/[\\/]+$/, ""); - return UNSAFE_DIRECTORIES.some( - (unsafe) => normalized.toLowerCase() === unsafe.toLowerCase() - ); -}; - -const getDownloadsPath = async (): Promise => { - const { app } = await import("electron"); - return app.getPath("downloads"); -}; - -const getDirectorySize = async (dirPath: string): Promise => { - let totalSize = 0; - - try { - 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 stat = await fs.promises.stat(fullPath); - totalSize += stat.size; - } - } catch { - // Skip files that can't be accessed - } - } - } catch { - // Directory can't be read - } - - return totalSize; -}; - -const getGameDiskUsage = async ( - _event: Electron.IpcMainInvokeEvent, - shop: GameShop, - objectId: string -) => { - const gameKey = levelKeys.game(shop, objectId); - - let game: Game | undefined; - let download: Download | undefined; - - try { - game = await gamesSublevel.get(gameKey); - } catch { - // Game not found - } - - try { - download = await downloadsSublevel.get(gameKey); - } catch { - // Download not found - } - - let installerSize: number | null = null; - let installedSize: number | null = null; - - // Priority 1: Use Hydra-managed download folder - if (download?.folderName) { - const downloadsPath = download.downloadPath ?? (await getDownloadsPath()); - const gamePath = path.join(downloadsPath, download.folderName); - - try { - if (fs.existsSync(gamePath)) { - const stat = await fs.promises.stat(gamePath); - - if (stat.isDirectory()) { - installedSize = await getDirectorySize(gamePath); - } else if (stat.isFile()) { - installerSize = stat.size; - } - } - } catch (err) { - logger.error("Error getting game path stats:", err); - } - } - - // Priority 2: Use executable directory (with safety check) - if (game?.executablePath && !installedSize) { - const executableDir = path.dirname(game.executablePath); - - if (!isUnsafeDirectory(executableDir)) { - try { - if (fs.existsSync(executableDir)) { - const stat = await fs.promises.stat(executableDir); - if (stat.isDirectory()) { - installedSize = await getDirectorySize(executableDir); - } - } - } catch (err) { - logger.error("Error getting executable directory stats:", err); - } - } - } - - // Priority 3: Wine prefix for Linux/macOS - if (game?.winePrefixPath && !installedSize) { - if (!isUnsafeDirectory(game.winePrefixPath)) { - try { - if (fs.existsSync(game.winePrefixPath)) { - const stat = await fs.promises.stat(game.winePrefixPath); - if (stat.isDirectory()) { - installedSize = await getDirectorySize(game.winePrefixPath); - } - } - } catch (err) { - logger.error("Error getting wine prefix stats:", err); - } - } - } - - return { installerSize, installedSize }; -}; - -registerEvent("getGameDiskUsage", getGameDiskUsage); diff --git a/src/main/events/hardware/index.ts b/src/main/events/hardware/index.ts index 488fad94..76823f51 100644 --- a/src/main/events/hardware/index.ts +++ b/src/main/events/hardware/index.ts @@ -1,3 +1,2 @@ import "./check-folder-write-permission"; import "./get-disk-free-space"; -import "./get-game-disk-usage"; 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 b9cef25b..5c3af90d 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?.folderName) return; @@ -49,7 +49,16 @@ const deleteGameFolder = async ( await deleteFile(folderPath, true); 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 9fb3416b..de2ffd52 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..841ca3b9 100644 --- a/src/main/events/library/update-executable-path.ts +++ b/src/main/events/library/update-executable-path.ts @@ -1,5 +1,8 @@ +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 { gamesSublevel, levelKeys } from "@main/level"; import type { GameShop } from "@types"; @@ -18,12 +21,29 @@ 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) { + const executableDir = path.dirname(parsedPath); + + getDirectorySize(executableDir).then(async (installedSizeInBytes) => { + const currentGame = await gamesSublevel.get(gameKey); + if (!currentGame) return; + + await gamesSublevel.put(gameKey, { + ...currentGame, + installedSizeInBytes, + }); + }); + } }; registerEvent("updateExecutablePath", updateExecutablePath); diff --git a/src/main/services/download/download-manager.ts b/src/main/services/download/download-manager.ts index 0383c2d3..8ba65a7d 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; @@ -360,6 +361,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); } diff --git a/src/main/services/game-files-manager.ts b/src/main/services/game-files-manager.ts index f3684a0a..ccb0006f 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"; const PROGRESS_THROTTLE_MS = 1000; @@ -142,6 +143,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/preload/index.ts b/src/preload/index.ts index 83dfb030..dd7497bf 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -299,8 +299,6 @@ contextBridge.exposeInMainWorld("electron", { ipcRenderer.invoke("getDiskFreeSpace", path), checkFolderWritePermission: (path: string) => ipcRenderer.invoke("checkFolderWritePermission", path), - getGameDiskUsage: (shop: GameShop, objectId: string) => - ipcRenderer.invoke("getGameDiskUsage", shop, objectId), /* Cloud save */ uploadSaveGame: ( diff --git a/src/renderer/src/declaration.d.ts b/src/renderer/src/declaration.d.ts index 7697df42..a2cc6ccf 100644 --- a/src/renderer/src/declaration.d.ts +++ b/src/renderer/src/declaration.d.ts @@ -238,13 +238,6 @@ declare global { /* Hardware */ getDiskFreeSpace: (path: string) => Promise; checkFolderWritePermission: (path: string) => Promise; - getGameDiskUsage: ( - shop: GameShop, - objectId: string - ) => Promise<{ - installerSize: number | null; - installedSize: number | null; - }>; /* Cloud save */ uploadSaveGame: ( diff --git a/src/renderer/src/hooks/index.ts b/src/renderer/src/hooks/index.ts index 112474b9..a1666ede 100644 --- a/src/renderer/src/hooks/index.ts +++ b/src/renderer/src/hooks/index.ts @@ -8,7 +8,6 @@ export * from "./use-format"; export * from "./use-feature"; export * from "./use-download-options-listener"; export * from "./use-game-card"; -export * from "./use-game-disk-usage"; export * from "./use-search-history"; export * from "./use-search-suggestions"; export * from "./use-hls-video"; diff --git a/src/renderer/src/hooks/use-game-disk-usage.ts b/src/renderer/src/hooks/use-game-disk-usage.ts deleted file mode 100644 index 5bd36251..00000000 --- a/src/renderer/src/hooks/use-game-disk-usage.ts +++ /dev/null @@ -1,63 +0,0 @@ -import { useCallback, useEffect, useState } from "react"; -import type { GameShop } from "@types"; -import { formatBytes } from "@renderer/utils/format-bytes"; -import { logger } from "@renderer/logger"; - -interface GameDiskUsageResult { - installerSize: string | null; - installedSize: string | null; - totalSize: string | null; - isLoading: boolean; - refetch: () => void; -} - -export function useGameDiskUsage( - shop: GameShop, - objectId: string -): GameDiskUsageResult { - const [installerSizeBytes, setInstallerSizeBytes] = useState( - null - ); - const [installedSizeBytes, setInstalledSizeBytes] = useState( - null - ); - const [isLoading, setIsLoading] = useState(true); - - const fetchDiskUsage = useCallback(async () => { - logger.log("useGameDiskUsage: fetching for", { shop, objectId }); - setIsLoading(true); - try { - const result = await window.electron.getGameDiskUsage(shop, objectId); - logger.log("useGameDiskUsage: got result", result); - setInstallerSizeBytes(result.installerSize); - setInstalledSizeBytes(result.installedSize); - } catch (err) { - logger.error("Failed to fetch disk usage:", err); - setInstallerSizeBytes(null); - setInstalledSizeBytes(null); - } finally { - setIsLoading(false); - } - }, [shop, objectId]); - - useEffect(() => { - fetchDiskUsage(); - }, [fetchDiskUsage]); - - const installerSize = - installerSizeBytes !== null ? formatBytes(installerSizeBytes) : null; - const installedSize = - installedSizeBytes !== null ? formatBytes(installedSizeBytes) : null; - - const totalBytes = - (installerSizeBytes ?? 0) + (installedSizeBytes ?? 0) || null; - const totalSize = totalBytes !== null ? formatBytes(totalBytes) : null; - - return { - installerSize, - installedSize, - totalSize, - isLoading, - refetch: fetchDiskUsage, - }; -} 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 1a98be72..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; @@ -140,25 +179,6 @@ font-weight: 500; } - &__disk-usage { - background: rgba(0, 0, 0, 0.4); - backdrop-filter: blur(8px); - -webkit-backdrop-filter: blur(8px); - color: rgba(255, 255, 255, 0.95); - border: solid 1px rgba(255, 255, 255, 0.15); - border-radius: 4px; - display: flex; - align-items: center; - gap: 6px; - padding: 6px 12px; - box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2); - font-size: 12px; - } - - &__disk-usage-text { - font-weight: 500; - } - &__manual-playtime { color: globals.$warning-color; } 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 bdf0ed97..dafd7f3c 100644 --- a/src/renderer/src/pages/library/library-game-card-large.tsx +++ b/src/renderer/src/pages/library/library-game-card-large.tsx @@ -1,10 +1,12 @@ import { LibraryGame } from "@types"; -import { useGameCard, useGameDiskUsage } from "@renderer/hooks"; +import { useGameCard } from "@renderer/hooks"; +import { formatBytes } from "@renderer/utils"; import { ClockIcon, AlertFillIcon, TrophyIcon, DatabaseIcon, + FileZipIcon, } from "@primer/octicons-react"; import { memo, useEffect, useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; @@ -40,10 +42,48 @@ export const LibraryGameCardLarge = memo(function LibraryGameCardLarge({ const { formatPlayTime, handleCardClick, handleContextMenuClick } = useGameCard(game, onContextMenu); - const { installedSize, isLoading: isDiskUsageLoading } = useGameDiskUsage( - game.shop, - game.objectId - ); + 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( () => @@ -106,15 +146,24 @@ export const LibraryGameCardLarge = memo(function LibraryGameCardLarge({
- {installedSize && !isDiskUsageLoading && ( -
- - - {installedSize} - + {sizeBars.length > 0 && ( +
+ {sizeBars.map((bar) => ( +
+ +
+ + {bar.formatted} + +
+ ))}
)} diff --git a/src/types/level.types.ts b/src/types/level.types.ts index 0d76c3c7..73eb0918 100644 --- a/src/types/level.types.ts +++ b/src/types/level.types.ts @@ -64,6 +64,8 @@ export interface Game { automaticCloudSync?: boolean; hasManuallyUpdatedPlaytime?: boolean; newDownloadOptionsCount?: number; + installedSizeInBytes?: number | null; + installerSizeInBytes?: number | null; } export interface Download {