mirror of
https://github.com/hydralauncher/hydra.git
synced 2026-01-15 16:33:02 -03:00
feat: implement game disk usage tracking and enhance UI
This commit is contained in:
@@ -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",
|
||||
|
||||
145
src/main/events/hardware/get-game-disk-usage.ts
Normal file
145
src/main/events/hardware/get-game-disk-usage.ts
Normal 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);
|
||||
@@ -1,2 +1,3 @@
|
||||
import "./check-folder-write-permission";
|
||||
import "./get-disk-free-space";
|
||||
import "./get-game-disk-usage";
|
||||
|
||||
@@ -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: (
|
||||
|
||||
7
src/renderer/src/declaration.d.ts
vendored
7
src/renderer/src/declaration.d.ts
vendored
@@ -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: (
|
||||
|
||||
@@ -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";
|
||||
|
||||
63
src/renderer/src/hooks/use-game-disk-usage.ts
Normal file
63
src/renderer/src/hooks/use-game-disk-usage.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
18
src/renderer/src/utils/format-bytes.ts
Normal file
18
src/renderer/src/utils/format-bytes.ts
Normal 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]}`;
|
||||
};
|
||||
1
src/renderer/src/utils/index.ts
Normal file
1
src/renderer/src/utils/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "./format-bytes";
|
||||
Reference in New Issue
Block a user