Compare commits

..

1 Commits

Author SHA1 Message Date
Moyasee
50bafbb7f6 refactor: improve notification handling in SidebarProfile component 2026-01-20 19:41:24 +02:00
18 changed files with 59 additions and 637 deletions

View File

@@ -108,17 +108,7 @@
"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.",
"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"
"version_available_download": "Version {{version}} available. Click here to download."
},
"bottom_panel": {
"no_downloads_in_progress": "No downloads in progress",
@@ -629,11 +619,7 @@
"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?",
"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"
"test_achievement_notification_description": "Pretty cool, huh?"
},
"system_tray": {
"open": "Open Hydra",

View File

@@ -24,7 +24,6 @@ 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";

View File

@@ -1,143 +0,0 @@
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<string>
): Promise<string | null> {
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<void> {
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<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[] = [];
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<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);

View File

@@ -362,11 +362,6 @@ 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();

View File

@@ -1,13 +0,0 @@
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);
}
}

View File

@@ -7,7 +7,6 @@ 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;
@@ -152,100 +151,6 @@ 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() {

View File

@@ -10,7 +10,6 @@ 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";

View File

@@ -69,7 +69,7 @@ const getGameExecutables = async () => {
return gameExecutables;
};
export const gameExecutables = await getGameExecutables();
const gameExecutables = await getGameExecutables();
const findGamePathByProcess = async (
processMap: Map<string, Set<string>>,

View File

@@ -241,7 +241,6 @@ 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) =>

View File

@@ -61,26 +61,10 @@
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 {

View File

@@ -1,13 +1,7 @@
import { useTranslation } from "react-i18next";
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 { useEffect, useMemo, useRef, useState } from "react";
import { useLocation, useNavigate } from "react-router-dom";
import { ArrowLeftIcon, SearchIcon, XIcon } from "@primer/octicons-react";
import {
useAppDispatch,
@@ -18,7 +12,6 @@ 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";
@@ -36,11 +29,9 @@ 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();
const [searchParams, setSearchParams] = useSearchParams();
const { headerTitle, draggingDisabled } = useAppSelector(
(state) => state.window
@@ -70,12 +61,6 @@ 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");
@@ -239,25 +224,6 @@ 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;
@@ -269,14 +235,6 @@ 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 (
<>
<header
@@ -307,21 +265,6 @@ 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", {
@@ -361,11 +304,6 @@ export function Header() {
</div>
</section>
</header>
{isOnLibraryPage && window.electron.platform === "win32" && (
<Tooltip id={scanButtonTooltipId} style={{ zIndex: 1 }} />
)}
<AutoUpdateSubHeader />
<SearchDropdown
@@ -389,15 +327,6 @@ export function Header() {
currentQuery={searchValue}
searchContainerRef={searchContainerRef}
/>
<ScanGamesModal
visible={showScanModal}
onClose={() => setShowScanModal(false)}
isScanning={isScanning}
scanResult={scanResult}
onStartScan={handleStartScan}
onClearResult={handleClearScanResult}
/>
</>
);
}

View File

@@ -1,107 +0,0 @@
@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);
}
}

View File

@@ -1,126 +0,0 @@
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<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>
);
}

View File

@@ -1,7 +1,7 @@
import { useNavigate } from "react-router-dom";
import { BellIcon } from "@primer/octicons-react";
import { useAppSelector, useUserDetails } from "@renderer/hooks";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import SteamLogo from "@renderer/assets/steam-logo.svg?react";
import { Avatar } from "../avatar/avatar";
@@ -10,6 +10,8 @@ import { logger } from "@renderer/logger";
import type { NotificationCountResponse } from "@types";
import "./sidebar-profile.scss";
const NOTIFICATION_POLL_INTERVAL_MS = 5 * 60 * 1000;
export function SidebarProfile() {
const navigate = useNavigate();
@@ -20,51 +22,78 @@ export function SidebarProfile() {
const { gameRunning } = useAppSelector((state) => state.gameRunning);
const [notificationCount, setNotificationCount] = useState(0);
const apiNotificationCountRef = useRef(0);
const userDetailsRef = useRef(userDetails);
const fetchNotificationCount = useCallback(async () => {
// Keep userDetailsRef in sync
useEffect(() => {
userDetailsRef.current = userDetails;
}, [userDetails]);
const fetchLocalNotificationCount = useCallback(async () => {
try {
const localCount = await window.electron.getLocalNotificationsCount();
setNotificationCount(localCount + apiNotificationCountRef.current);
} catch (error) {
logger.error("Failed to fetch local notification count", error);
}
}, []);
const fetchFullNotificationCount = useCallback(async () => {
try {
// Always fetch local notification count
const localCount = await window.electron.getLocalNotificationsCount();
// Fetch API notification count only if logged in
let apiCount = 0;
if (userDetails) {
if (userDetailsRef.current) {
try {
const response =
await window.electron.hydraApi.get<NotificationCountResponse>(
"/profile/notifications/count",
{ needsAuth: true }
);
apiCount = response.count;
apiNotificationCountRef.current = response.count;
} catch {
// Ignore API errors
}
} else {
apiNotificationCountRef.current = 0;
}
setNotificationCount(localCount + apiCount);
setNotificationCount(localCount + apiNotificationCountRef.current);
} catch (error) {
logger.error("Failed to fetch notification count", error);
}
}, [userDetails]);
}, []);
useEffect(() => {
fetchNotificationCount();
fetchFullNotificationCount();
const interval = setInterval(fetchNotificationCount, 60000);
const interval = setInterval(
fetchFullNotificationCount,
NOTIFICATION_POLL_INTERVAL_MS
);
return () => clearInterval(interval);
}, [fetchNotificationCount]);
}, [fetchFullNotificationCount]);
useEffect(() => {
if (userDetails) {
fetchFullNotificationCount();
} else {
apiNotificationCountRef.current = 0;
fetchLocalNotificationCount();
}
}, [userDetails, fetchFullNotificationCount, fetchLocalNotificationCount]);
useEffect(() => {
const unsubscribe = window.electron.onLocalNotificationCreated(() => {
fetchNotificationCount();
fetchLocalNotificationCount();
});
return () => unsubscribe();
}, [fetchNotificationCount]);
}, [fetchLocalNotificationCount]);
useEffect(() => {
const handleNotificationsChange = () => {
fetchNotificationCount();
fetchLocalNotificationCount();
};
window.addEventListener("notificationsChanged", handleNotificationsChange);
@@ -74,15 +103,18 @@ export function SidebarProfile() {
handleNotificationsChange
);
};
}, [fetchNotificationCount]);
}, [fetchLocalNotificationCount]);
useEffect(() => {
const unsubscribe = window.electron.onSyncNotificationCount(() => {
fetchNotificationCount();
});
const unsubscribe = window.electron.onSyncNotificationCount(
(notification) => {
apiNotificationCountRef.current = notification.notificationCount;
fetchLocalNotificationCount();
}
);
return () => unsubscribe();
}, [fetchNotificationCount]);
}, [fetchLocalNotificationCount]);
const handleProfileClick = () => {
if (userDetails === null) {

View File

@@ -225,16 +225,6 @@ export function GameDetailsContextProvider({
};
}, [game?.id, isGameRunning, updateGame]);
useEffect(() => {
const unsubscribe = window.electron.onLibraryBatchComplete(() => {
updateGame();
});
return () => {
unsubscribe();
};
}, [updateGame]);
useEffect(() => {
const handler = (ev: Event) => {
try {

View File

@@ -211,10 +211,6 @@ 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;

View File

@@ -58,8 +58,6 @@ export function LocalNotificationItem({
return <SyncIcon size={24} />;
case "ACHIEVEMENT_UNLOCKED":
return <TrophyIcon size={24} />;
case "SCAN_GAMES_COMPLETE":
return <SyncIcon size={24} />;
default:
return <DownloadIcon size={24} />;
}

View File

@@ -330,8 +330,7 @@ export type LocalNotificationType =
| "EXTRACTION_COMPLETE"
| "DOWNLOAD_COMPLETE"
| "UPDATE_AVAILABLE"
| "ACHIEVEMENT_UNLOCKED"
| "SCAN_GAMES_COMPLETE";
| "ACHIEVEMENT_UNLOCKED";
export interface Notification {
id: string;