mirror of
https://github.com/hydralauncher/hydra.git
synced 2026-01-19 10:22:59 -03:00
Compare commits
4 Commits
feat/LBX-3
...
feat/LBX-3
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
049a989e85 | ||
|
|
88b2581797 | ||
|
|
c9801644ac | ||
|
|
98cfe7be98 |
@@ -108,7 +108,17 @@
|
||||
"search_results": "Search results",
|
||||
"settings": "Settings",
|
||||
"version_available_install": "Version {{version}} available. Click here to restart and install.",
|
||||
"version_available_download": "Version {{version}} available. Click here to download."
|
||||
"version_available_download": "Version {{version}} available. Click here to download.",
|
||||
"scan_games_tooltip": "Scan PC for installed games",
|
||||
"scan_games_title": "Scan PC for installed games",
|
||||
"scan_games_description": "This will scan your disks for known game executables. This may take several minutes.",
|
||||
"scan_games_start": "Start Scan",
|
||||
"scan_games_cancel": "Cancel",
|
||||
"scan_games_result": "Found {{found}} of {{total}} games without executable path",
|
||||
"scan_games_no_results": "We couldn't find any installed games.",
|
||||
"scan_games_in_progress": "Scanning your disks for installed games...",
|
||||
"scan_games_close": "Close",
|
||||
"scan_games_scan_again": "Scan Again"
|
||||
},
|
||||
"bottom_panel": {
|
||||
"no_downloads_in_progress": "No downloads in progress",
|
||||
@@ -189,6 +199,7 @@
|
||||
"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",
|
||||
@@ -773,10 +784,7 @@
|
||||
"manual_playtime_tooltip": "This playtime has been manually updated",
|
||||
"all_games": "All Games",
|
||||
"recently_played": "Recently Played",
|
||||
"favorites": "Favorites",
|
||||
"disk_usage": "Disk usage",
|
||||
"disk_usage_tooltip": "Installed size on disk",
|
||||
"installer_size_tooltip": "Installer size"
|
||||
"favorites": "Favorites"
|
||||
},
|
||||
"achievement": {
|
||||
"achievement_unlocked": "Achievement unlocked",
|
||||
|
||||
@@ -1,247 +0,0 @@
|
||||
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<DirectoryScore> => {
|
||||
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<DirectoryScore[]> => {
|
||||
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<string | null> => {
|
||||
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;
|
||||
}
|
||||
};
|
||||
@@ -1,39 +0,0 @@
|
||||
import path from "node:path";
|
||||
import fs from "node:fs";
|
||||
|
||||
export const getDirectorySize = async (dirPath: string): Promise<number> => {
|
||||
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;
|
||||
};
|
||||
@@ -1,9 +1,7 @@
|
||||
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,
|
||||
@@ -13,33 +11,8 @@ 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);
|
||||
|
||||
@@ -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, gamesSublevel, levelKeys } from "@main/level";
|
||||
import { downloadsSublevel, levelKeys } from "@main/level";
|
||||
|
||||
const deleteGameFolder = async (
|
||||
_event: Electron.IpcMainInvokeEvent,
|
||||
shop: GameShop,
|
||||
objectId: string
|
||||
): Promise<void> => {
|
||||
const gameKey = levelKeys.game(shop, objectId);
|
||||
const download = await downloadsSublevel.get(gameKey);
|
||||
const downloadKey = levelKeys.game(shop, objectId);
|
||||
const download = await downloadsSublevel.get(downloadKey);
|
||||
|
||||
if (!download?.folderName) return;
|
||||
|
||||
@@ -49,16 +49,7 @@ const deleteGameFolder = async (
|
||||
|
||||
await deleteFile(folderPath, true);
|
||||
await deleteFile(metaPath);
|
||||
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,
|
||||
});
|
||||
}
|
||||
await downloadsSublevel.del(downloadKey);
|
||||
};
|
||||
|
||||
registerEvent("deleteGameFolder", deleteGameFolder);
|
||||
|
||||
@@ -1,6 +1,3 @@
|
||||
import path from "node:path";
|
||||
import fs from "node:fs";
|
||||
|
||||
import type { LibraryGame } from "@types";
|
||||
import { registerEvent } from "../register-event";
|
||||
import {
|
||||
@@ -31,40 +28,9 @@ const getLibrary = async (): Promise<LibraryGame[]> => {
|
||||
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,
|
||||
|
||||
@@ -24,6 +24,7 @@ import "./remove-game-from-favorites";
|
||||
import "./remove-game-from-library";
|
||||
import "./remove-game";
|
||||
import "./reset-game-achievements";
|
||||
import "./scan-installed-games";
|
||||
import "./select-game-wine-prefix";
|
||||
import "./toggle-automatic-cloud-sync";
|
||||
import "./toggle-game-pin";
|
||||
|
||||
129
src/main/events/library/scan-installed-games.ts
Normal file
129
src/main/events/library/scan-installed-games.ts
Normal file
@@ -0,0 +1,129 @@
|
||||
import path from "node:path";
|
||||
import fs from "node:fs";
|
||||
import { registerEvent } from "../register-event";
|
||||
import { gamesSublevel } from "@main/level";
|
||||
import { GameExecutables, logger, WindowManager } from "@main/services";
|
||||
|
||||
const SCAN_DIRECTORIES = [
|
||||
String.raw`C:\Games`,
|
||||
String.raw`D:\Games`,
|
||||
String.raw`C:\Program Files (x86)\Steam\steamapps\common`,
|
||||
String.raw`C:\Program Files\Steam\steamapps\common`,
|
||||
String.raw`C:\Program Files (x86)\DODI-Repacks`,
|
||||
];
|
||||
|
||||
interface FoundGame {
|
||||
title: string;
|
||||
executablePath: string;
|
||||
}
|
||||
|
||||
interface ScanResult {
|
||||
foundGames: FoundGame[];
|
||||
total: number;
|
||||
}
|
||||
|
||||
const scanInstalledGames = async (
|
||||
_event: Electron.IpcMainInvokeEvent
|
||||
): Promise<ScanResult> => {
|
||||
const games = await gamesSublevel
|
||||
.iterator()
|
||||
.all()
|
||||
.then((results) =>
|
||||
results
|
||||
.filter(
|
||||
([_key, game]) => game.isDeleted === false && game.shop !== "custom"
|
||||
)
|
||||
.map(([key, game]) => ({ key, game }))
|
||||
);
|
||||
|
||||
const foundGames: FoundGame[] = [];
|
||||
|
||||
for (const { key, game } of games) {
|
||||
if (game.executablePath) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const executableNames = GameExecutables.getExecutablesForGame(
|
||||
game.objectId
|
||||
);
|
||||
|
||||
if (!executableNames || executableNames.length === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const normalizedNames = new Set(
|
||||
executableNames.map((name) => name.toLowerCase())
|
||||
);
|
||||
|
||||
let foundPath: string | null = null;
|
||||
|
||||
for (const scanDir of SCAN_DIRECTORIES) {
|
||||
if (!fs.existsSync(scanDir)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
foundPath = await findExecutableInFolder(scanDir, normalizedNames);
|
||||
|
||||
if (foundPath) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (foundPath) {
|
||||
await gamesSublevel.put(key, {
|
||||
...game,
|
||||
executablePath: foundPath,
|
||||
});
|
||||
|
||||
logger.info(
|
||||
`[ScanInstalledGames] Found executable for ${game.objectId}: ${foundPath}`
|
||||
);
|
||||
|
||||
foundGames.push({
|
||||
title: game.title,
|
||||
executablePath: foundPath,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
WindowManager.mainWindow?.webContents.send("on-library-batch-complete");
|
||||
|
||||
return {
|
||||
foundGames,
|
||||
total: games.filter((g) => !g.game.executablePath).length,
|
||||
};
|
||||
};
|
||||
|
||||
async function findExecutableInFolder(
|
||||
folderPath: string,
|
||||
executableNames: Set<string>
|
||||
): Promise<string | null> {
|
||||
try {
|
||||
const entries = await fs.promises.readdir(folderPath, {
|
||||
withFileTypes: true,
|
||||
recursive: true,
|
||||
});
|
||||
|
||||
for (const entry of entries) {
|
||||
if (!entry.isFile()) continue;
|
||||
|
||||
const fileName = entry.name.toLowerCase();
|
||||
|
||||
if (executableNames.has(fileName)) {
|
||||
const parentPath =
|
||||
"parentPath" in entry ? entry.parentPath : folderPath;
|
||||
|
||||
return path.join(parentPath, entry.name);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error(
|
||||
`[ScanInstalledGames] Error reading folder ${folderPath}:`,
|
||||
err
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
registerEvent("scanInstalledGames", scanInstalledGames);
|
||||
@@ -1,9 +1,6 @@
|
||||
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 (
|
||||
@@ -21,40 +18,12 @@ 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);
|
||||
|
||||
@@ -27,7 +27,6 @@ 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,26 +360,13 @@ 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 {
|
||||
// For downloads without extraction (e.g., torrents with ready-to-play files),
|
||||
// search for executable in the download folder
|
||||
const gameFilesManager = new GameFilesManager(game.shop, game.objectId);
|
||||
gameFilesManager.searchAndBindExecutable();
|
||||
}
|
||||
|
||||
await this.processNextQueuedDownload();
|
||||
|
||||
13
src/main/services/game-executables.ts
Normal file
13
src/main/services/game-executables.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { gameExecutables } from "./process-watcher";
|
||||
|
||||
export class GameExecutables {
|
||||
static getExecutablesForGame(objectId: string): string[] | null {
|
||||
const executables = gameExecutables[objectId];
|
||||
|
||||
if (!executables || executables.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return executables.map((exe) => exe.exe);
|
||||
}
|
||||
}
|
||||
@@ -7,7 +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";
|
||||
|
||||
const PROGRESS_THROTTLE_MS = 1000;
|
||||
|
||||
@@ -143,17 +143,6 @@ 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,
|
||||
@@ -163,6 +152,100 @@ export class GameFilesManager {
|
||||
if (publishNotification && game) {
|
||||
publishExtractionCompleteNotification(game);
|
||||
}
|
||||
|
||||
await this.searchAndBindExecutable();
|
||||
}
|
||||
|
||||
async searchAndBindExecutable(): Promise<void> {
|
||||
try {
|
||||
const [download, game] = await Promise.all([
|
||||
downloadsSublevel.get(this.gameKey),
|
||||
gamesSublevel.get(this.gameKey),
|
||||
]);
|
||||
|
||||
if (!download || !game || game.executablePath) {
|
||||
return;
|
||||
}
|
||||
|
||||
const executableNames = GameExecutables.getExecutablesForGame(
|
||||
this.objectId
|
||||
);
|
||||
|
||||
if (!executableNames || executableNames.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!download.folderName) {
|
||||
return;
|
||||
}
|
||||
|
||||
const gameFolderPath = path.join(
|
||||
download.downloadPath,
|
||||
download.folderName
|
||||
);
|
||||
|
||||
if (!fs.existsSync(gameFolderPath)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const foundExePath = await this.findExecutableInFolder(
|
||||
gameFolderPath,
|
||||
executableNames
|
||||
);
|
||||
|
||||
if (foundExePath) {
|
||||
logger.info(
|
||||
`[GameFilesManager] Auto-detected executable for ${this.objectId}: ${foundExePath}`
|
||||
);
|
||||
|
||||
await gamesSublevel.put(this.gameKey, {
|
||||
...game,
|
||||
executablePath: foundExePath,
|
||||
});
|
||||
|
||||
WindowManager.mainWindow?.webContents.send("on-library-batch-complete");
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error(
|
||||
`[GameFilesManager] Error searching for executable: ${this.objectId}`,
|
||||
err
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private async findExecutableInFolder(
|
||||
folderPath: string,
|
||||
executableNames: string[]
|
||||
): Promise<string | null> {
|
||||
const normalizedNames = new Set(
|
||||
executableNames.map((name) => name.toLowerCase())
|
||||
);
|
||||
|
||||
try {
|
||||
const entries = await fs.promises.readdir(folderPath, {
|
||||
withFileTypes: true,
|
||||
recursive: true,
|
||||
});
|
||||
|
||||
for (const entry of entries) {
|
||||
if (!entry.isFile()) continue;
|
||||
|
||||
const fileName = entry.name.toLowerCase();
|
||||
|
||||
if (normalizedNames.has(fileName)) {
|
||||
const parentPath =
|
||||
"parentPath" in entry
|
||||
? entry.parentPath
|
||||
: (entry as unknown as { path?: string }).path || folderPath;
|
||||
|
||||
return path.join(parentPath, entry.name);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Silently fail if folder cannot be read
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
async extractDownloadedFile() {
|
||||
|
||||
@@ -10,6 +10,7 @@ export * from "./ludusavi";
|
||||
export * from "./cloud-sync";
|
||||
export * from "./7zip";
|
||||
export * from "./game-files-manager";
|
||||
export * from "./game-executables";
|
||||
export * from "./common-redist-manager";
|
||||
export * from "./aria2";
|
||||
export * from "./ws";
|
||||
|
||||
@@ -69,7 +69,7 @@ const getGameExecutables = async () => {
|
||||
return gameExecutables;
|
||||
};
|
||||
|
||||
const gameExecutables = await getGameExecutables();
|
||||
export const gameExecutables = await getGameExecutables();
|
||||
|
||||
const findGamePathByProcess = async (
|
||||
processMap: Map<string, Set<string>>,
|
||||
|
||||
@@ -241,6 +241,7 @@ contextBridge.exposeInMainWorld("electron", {
|
||||
ipcRenderer.invoke("changeGamePlayTime", shop, objectId, playtime),
|
||||
extractGameDownload: (shop: GameShop, objectId: string) =>
|
||||
ipcRenderer.invoke("extractGameDownload", shop, objectId),
|
||||
scanInstalledGames: () => ipcRenderer.invoke("scanInstalledGames"),
|
||||
getDefaultWinePrefixSelectionPath: () =>
|
||||
ipcRenderer.invoke("getDefaultWinePrefixSelectionPath"),
|
||||
createSteamShortcut: (shop: GameShop, objectId: string) =>
|
||||
|
||||
@@ -65,6 +65,19 @@
|
||||
&:hover {
|
||||
color: #dadbe1;
|
||||
}
|
||||
|
||||
&--scanning {
|
||||
animation: spin 2s linear infinite;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
from {
|
||||
transform: rotate(-0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(-360deg);
|
||||
}
|
||||
}
|
||||
|
||||
&__section {
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useEffect, useId, useMemo, useRef, useState } from "react";
|
||||
import { useLocation, useNavigate } from "react-router-dom";
|
||||
import { ArrowLeftIcon, SearchIcon, XIcon } from "@primer/octicons-react";
|
||||
import {
|
||||
ArrowLeftIcon,
|
||||
SearchIcon,
|
||||
SyncIcon,
|
||||
XIcon,
|
||||
} from "@primer/octicons-react";
|
||||
import { Tooltip } from "react-tooltip";
|
||||
|
||||
import {
|
||||
useAppDispatch,
|
||||
@@ -12,6 +18,7 @@ import {
|
||||
|
||||
import "./header.scss";
|
||||
import { AutoUpdateSubHeader } from "./auto-update-sub-header";
|
||||
import { ScanGamesModal } from "./scan-games-modal";
|
||||
import { setFilters, setLibrarySearchQuery } from "@renderer/features";
|
||||
import cn from "classnames";
|
||||
import { SearchDropdown } from "@renderer/components";
|
||||
@@ -29,6 +36,7 @@ const pathTitle: Record<string, string> = {
|
||||
export function Header() {
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const searchContainerRef = useRef<HTMLDivElement>(null);
|
||||
const scanButtonTooltipId = useId();
|
||||
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
@@ -61,6 +69,12 @@ export function Header() {
|
||||
x: 0,
|
||||
y: 0,
|
||||
});
|
||||
const [showScanModal, setShowScanModal] = useState(false);
|
||||
const [isScanning, setIsScanning] = useState(false);
|
||||
const [scanResult, setScanResult] = useState<{
|
||||
foundGames: { title: string; executablePath: string }[];
|
||||
total: number;
|
||||
} | null>(null);
|
||||
|
||||
const { t } = useTranslation("header");
|
||||
|
||||
@@ -224,6 +238,25 @@ export function Header() {
|
||||
setActiveIndex(-1);
|
||||
};
|
||||
|
||||
const handleStartScan = async () => {
|
||||
if (isScanning) return;
|
||||
|
||||
setIsScanning(true);
|
||||
setScanResult(null);
|
||||
setShowScanModal(false);
|
||||
|
||||
try {
|
||||
const result = await window.electron.scanInstalledGames();
|
||||
setScanResult(result);
|
||||
} finally {
|
||||
setIsScanning(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClearScanResult = () => {
|
||||
setScanResult(null);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!isDropdownVisible) return;
|
||||
|
||||
@@ -265,6 +298,21 @@ export function Header() {
|
||||
</section>
|
||||
|
||||
<section className="header__section">
|
||||
{isOnLibraryPage && window.electron.platform === "win32" && (
|
||||
<button
|
||||
type="button"
|
||||
className={cn("header__action-button", {
|
||||
"header__action-button--scanning": isScanning,
|
||||
})}
|
||||
onClick={() => setShowScanModal(true)}
|
||||
data-tooltip-id={scanButtonTooltipId}
|
||||
data-tooltip-content={t("scan_games_tooltip")}
|
||||
data-tooltip-place="bottom"
|
||||
>
|
||||
<SyncIcon size={16} />
|
||||
</button>
|
||||
)}
|
||||
|
||||
<div
|
||||
ref={searchContainerRef}
|
||||
className={cn("header__search", {
|
||||
@@ -304,6 +352,11 @@ export function Header() {
|
||||
</div>
|
||||
</section>
|
||||
</header>
|
||||
|
||||
{isOnLibraryPage && window.electron.platform === "win32" && (
|
||||
<Tooltip id={scanButtonTooltipId} style={{ zIndex: 1 }} />
|
||||
)}
|
||||
|
||||
<AutoUpdateSubHeader />
|
||||
|
||||
<SearchDropdown
|
||||
@@ -327,6 +380,15 @@ export function Header() {
|
||||
currentQuery={searchValue}
|
||||
searchContainerRef={searchContainerRef}
|
||||
/>
|
||||
|
||||
<ScanGamesModal
|
||||
visible={showScanModal}
|
||||
onClose={() => setShowScanModal(false)}
|
||||
isScanning={isScanning}
|
||||
scanResult={scanResult}
|
||||
onStartScan={handleStartScan}
|
||||
onClearResult={handleClearScanResult}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
107
src/renderer/src/components/header/scan-games-modal.scss
Normal file
107
src/renderer/src/components/header/scan-games-modal.scss
Normal file
@@ -0,0 +1,107 @@
|
||||
@use "../../scss/globals.scss";
|
||||
|
||||
.scan-games-modal {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: calc(globals.$spacing-unit * 3);
|
||||
min-width: 400px;
|
||||
|
||||
&__description {
|
||||
color: globals.$muted-color;
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
&__results {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: calc(globals.$spacing-unit * 2);
|
||||
}
|
||||
|
||||
&__result {
|
||||
color: globals.$body-color;
|
||||
font-size: 14px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
&__no-results {
|
||||
color: globals.$muted-color;
|
||||
font-size: 14px;
|
||||
margin: 0;
|
||||
text-align: center;
|
||||
padding: calc(globals.$spacing-unit * 2) 0;
|
||||
}
|
||||
|
||||
&__scanning {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: calc(globals.$spacing-unit * 2);
|
||||
padding: calc(globals.$spacing-unit * 3) 0;
|
||||
}
|
||||
|
||||
&__spinner {
|
||||
color: globals.$muted-color;
|
||||
animation: spin 2s linear infinite;
|
||||
}
|
||||
|
||||
&__scanning-text {
|
||||
color: globals.$muted-color;
|
||||
font-size: 14px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
&__games-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: globals.$spacing-unit;
|
||||
background-color: globals.$dark-background-color;
|
||||
border-radius: 4px;
|
||||
padding: calc(globals.$spacing-unit * 2);
|
||||
}
|
||||
|
||||
&__game-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
padding-bottom: globals.$spacing-unit;
|
||||
border-bottom: 1px solid globals.$border-color;
|
||||
|
||||
&:last-child {
|
||||
border-bottom: none;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
&__game-title {
|
||||
color: globals.$body-color;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
&__game-path {
|
||||
color: globals.$muted-color;
|
||||
font-size: 12px;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
&__actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: calc(globals.$spacing-unit * 2);
|
||||
}
|
||||
}
|
||||
126
src/renderer/src/components/header/scan-games-modal.tsx
Normal file
126
src/renderer/src/components/header/scan-games-modal.tsx
Normal file
@@ -0,0 +1,126 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { SyncIcon } from "@primer/octicons-react";
|
||||
|
||||
import { Button, Modal } from "@renderer/components";
|
||||
|
||||
import "./scan-games-modal.scss";
|
||||
|
||||
interface FoundGame {
|
||||
title: string;
|
||||
executablePath: string;
|
||||
}
|
||||
|
||||
interface ScanResult {
|
||||
foundGames: FoundGame[];
|
||||
total: number;
|
||||
}
|
||||
|
||||
export interface ScanGamesModalProps {
|
||||
visible: boolean;
|
||||
onClose: () => void;
|
||||
isScanning: boolean;
|
||||
scanResult: ScanResult | null;
|
||||
onStartScan: () => void;
|
||||
onClearResult: () => void;
|
||||
}
|
||||
|
||||
export function ScanGamesModal({
|
||||
visible,
|
||||
onClose,
|
||||
isScanning,
|
||||
scanResult,
|
||||
onStartScan,
|
||||
onClearResult,
|
||||
}: ScanGamesModalProps) {
|
||||
const { t } = useTranslation("header");
|
||||
|
||||
const handleClose = () => {
|
||||
onClose();
|
||||
};
|
||||
|
||||
const handleStartScan = () => {
|
||||
onStartScan();
|
||||
};
|
||||
|
||||
const handleScanAgain = () => {
|
||||
onClearResult();
|
||||
onStartScan();
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
visible={visible}
|
||||
title={t("scan_games_title")}
|
||||
onClose={handleClose}
|
||||
clickOutsideToClose={!isScanning}
|
||||
>
|
||||
<div className="scan-games-modal">
|
||||
{!scanResult && !isScanning && (
|
||||
<p className="scan-games-modal__description">
|
||||
{t("scan_games_description")}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{isScanning && !scanResult && (
|
||||
<div className="scan-games-modal__scanning">
|
||||
<SyncIcon size={24} className="scan-games-modal__spinner" />
|
||||
<p className="scan-games-modal__scanning-text">
|
||||
{t("scan_games_in_progress")}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{scanResult && (
|
||||
<div className="scan-games-modal__results">
|
||||
{scanResult.foundGames.length > 0 ? (
|
||||
<>
|
||||
<p className="scan-games-modal__result">
|
||||
{t("scan_games_result", {
|
||||
found: scanResult.foundGames.length,
|
||||
total: scanResult.total,
|
||||
})}
|
||||
</p>
|
||||
|
||||
<ul className="scan-games-modal__games-list">
|
||||
{scanResult.foundGames.map((game) => (
|
||||
<li
|
||||
key={game.executablePath}
|
||||
className="scan-games-modal__game-item"
|
||||
>
|
||||
<span className="scan-games-modal__game-title">
|
||||
{game.title}
|
||||
</span>
|
||||
<span className="scan-games-modal__game-path">
|
||||
{game.executablePath}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</>
|
||||
) : (
|
||||
<p className="scan-games-modal__no-results">
|
||||
{t("scan_games_no_results")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="scan-games-modal__actions">
|
||||
<Button theme="outline" onClick={handleClose}>
|
||||
{scanResult ? t("scan_games_close") : t("scan_games_cancel")}
|
||||
</Button>
|
||||
{!scanResult && (
|
||||
<Button onClick={handleStartScan} disabled={isScanning}>
|
||||
{t("scan_games_start")}
|
||||
</Button>
|
||||
)}
|
||||
{scanResult && (
|
||||
<Button onClick={handleScanAgain}>
|
||||
{t("scan_games_scan_again")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -225,6 +225,16 @@ export function GameDetailsContextProvider({
|
||||
};
|
||||
}, [game?.id, isGameRunning, updateGame]);
|
||||
|
||||
useEffect(() => {
|
||||
const unsubscribe = window.electron.onLibraryBatchComplete(() => {
|
||||
updateGame();
|
||||
});
|
||||
|
||||
return () => {
|
||||
unsubscribe();
|
||||
};
|
||||
}, [updateGame]);
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (ev: Event) => {
|
||||
try {
|
||||
|
||||
4
src/renderer/src/declaration.d.ts
vendored
4
src/renderer/src/declaration.d.ts
vendored
@@ -211,6 +211,10 @@ declare global {
|
||||
minimized: boolean;
|
||||
}) => Promise<void>;
|
||||
extractGameDownload: (shop: GameShop, objectId: string) => Promise<boolean>;
|
||||
scanInstalledGames: () => Promise<{
|
||||
foundGames: { title: string; executablePath: string }[];
|
||||
total: number;
|
||||
}>;
|
||||
onExtractionComplete: (
|
||||
cb: (shop: GameShop, objectId: string) => void
|
||||
) => () => Electron.IpcRenderer;
|
||||
|
||||
@@ -84,45 +84,6 @@
|
||||
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;
|
||||
|
||||
@@ -1,15 +1,7 @@
|
||||
import { LibraryGame } from "@types";
|
||||
import { useGameCard } from "@renderer/hooks";
|
||||
import { formatBytes } from "@renderer/utils";
|
||||
import {
|
||||
ClockIcon,
|
||||
AlertFillIcon,
|
||||
TrophyIcon,
|
||||
DatabaseIcon,
|
||||
FileZipIcon,
|
||||
} from "@primer/octicons-react";
|
||||
import { ClockIcon, AlertFillIcon, TrophyIcon } from "@primer/octicons-react";
|
||||
import { memo, useEffect, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import "./library-game-card-large.scss";
|
||||
|
||||
interface LibraryGameCardLargeProps {
|
||||
@@ -38,53 +30,9 @@ export const LibraryGameCardLarge = memo(function LibraryGameCardLarge({
|
||||
game,
|
||||
onContextMenu,
|
||||
}: Readonly<LibraryGameCardLargeProps>) {
|
||||
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(
|
||||
@@ -146,27 +94,6 @@ export const LibraryGameCardLarge = memo(function LibraryGameCardLarge({
|
||||
|
||||
<div className="library-game-card-large__overlay">
|
||||
<div className="library-game-card-large__top-section">
|
||||
{sizeBars.length > 0 && (
|
||||
<div className="library-game-card-large__size-badges">
|
||||
{sizeBars.map((bar) => (
|
||||
<div
|
||||
key={bar.type}
|
||||
className="library-game-card-large__size-bar"
|
||||
title={t(bar.tooltipKey)}
|
||||
>
|
||||
<bar.icon size={11} />
|
||||
<div
|
||||
className={`library-game-card-large__size-bar-line library-game-card-large__size-bar-line--${bar.type}`}
|
||||
style={{ width: `${bar.widthPx}px` }}
|
||||
/>
|
||||
<span className="library-game-card-large__size-bar-text">
|
||||
{bar.formatted}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="library-game-card-large__playtime">
|
||||
{game.hasManuallyUpdatedPlaytime ? (
|
||||
<AlertFillIcon
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
/**
|
||||
* 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 +0,0 @@
|
||||
export * from "./format-bytes";
|
||||
@@ -64,8 +64,6 @@ export interface Game {
|
||||
automaticCloudSync?: boolean;
|
||||
hasManuallyUpdatedPlaytime?: boolean;
|
||||
newDownloadOptionsCount?: number;
|
||||
installedSizeInBytes?: number | null;
|
||||
installerSizeInBytes?: number | null;
|
||||
}
|
||||
|
||||
export interface Download {
|
||||
|
||||
Reference in New Issue
Block a user