feat: implement game disk usage tracking and enhance UI

This commit is contained in:
Moyasee
2026-01-14 03:48:40 +02:00
parent 7e78a0f9f1
commit af6d027b06
11 changed files with 286 additions and 4 deletions

View File

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

View File

@@ -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<string> => {
const { app } = await import("electron");
return app.getPath("downloads");
};
const getDirectorySize = async (dirPath: string): Promise<number> => {
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);

View File

@@ -1,2 +1,3 @@
import "./check-folder-write-permission";
import "./get-disk-free-space";
import "./get-game-disk-usage";

View File

@@ -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: (

View File

@@ -238,6 +238,13 @@ declare global {
/* Hardware */
getDiskFreeSpace: (path: string) => Promise<DiskUsage>;
checkFolderWritePermission: (path: string) => Promise<boolean>;
getGameDiskUsage: (
shop: GameShop,
objectId: string
) => Promise<{
installerSize: number | null;
installedSize: number | null;
}>;
/* Cloud save */
uploadSaveGame: (

View File

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

View File

@@ -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<number | null>(
null
);
const [installedSizeBytes, setInstalledSizeBytes] = useState<number | null>(
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,
};
}

View File

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

View File

@@ -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<LibraryGameCardLargeProps>) {
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({
<div className="library-game-card-large__overlay">
<div className="library-game-card-large__top-section">
{installedSize && !isDiskUsageLoading && (
<div
className="library-game-card-large__disk-usage"
title={t("disk_usage_tooltip")}
>
<DatabaseIcon size={11} />
<span className="library-game-card-large__disk-usage-text">
{installedSize}
</span>
</div>
)}
<div className="library-game-card-large__playtime">
{game.hasManuallyUpdatedPlaytime ? (
<AlertFillIcon

View File

@@ -0,0 +1,18 @@
/**
* Converts a number of bytes into a human-readable string with appropriate units
* @param bytes - The number of bytes to format
* @param decimals - Number of decimal places (default: 2)
* @returns Formatted string like "1.5 GB", "256 MB", etc.
*/
export const formatBytes = (bytes: number, decimals = 2): string => {
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]}`;
};

View File

@@ -0,0 +1 @@
export * from "./format-bytes";