From 88b258179736e29a08f81b3f8593393d07df6b7d Mon Sep 17 00:00:00 2001 From: Moyasee Date: Mon, 19 Jan 2026 15:17:27 +0200 Subject: [PATCH] feat: add scan installed games functionality with UI integration --- src/locales/en/translation.json | 12 +- src/main/events/library/index.ts | 1 + .../events/library/scan-installed-games.ts | 129 ++++++++++++++++++ src/preload/index.ts | 1 + .../src/components/header/header.scss | 13 ++ src/renderer/src/components/header/header.tsx | 66 ++++++++- .../components/header/scan-games-modal.scss | 108 +++++++++++++++ .../components/header/scan-games-modal.tsx | 126 +++++++++++++++++ src/renderer/src/declaration.d.ts | 4 + 9 files changed, 457 insertions(+), 3 deletions(-) create mode 100644 src/main/events/library/scan-installed-games.ts create mode 100644 src/renderer/src/components/header/scan-games-modal.scss create mode 100644 src/renderer/src/components/header/scan-games-modal.tsx diff --git a/src/locales/en/translation.json b/src/locales/en/translation.json index 669808b8..9a97423b 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", 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..2e7c7843 --- /dev/null +++ b/src/main/events/library/scan-installed-games.ts @@ -0,0 +1,129 @@ +import path from "node:path"; +import fs from "node:fs"; +import { registerEvent } from "../register-event"; +import { gamesSublevel, levelKeys } from "@main/level"; +import { GameExecutables, logger, WindowManager } from "@main/services"; + +const SCAN_DIRECTORIES = [ + "C:\\Games", + "D:\\Games", + "C:\\Program Files (x86)\\Steam\\steamapps\\common", + "C:\\Program Files\\Steam\\steamapps\\common", + "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 => { + 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 +): 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 as string) : 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/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..0debe0fc 100644 --- a/src/renderer/src/components/header/header.scss +++ b/src/renderer/src/components/header/header.scss @@ -65,6 +65,19 @@ &:hover { color: #dadbe1; } + + &--scanning { + 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..faf60a1e 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 { 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 = { export function Header() { const inputRef = useRef(null); const searchContainerRef = useRef(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() {
+ {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..dfa83d64 --- /dev/null +++ b/src/renderer/src/components/header/scan-games-modal.scss @@ -0,0 +1,108 @@ +@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; + padding: 0; + 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..fa771182 --- /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, +}: ScanGamesModalProps) { + 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/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;