diff --git a/src/locales/en/translation.json b/src/locales/en/translation.json index 669808b8..3ba3f0b7 100755 --- a/src/locales/en/translation.json +++ b/src/locales/en/translation.json @@ -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", @@ -372,6 +382,9 @@ "audio": "Audio", "filter_by_source": "Filter by source", "no_repacks_found": "No sources found for this game", + "source_online": "Source is online", + "source_partial": "Some links are offline", + "source_offline": "Source is offline", "delete_review": "Delete review", "remove_review": "Remove Review", "delete_review_modal_title": "Are you sure you want to delete your review?", @@ -619,7 +632,11 @@ "game_extracted": "{{title}} extracted successfully", "friend_started_playing_game": "{{displayName}} started playing a game", "test_achievement_notification_title": "This is a test notification", - "test_achievement_notification_description": "Pretty cool, huh?" + "test_achievement_notification_description": "Pretty cool, huh?", + "scan_games_complete_title": "Scanning for games finished successfully", + "scan_games_complete_description": "Found {{count}} games without executable path set", + "scan_games_no_results_title": "Scanning for games finished", + "scan_games_no_results_description": "No installed games were found" }, "system_tray": { "open": "Open Hydra", diff --git a/src/main/events/library/index.ts b/src/main/events/library/index.ts index 1e4db10d..7848c0b2 100644 --- a/src/main/events/library/index.ts +++ b/src/main/events/library/index.ts @@ -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"; diff --git a/src/main/events/library/scan-installed-games.ts b/src/main/events/library/scan-installed-games.ts new file mode 100644 index 00000000..7e6c290f --- /dev/null +++ b/src/main/events/library/scan-installed-games.ts @@ -0,0 +1,143 @@ +import path from "node:path"; +import fs from "node:fs"; +import { t } from "i18next"; +import { registerEvent } from "../register-event"; +import { gamesSublevel } from "@main/level"; +import { + GameExecutables, + LocalNotificationManager, + 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; +} + +async function searchInDirectories( + executableNames: Set +): Promise { + for (const scanDir of SCAN_DIRECTORIES) { + if (!fs.existsSync(scanDir)) continue; + + const foundPath = await findExecutableInFolder(scanDir, executableNames); + if (foundPath) return foundPath; + } + return null; +} + +async function publishScanNotification(foundCount: number): Promise { + const hasFoundGames = foundCount > 0; + + await LocalNotificationManager.createNotification( + "SCAN_GAMES_COMPLETE", + t( + hasFoundGames + ? "scan_games_complete_title" + : "scan_games_no_results_title", + { ns: "notifications" } + ), + t( + hasFoundGames + ? "scan_games_complete_description" + : "scan_games_no_results_description", + { ns: "notifications", count: foundCount } + ), + { url: "/library?openScanModal=true" } + ); +} + +const scanInstalledGames = async ( + _event: Electron.IpcMainInvokeEvent +): Promise => { + 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[] = []; + const gamesToScan = games.filter((g) => !g.game.executablePath); + + for (const { key, game } of gamesToScan) { + const executableNames = GameExecutables.getExecutablesForGame( + game.objectId + ); + + if (!executableNames || executableNames.length === 0) continue; + + const normalizedNames = new Set( + executableNames.map((name) => name.toLowerCase()) + ); + + const foundPath = await searchInDirectories(normalizedNames); + + 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"); + await publishScanNotification(foundGames.length); + + return { foundGames, total: gamesToScan.length }; +}; + +async function findExecutableInFolder( + folderPath: string, + executableNames: Set +): Promise { + 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); diff --git a/src/main/services/download/download-manager.ts b/src/main/services/download/download-manager.ts index 0383c2d3..e66d04b6 100644 --- a/src/main/services/download/download-manager.ts +++ b/src/main/services/download/download-manager.ts @@ -362,6 +362,11 @@ export class DownloadManager { 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(); diff --git a/src/main/services/game-executables.ts b/src/main/services/game-executables.ts new file mode 100644 index 00000000..05bd60cf --- /dev/null +++ b/src/main/services/game-executables.ts @@ -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); + } +} diff --git a/src/main/services/game-files-manager.ts b/src/main/services/game-files-manager.ts index f3684a0a..722e1c9e 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 { GameExecutables } from "./game-executables"; const PROGRESS_THROTTLE_MS = 1000; @@ -151,6 +152,100 @@ export class GameFilesManager { if (publishNotification && game) { publishExtractionCompleteNotification(game); } + + await this.searchAndBindExecutable(); + } + + async searchAndBindExecutable(): Promise { + 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 { + 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() { diff --git a/src/main/services/index.ts b/src/main/services/index.ts index e6ceef03..cc37b8ae 100644 --- a/src/main/services/index.ts +++ b/src/main/services/index.ts @@ -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"; diff --git a/src/main/services/process-watcher.ts b/src/main/services/process-watcher.ts index db5bbee1..ee19294d 100644 --- a/src/main/services/process-watcher.ts +++ b/src/main/services/process-watcher.ts @@ -69,7 +69,7 @@ const getGameExecutables = async () => { return gameExecutables; }; -const gameExecutables = await getGameExecutables(); +export const gameExecutables = await getGameExecutables(); const findGamePathByProcess = async ( processMap: Map>, diff --git a/src/preload/index.ts b/src/preload/index.ts index dd7497bf..6d929d99 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -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) => diff --git a/src/renderer/src/components/header/header.scss b/src/renderer/src/components/header/header.scss index f0c72ce0..35148ee1 100644 --- a/src/renderer/src/components/header/header.scss +++ b/src/renderer/src/components/header/header.scss @@ -61,10 +61,26 @@ cursor: pointer; transition: all ease 0.2s; padding: globals.$spacing-unit; + display: flex; + align-items: center; + justify-content: center; &:hover { color: #dadbe1; } + + &--scanning svg { + animation: spin 2s linear infinite; + } + } + + @keyframes spin { + from { + transform: rotate(0deg); + } + to { + transform: rotate(360deg); + } } &__section { diff --git a/src/renderer/src/components/header/header.tsx b/src/renderer/src/components/header/header.tsx index acaec9f1..b84a6dba 100644 --- a/src/renderer/src/components/header/header.tsx +++ b/src/renderer/src/components/header/header.tsx @@ -1,7 +1,13 @@ import { useTranslation } from "react-i18next"; -import { useEffect, useMemo, useRef, useState } from "react"; -import { useLocation, useNavigate } from "react-router-dom"; -import { ArrowLeftIcon, SearchIcon, XIcon } from "@primer/octicons-react"; +import { useEffect, useId, useMemo, useRef, useState } from "react"; +import { useLocation, useNavigate, useSearchParams } from "react-router-dom"; +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,9 +36,11 @@ const pathTitle: Record = { export function Header() { const inputRef = useRef(null); const searchContainerRef = useRef(null); + const scanButtonTooltipId = useId(); const navigate = useNavigate(); const location = useLocation(); + const [searchParams, setSearchParams] = useSearchParams(); const { headerTitle, draggingDisabled } = useAppSelector( (state) => state.window @@ -61,6 +70,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 +239,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; @@ -235,6 +269,14 @@ export function Header() { return () => window.removeEventListener("resize", handleResize); }, [isDropdownVisible]); + useEffect(() => { + if (searchParams.get("openScanModal") === "true") { + setShowScanModal(true); + searchParams.delete("openScanModal"); + setSearchParams(searchParams, { replace: true }); + } + }, [searchParams, setSearchParams]); + return ( <>
+ {isOnLibraryPage && window.electron.platform === "win32" && ( + + )} +
+ + {isOnLibraryPage && window.electron.platform === "win32" && ( + + )} + + + setShowScanModal(false)} + isScanning={isScanning} + scanResult={scanResult} + onStartScan={handleStartScan} + onClearResult={handleClearScanResult} + /> ); } diff --git a/src/renderer/src/components/header/scan-games-modal.scss b/src/renderer/src/components/header/scan-games-modal.scss new file mode 100644 index 00000000..391480bc --- /dev/null +++ b/src/renderer/src/components/header/scan-games-modal.scss @@ -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); + } +} diff --git a/src/renderer/src/components/header/scan-games-modal.tsx b/src/renderer/src/components/header/scan-games-modal.tsx new file mode 100644 index 00000000..9e9b1de1 --- /dev/null +++ b/src/renderer/src/components/header/scan-games-modal.tsx @@ -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, +}: Readonly) { + const { t } = useTranslation("header"); + + const handleClose = () => { + onClose(); + }; + + const handleStartScan = () => { + onStartScan(); + }; + + const handleScanAgain = () => { + onClearResult(); + onStartScan(); + }; + + return ( + +
+ {!scanResult && !isScanning && ( +

+ {t("scan_games_description")} +

+ )} + + {isScanning && !scanResult && ( +
+ +

+ {t("scan_games_in_progress")} +

+
+ )} + + {scanResult && ( +
+ {scanResult.foundGames.length > 0 ? ( + <> +

+ {t("scan_games_result", { + found: scanResult.foundGames.length, + total: scanResult.total, + })} +

+ +
    + {scanResult.foundGames.map((game) => ( +
  • + + {game.title} + + + {game.executablePath} + +
  • + ))} +
+ + ) : ( +

+ {t("scan_games_no_results")} +

+ )} +
+ )} + +
+ + {!scanResult && ( + + )} + {scanResult && ( + + )} +
+
+
+ ); +} diff --git a/src/renderer/src/context/game-details/game-details.context.tsx b/src/renderer/src/context/game-details/game-details.context.tsx index 29feabf5..f0df0547 100644 --- a/src/renderer/src/context/game-details/game-details.context.tsx +++ b/src/renderer/src/context/game-details/game-details.context.tsx @@ -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 { diff --git a/src/renderer/src/declaration.d.ts b/src/renderer/src/declaration.d.ts index a2cc6ccf..e86c207b 100644 --- a/src/renderer/src/declaration.d.ts +++ b/src/renderer/src/declaration.d.ts @@ -211,6 +211,10 @@ declare global { minimized: boolean; }) => Promise; extractGameDownload: (shop: GameShop, objectId: string) => Promise; + scanInstalledGames: () => Promise<{ + foundGames: { title: string; executablePath: string }[]; + total: number; + }>; onExtractionComplete: ( cb: (shop: GameShop, objectId: string) => void ) => () => Electron.IpcRenderer; diff --git a/src/renderer/src/pages/game-details/modals/repacks-modal.scss b/src/renderer/src/pages/game-details/modals/repacks-modal.scss index 420029c7..53ba7c4c 100644 --- a/src/renderer/src/pages/game-details/modals/repacks-modal.scss +++ b/src/renderer/src/pages/game-details/modals/repacks-modal.scss @@ -40,6 +40,34 @@ gap: calc(globals.$spacing-unit * 1); color: globals.$body-color; padding: calc(globals.$spacing-unit * 2); + padding-right: calc(globals.$spacing-unit * 4); + position: relative; + } + + &__availability-orb { + position: absolute; + top: calc(globals.$spacing-unit * 1.5); + right: calc(globals.$spacing-unit * 1.5); + width: 8px; + height: 8px; + border-radius: 50%; + flex-shrink: 0; + + &--online { + background-color: #22c55e; + box-shadow: 0 0 6px rgba(34, 197, 94, 0.5); + } + + &--partial { + background-color: #eab308; + box-shadow: 0 0 6px rgba(234, 179, 8, 0.5); + } + + &--offline { + background-color: #ef4444; + opacity: 0.7; + box-shadow: 0 0 6px rgba(239, 68, 68, 0.4); + } } &__repack-title { diff --git a/src/renderer/src/pages/game-details/modals/repacks-modal.tsx b/src/renderer/src/pages/game-details/modals/repacks-modal.tsx index 683ce53a..1a1132f1 100644 --- a/src/renderer/src/pages/game-details/modals/repacks-modal.tsx +++ b/src/renderer/src/pages/game-details/modals/repacks-modal.tsx @@ -6,6 +6,7 @@ import { ChevronDownIcon, ChevronUpIcon, } from "@primer/octicons-react"; +import { Tooltip } from "react-tooltip"; import { Badge, @@ -185,6 +186,20 @@ export function RepacksModal({ ); }, [repacks, hashesInDebrid]); + const getRepackAvailabilityStatus = ( + repack: GameRepack + ): "online" | "partial" | "offline" => { + const unavailableSet = new Set(repack.unavailableUris ?? []); + const availableCount = repack.uris.filter( + (uri) => !unavailableSet.has(uri) + ).length; + const unavailableCount = repack.uris.length - availableCount; + + if (unavailableCount === 0) return "online"; + if (availableCount === 0) return "offline"; + return "partial"; + }; + useEffect(() => { const term = filterTerm.trim().toLowerCase(); @@ -363,6 +378,8 @@ export function RepacksModal({ filteredRepacks.map((repack) => { const isLastDownloadedOption = checkIfLastDownloadedOption(repack); + const availabilityStatus = getRepackAvailabilityStatus(repack); + const tooltipId = `availability-orb-${repack.id}`; return (