From 98cfe7be9894dd515750e3bfdcde3c58704f4316 Mon Sep 17 00:00:00 2001 From: Moyasee Date: Mon, 19 Jan 2026 04:01:21 +0200 Subject: [PATCH 1/8] feat: add automatic executable path binding upon download finish --- .../services/download/download-manager.ts | 5 + src/main/services/game-executables.ts | 13 +++ src/main/services/game-files-manager.ts | 91 +++++++++++++++++++ src/main/services/index.ts | 1 + src/main/services/process-watcher.ts | 2 +- .../game-details/game-details.context.tsx | 10 ++ 6 files changed, 121 insertions(+), 1 deletion(-) create mode 100644 src/main/services/game-executables.ts 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..66044476 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,96 @@ 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; + } + + 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/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 { From c9801644ace2cff8899adfef26266c65af39bbf0 Mon Sep 17 00:00:00 2001 From: Moyasee Date: Mon, 19 Jan 2026 04:22:44 +0200 Subject: [PATCH 2/8] fix: prevent processing downloads without a folder name --- src/main/services/game-files-manager.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/main/services/game-files-manager.ts b/src/main/services/game-files-manager.ts index 66044476..722e1c9e 100644 --- a/src/main/services/game-files-manager.ts +++ b/src/main/services/game-files-manager.ts @@ -175,9 +175,13 @@ export class GameFilesManager { return; } + if (!download.folderName) { + return; + } + const gameFolderPath = path.join( download.downloadPath, - download.folderName! + download.folderName ); if (!fs.existsSync(gameFolderPath)) { From 88b258179736e29a08f81b3f8593393d07df6b7d Mon Sep 17 00:00:00 2001 From: Moyasee Date: Mon, 19 Jan 2026 15:17:27 +0200 Subject: [PATCH 3/8] 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; From 049a989e8515dc143025735832ed35a5703cb0e3 Mon Sep 17 00:00:00 2001 From: Moyasee Date: Mon, 19 Jan 2026 15:19:50 +0200 Subject: [PATCH 4/8] fix: deleted unnecessary import and fixed assertion --- src/main/events/library/scan-installed-games.ts | 14 +++++++------- .../src/components/header/scan-games-modal.scss | 1 - 2 files changed, 7 insertions(+), 8 deletions(-) diff --git a/src/main/events/library/scan-installed-games.ts b/src/main/events/library/scan-installed-games.ts index 2e7c7843..cb123ee9 100644 --- a/src/main/events/library/scan-installed-games.ts +++ b/src/main/events/library/scan-installed-games.ts @@ -1,15 +1,15 @@ import path from "node:path"; import fs from "node:fs"; import { registerEvent } from "../register-event"; -import { gamesSublevel, levelKeys } from "@main/level"; +import { gamesSublevel } 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", + 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 { @@ -111,7 +111,7 @@ async function findExecutableInFolder( if (executableNames.has(fileName)) { const parentPath = - "parentPath" in entry ? (entry.parentPath as string) : folderPath; + "parentPath" in entry ? entry.parentPath : folderPath; return path.join(parentPath, entry.name); } diff --git a/src/renderer/src/components/header/scan-games-modal.scss b/src/renderer/src/components/header/scan-games-modal.scss index dfa83d64..391480bc 100644 --- a/src/renderer/src/components/header/scan-games-modal.scss +++ b/src/renderer/src/components/header/scan-games-modal.scss @@ -63,7 +63,6 @@ &__games-list { list-style: none; - padding: 0; margin: 0; max-height: 200px; overflow-y: auto; From fbbb2520e046fff848565866ab268516513d8086 Mon Sep 17 00:00:00 2001 From: Moyasee Date: Mon, 19 Jan 2026 17:57:49 +0200 Subject: [PATCH 5/8] feat: enhance game scanning notifications and UI updates --- src/locales/en/translation.json | 6 +++- .../events/library/scan-installed-games.ts | 33 +++++++++++++++++-- .../src/components/header/header.scss | 9 +++-- src/renderer/src/components/header/header.tsx | 11 ++++++- .../notifications/local-notification-item.tsx | 2 ++ src/types/index.ts | 3 +- 6 files changed, 56 insertions(+), 8 deletions(-) diff --git a/src/locales/en/translation.json b/src/locales/en/translation.json index 9a97423b..833685d7 100755 --- a/src/locales/en/translation.json +++ b/src/locales/en/translation.json @@ -629,7 +629,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/scan-installed-games.ts b/src/main/events/library/scan-installed-games.ts index cb123ee9..0bdc818c 100644 --- a/src/main/events/library/scan-installed-games.ts +++ b/src/main/events/library/scan-installed-games.ts @@ -1,8 +1,14 @@ 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, logger, WindowManager } from "@main/services"; +import { + GameExecutables, + LocalNotificationManager, + logger, + WindowManager, +} from "@main/services"; const SCAN_DIRECTORIES = [ String.raw`C:\Games`, @@ -88,9 +94,32 @@ const scanInstalledGames = async ( WindowManager.mainWindow?.webContents.send("on-library-batch-complete"); + const total = games.filter((g) => !g.game.executablePath).length; + + const hasFoundGames = foundGames.length > 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: foundGames.length } + ), + { + url: "/library?openScanModal=true", + } + ); + return { foundGames, - total: games.filter((g) => !g.game.executablePath).length, + total, }; }; diff --git a/src/renderer/src/components/header/header.scss b/src/renderer/src/components/header/header.scss index 0debe0fc..35148ee1 100644 --- a/src/renderer/src/components/header/header.scss +++ b/src/renderer/src/components/header/header.scss @@ -61,22 +61,25 @@ cursor: pointer; transition: all ease 0.2s; padding: globals.$spacing-unit; + display: flex; + align-items: center; + justify-content: center; &:hover { color: #dadbe1; } - &--scanning { + &--scanning svg { animation: spin 2s linear infinite; } } @keyframes spin { from { - transform: rotate(-0deg); + transform: rotate(0deg); } to { - transform: rotate(-360deg); + transform: rotate(360deg); } } diff --git a/src/renderer/src/components/header/header.tsx b/src/renderer/src/components/header/header.tsx index faf60a1e..b84a6dba 100644 --- a/src/renderer/src/components/header/header.tsx +++ b/src/renderer/src/components/header/header.tsx @@ -1,6 +1,6 @@ import { useTranslation } from "react-i18next"; import { useEffect, useId, useMemo, useRef, useState } from "react"; -import { useLocation, useNavigate } from "react-router-dom"; +import { useLocation, useNavigate, useSearchParams } from "react-router-dom"; import { ArrowLeftIcon, SearchIcon, @@ -40,6 +40,7 @@ export function Header() { const navigate = useNavigate(); const location = useLocation(); + const [searchParams, setSearchParams] = useSearchParams(); const { headerTitle, draggingDisabled } = useAppSelector( (state) => state.window @@ -268,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 ( <>
; case "ACHIEVEMENT_UNLOCKED": return ; + case "SCAN_GAMES_COMPLETE": + return ; default: return ; } diff --git a/src/types/index.ts b/src/types/index.ts index 3ddd660c..39fd0791 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -330,7 +330,8 @@ export type LocalNotificationType = | "EXTRACTION_COMPLETE" | "DOWNLOAD_COMPLETE" | "UPDATE_AVAILABLE" - | "ACHIEVEMENT_UNLOCKED"; + | "ACHIEVEMENT_UNLOCKED" + | "SCAN_GAMES_COMPLETE"; export interface Notification { id: string; From 2108a523bc39f5229a719039e63566458efe1558 Mon Sep 17 00:00:00 2001 From: Moyasee Date: Mon, 19 Jan 2026 18:01:55 +0200 Subject: [PATCH 6/8] refactor: streamline game scanning logic and enhance notification handling --- .../events/library/scan-installed-games.ts | 97 ++++++++----------- .../components/header/scan-games-modal.tsx | 2 +- 2 files changed, 42 insertions(+), 57 deletions(-) diff --git a/src/main/events/library/scan-installed-games.ts b/src/main/events/library/scan-installed-games.ts index 0bdc818c..7e6c290f 100644 --- a/src/main/events/library/scan-installed-games.ts +++ b/src/main/events/library/scan-installed-games.ts @@ -28,6 +28,39 @@ interface ScanResult { 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 => { @@ -43,84 +76,36 @@ const scanInstalledGames = async ( ); const foundGames: FoundGame[] = []; + const gamesToScan = games.filter((g) => !g.game.executablePath); - for (const { key, game } of games) { - if (game.executablePath) { - continue; - } - + for (const { key, game } of gamesToScan) { const executableNames = GameExecutables.getExecutablesForGame( game.objectId ); - if (!executableNames || executableNames.length === 0) { - continue; - } + 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; - } - } + const foundPath = await searchInDirectories(normalizedNames); if (foundPath) { - await gamesSublevel.put(key, { - ...game, - executablePath: foundPath, - }); + await gamesSublevel.put(key, { ...game, executablePath: foundPath }); logger.info( `[ScanInstalledGames] Found executable for ${game.objectId}: ${foundPath}` ); - foundGames.push({ - title: game.title, - executablePath: foundPath, - }); + foundGames.push({ title: game.title, executablePath: foundPath }); } } WindowManager.mainWindow?.webContents.send("on-library-batch-complete"); + await publishScanNotification(foundGames.length); - const total = games.filter((g) => !g.game.executablePath).length; - - const hasFoundGames = foundGames.length > 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: foundGames.length } - ), - { - url: "/library?openScanModal=true", - } - ); - - return { - foundGames, - total, - }; + return { foundGames, total: gamesToScan.length }; }; async function findExecutableInFolder( diff --git a/src/renderer/src/components/header/scan-games-modal.tsx b/src/renderer/src/components/header/scan-games-modal.tsx index fa771182..9e9b1de1 100644 --- a/src/renderer/src/components/header/scan-games-modal.tsx +++ b/src/renderer/src/components/header/scan-games-modal.tsx @@ -31,7 +31,7 @@ export function ScanGamesModal({ scanResult, onStartScan, onClearResult, -}: ScanGamesModalProps) { +}: Readonly) { const { t } = useTranslation("header"); const handleClose = () => { From 9ca6a114b119fcbd9d38dfb53b9bd9dc9a9ed52a Mon Sep 17 00:00:00 2001 From: Moyasee Date: Tue, 20 Jan 2026 18:52:52 +0200 Subject: [PATCH 7/8] feat: enhance repack availability status display with new UI elements and translations --- src/locales/en/translation.json | 3 ++ .../game-details/modals/repacks-modal.scss | 35 ++++++++++++++++--- .../game-details/modals/repacks-modal.tsx | 27 +++++++++++++- 3 files changed, 60 insertions(+), 5 deletions(-) diff --git a/src/locales/en/translation.json b/src/locales/en/translation.json index 669808b8..c914e4e8 100755 --- a/src/locales/en/translation.json +++ b/src/locales/en/translation.json @@ -372,6 +372,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?", 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..77b5d6cf 100644 --- a/src/renderer/src/pages/game-details/modals/repacks-modal.scss +++ b/src/renderer/src/pages/game-details/modals/repacks-modal.scss @@ -42,12 +42,38 @@ padding: calc(globals.$spacing-unit * 2); } + &__availability-orb { + display: inline-block; + width: 8px; + height: 8px; + border-radius: 50%; + margin-left: calc(globals.$spacing-unit * 1); + vertical-align: middle; + + &--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 { color: globals.$muted-color; word-break: break-word; - display: flex; - align-items: center; - gap: calc(globals.$spacing-unit * 1); + } + + &__repack-title-text { + word-break: break-word; } &__repack-info { @@ -61,8 +87,9 @@ border-radius: 6px; font-size: 9px; text-align: center; - flex-shrink: 0; border: 1px solid rgba(34, 197, 94, 0.5); + margin-left: calc(globals.$spacing-unit * 1); + vertical-align: middle; } &__no-results { 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..77f70187 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 (