diff --git a/src/locales/en/translation.json b/src/locales/en/translation.json index 669808b8..e22a5026 100755 --- a/src/locales/en/translation.json +++ b/src/locales/en/translation.json @@ -189,7 +189,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", @@ -774,7 +773,9 @@ "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" }, "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 new file mode 100644 index 00000000..bbab6b38 --- /dev/null +++ b/src/main/events/hardware/get-game-disk-usage.ts @@ -0,0 +1,145 @@ +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 76823f51..488fad94 100644 --- a/src/main/events/hardware/index.ts +++ b/src/main/events/hardware/index.ts @@ -1,2 +1,3 @@ import "./check-folder-write-permission"; import "./get-disk-free-space"; +import "./get-game-disk-usage"; diff --git a/src/preload/index.ts b/src/preload/index.ts index dd7497bf..83dfb030 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -299,6 +299,8 @@ 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 a2cc6ccf..7697df42 100644 --- a/src/renderer/src/declaration.d.ts +++ b/src/renderer/src/declaration.d.ts @@ -238,6 +238,13 @@ 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 a1666ede..112474b9 100644 --- a/src/renderer/src/hooks/index.ts +++ b/src/renderer/src/hooks/index.ts @@ -8,6 +8,7 @@ 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 new file mode 100644 index 00000000..5bd36251 --- /dev/null +++ b/src/renderer/src/hooks/use-game-disk-usage.ts @@ -0,0 +1,63 @@ +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 8ac59112..1a98be72 100644 --- a/src/renderer/src/pages/library/library-game-card-large.scss +++ b/src/renderer/src/pages/library/library-game-card-large.scss @@ -140,6 +140,25 @@ 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 dd998c59..bdf0ed97 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,13 @@ import { LibraryGame } from "@types"; -import { useGameCard } from "@renderer/hooks"; -import { ClockIcon, AlertFillIcon, TrophyIcon } from "@primer/octicons-react"; +import { useGameCard, useGameDiskUsage } from "@renderer/hooks"; +import { + ClockIcon, + AlertFillIcon, + TrophyIcon, + DatabaseIcon, +} 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 +36,15 @@ export const LibraryGameCardLarge = memo(function LibraryGameCardLarge({ game, onContextMenu, }: Readonly) { + const { t } = useTranslation("library"); const { formatPlayTime, handleCardClick, handleContextMenuClick } = useGameCard(game, onContextMenu); + const { installedSize, isLoading: isDiskUsageLoading } = useGameDiskUsage( + game.shop, + game.objectId + ); + const backgroundImage = useMemo( () => getImageWithCustomPriority( @@ -94,6 +106,18 @@ export const LibraryGameCardLarge = memo(function LibraryGameCardLarge({
+ {installedSize && !isDiskUsageLoading && ( +
+ + + {installedSize} + +
+ )} +
{game.hasManuallyUpdatedPlaytime ? ( { + if (bytes === 0) return "0 B"; + + const k = 1024; + const dm = decimals < 0 ? 0 : decimals; + const sizes = ["B", "KB", "MB", "GB", "TB", "PB"]; + + const i = Math.floor(Math.log(bytes) / Math.log(k)); + const index = Math.min(i, sizes.length - 1); + + return `${parseFloat((bytes / Math.pow(k, index)).toFixed(dm))} ${sizes[index]}`; +}; diff --git a/src/renderer/src/utils/index.ts b/src/renderer/src/utils/index.ts new file mode 100644 index 00000000..7a828a7f --- /dev/null +++ b/src/renderer/src/utils/index.ts @@ -0,0 +1 @@ +export * from "./format-bytes";