From 101bc3546086123c80dce7279bac273407992a86 Mon Sep 17 00:00:00 2001 From: Moyasee Date: Thu, 30 Oct 2025 23:21:31 +0200 Subject: [PATCH] feat: sidebar badge on new game download option --- .../downloadSourcesCheckTimestamp.ts | 16 +++ src/main/level/sublevels/index.ts | 1 + src/main/level/sublevels/keys.ts | 1 + src/main/main.ts | 4 + src/main/services/download-sources-checker.ts | 127 ++++++++++++++++++ src/main/services/hydra-api.ts | 32 +++++ src/main/services/index.ts | 1 + src/preload/index.ts | 11 ++ src/renderer/src/app.tsx | 4 + .../components/sidebar/sidebar-game-item.tsx | 7 + .../src/components/sidebar/sidebar.scss | 20 +++ src/renderer/src/declaration.d.ts | 5 + src/renderer/src/features/library-slice.ts | 20 ++- src/renderer/src/hooks/index.ts | 1 + .../hooks/use-download-options-listener.ts | 19 +++ src/types/level.types.ts | 1 + 16 files changed, 269 insertions(+), 1 deletion(-) create mode 100644 src/main/level/sublevels/downloadSourcesCheckTimestamp.ts create mode 100644 src/main/services/download-sources-checker.ts create mode 100644 src/renderer/src/hooks/use-download-options-listener.ts diff --git a/src/main/level/sublevels/downloadSourcesCheckTimestamp.ts b/src/main/level/sublevels/downloadSourcesCheckTimestamp.ts new file mode 100644 index 00000000..13dbf682 --- /dev/null +++ b/src/main/level/sublevels/downloadSourcesCheckTimestamp.ts @@ -0,0 +1,16 @@ +import { levelKeys } from "./keys"; +import { db } from "../level"; + +export const getLastDownloadSourcesCheck = async (): Promise => { + try { + const timestamp = await db.get(levelKeys.lastDownloadSourcesCheck); + return timestamp; + } catch (error) { + // Key doesn't exist yet + return null; + } +}; + +export const updateLastDownloadSourcesCheck = async (timestamp: string): Promise => { + await db.put(levelKeys.lastDownloadSourcesCheck, timestamp); +}; \ No newline at end of file diff --git a/src/main/level/sublevels/index.ts b/src/main/level/sublevels/index.ts index 3619ae26..4575bbc4 100644 --- a/src/main/level/sublevels/index.ts +++ b/src/main/level/sublevels/index.ts @@ -7,3 +7,4 @@ export * from "./game-achievements"; export * from "./keys"; export * from "./themes"; export * from "./download-sources"; +export * from "./downloadSourcesCheckTimestamp"; diff --git a/src/main/level/sublevels/keys.ts b/src/main/level/sublevels/keys.ts index a28690b2..536f9dca 100644 --- a/src/main/level/sublevels/keys.ts +++ b/src/main/level/sublevels/keys.ts @@ -18,4 +18,5 @@ export const levelKeys = { screenState: "screenState", rpcPassword: "rpcPassword", downloadSources: "downloadSources", + lastDownloadSourcesCheck: "lastDownloadSourcesCheck", }; diff --git a/src/main/main.ts b/src/main/main.ts index ffb8f8a9..8e1264d7 100644 --- a/src/main/main.ts +++ b/src/main/main.ts @@ -16,6 +16,7 @@ import { Ludusavi, Lock, DeckyPlugin, + DownloadSourcesChecker, } from "@main/services"; import { migrateDownloadSources } from "./helpers/migrate-download-sources"; @@ -56,6 +57,9 @@ export const loadState = async () => { const { syncDownloadSourcesFromApi } = await import("./services/user"); void syncDownloadSourcesFromApi(); + + // Check for new download options on startup + void DownloadSourcesChecker.checkForChanges(); // WSClient.connect(); }); diff --git a/src/main/services/download-sources-checker.ts b/src/main/services/download-sources-checker.ts new file mode 100644 index 00000000..f22f12fc --- /dev/null +++ b/src/main/services/download-sources-checker.ts @@ -0,0 +1,127 @@ +import { HydraApi } from "./hydra-api"; +import { gamesSublevel, getLastDownloadSourcesCheck, updateLastDownloadSourcesCheck, downloadSourcesSublevel } from "@main/level"; +import { logger } from "./logger"; +import { WindowManager } from "./window-manager"; +import type { Game } from "@types"; + +interface DownloadSourcesChangeResponse { + shop: string; + objectId: string; + newDownloadOptionsCount: number; + downloadSourceIds: string[]; +} + +export class DownloadSourcesChecker { + static async checkForChanges(): Promise { + logger.info("DownloadSourcesChecker.checkForChanges() called"); + + try { + // Get all installed games (excluding custom games) + const installedGames = await gamesSublevel.values().all(); + const nonCustomGames = installedGames.filter((game: Game) => game.shop !== 'custom'); + logger.info(`Found ${installedGames.length} total games, ${nonCustomGames.length} non-custom games`); + + if (nonCustomGames.length === 0) { + logger.info("No non-custom games found, skipping download sources check"); + return; + } + + // Get download sources + const downloadSources = await downloadSourcesSublevel.values().all(); + const downloadSourceIds = downloadSources.map(source => source.id); + logger.info(`Found ${downloadSourceIds.length} download sources: ${downloadSourceIds.join(', ')}`); + + if (downloadSourceIds.length === 0) { + logger.info("No download sources found, skipping download sources check"); + return; + } + + // Get last check timestamp or use a default (24 hours ago) + const lastCheck = await getLastDownloadSourcesCheck(); + const since = lastCheck || new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString(); + logger.info(`Last check: ${lastCheck}, using since: ${since}`); + + // Clear any previously stored new download option counts so badges don't persist across restarts + const previouslyFlaggedGames = nonCustomGames.filter( + (game: Game) => (game as Game).newDownloadOptionsCount && (game as Game).newDownloadOptionsCount! > 0 + ); + + const clearedPayload: { gameId: string; count: number }[] = []; + if (previouslyFlaggedGames.length > 0) { + logger.info(`Clearing stale newDownloadOptionsCount for ${previouslyFlaggedGames.length} games`); + for (const game of previouslyFlaggedGames) { + await gamesSublevel.put(`${game.shop}:${game.objectId}`, { + ...game, + newDownloadOptionsCount: undefined, + }); + clearedPayload.push({ gameId: `${game.shop}:${game.objectId}`, count: 0 }); + } + } + + // Prepare games array for API call (excluding custom games) + const games = nonCustomGames.map((game: Game) => ({ + shop: game.shop, + objectId: game.objectId + })); + + logger.info(`Checking download sources changes for ${games.length} non-custom games since ${since}`); + logger.info(`Making API call to HydraApi.checkDownloadSourcesChanges with:`, { + downloadSourceIds, + gamesCount: games.length, + since + }); + + // Call the API + const response = await HydraApi.checkDownloadSourcesChanges( + downloadSourceIds, + games, + since + ); + + logger.info("API call completed, response:", response); + + // Update the last check timestamp + await updateLastDownloadSourcesCheck(new Date().toISOString()); + + // Process the response and store newDownloadOptionsCount for games with new options + if (response && Array.isArray(response)) { + const gamesWithNewOptions: { gameId: string; count: number }[] = []; + + for (const gameUpdate of response as DownloadSourcesChangeResponse[]) { + if (gameUpdate.newDownloadOptionsCount > 0) { + const game = nonCustomGames.find(g => + g.shop === gameUpdate.shop && g.objectId === gameUpdate.objectId + ); + + if (game) { + // Store the new download options count in the game data + await gamesSublevel.put(`${game.shop}:${game.objectId}`, { + ...game, + newDownloadOptionsCount: gameUpdate.newDownloadOptionsCount + }); + + gamesWithNewOptions.push({ + gameId: `${game.shop}:${game.objectId}`, + count: gameUpdate.newDownloadOptionsCount + }); + + logger.info(`Game ${game.title} has ${gameUpdate.newDownloadOptionsCount} new download options`); + } + } + } + + // Send IPC event to renderer to clear stale badges and set fresh counts from response + const eventPayload = [...clearedPayload, ...gamesWithNewOptions]; + if (eventPayload.length > 0 && WindowManager.mainWindow) { + WindowManager.mainWindow.webContents.send("on-new-download-options", eventPayload); + } + + logger.info(`Found new download options for ${gamesWithNewOptions.length} games`); + } + + logger.info("Download sources check completed successfully"); + } catch (error) { + logger.error("Failed to check download sources changes:", error); + } + } +} \ No newline at end of file diff --git a/src/main/services/hydra-api.ts b/src/main/services/hydra-api.ts index 12090df3..4f7091db 100644 --- a/src/main/services/hydra-api.ts +++ b/src/main/services/hydra-api.ts @@ -399,4 +399,36 @@ export class HydraApi { .then((response) => response.data) .catch(this.handleUnauthorizedError); } + + static async checkDownloadSourcesChanges( + downloadSourceIds: string[], + games: Array<{ shop: string; objectId: string }>, + since: string + ) { + logger.info("HydraApi.checkDownloadSourcesChanges called with:", { + downloadSourceIds, + gamesCount: games.length, + since, + isLoggedIn: this.isLoggedIn() + }); + + try { + const result = await this.post>("/download-sources/changes", { + downloadSourceIds, + games, + since, + }, { needsAuth: true }); + + logger.info("HydraApi.checkDownloadSourcesChanges completed successfully:", result); + return result; + } catch (error) { + logger.error("HydraApi.checkDownloadSourcesChanges failed:", error); + throw error; + } + } } diff --git a/src/main/services/index.ts b/src/main/services/index.ts index da4e6848..a3891dc6 100644 --- a/src/main/services/index.ts +++ b/src/main/services/index.ts @@ -19,3 +19,4 @@ export * from "./wine"; export * from "./lock"; export * from "./decky-plugin"; export * from "./user"; +export * from "./download-sources-checker"; diff --git a/src/preload/index.ts b/src/preload/index.ts index f89ec4db..a1a0d959 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -580,6 +580,17 @@ contextBridge.exposeInMainWorld("electron", { return () => ipcRenderer.removeListener("on-custom-theme-updated", listener); }, + onNewDownloadOptions: ( + cb: (gamesWithNewOptions: { gameId: string; count: number }[]) => void + ) => { + const listener = ( + _event: Electron.IpcRendererEvent, + gamesWithNewOptions: { gameId: string; count: number }[] + ) => cb(gamesWithNewOptions); + ipcRenderer.on("on-new-download-options", listener); + return () => + ipcRenderer.removeListener("on-new-download-options", listener); + }, closeEditorWindow: (themeId?: string) => ipcRenderer.invoke("closeEditorWindow", themeId), }); diff --git a/src/renderer/src/app.tsx b/src/renderer/src/app.tsx index 168a4435..274e95db 100644 --- a/src/renderer/src/app.tsx +++ b/src/renderer/src/app.tsx @@ -10,6 +10,7 @@ import { useToast, useUserDetails, } from "@renderer/hooks"; +import { useDownloadOptionsListener } from "@renderer/hooks/use-download-options-listener"; import { Outlet, useLocation, useNavigate } from "react-router-dom"; import { @@ -36,6 +37,9 @@ export function App() { const contentRef = useRef(null); const { updateLibrary, library } = useLibrary(); + // Listen for new download options updates + useDownloadOptionsListener(); + const { t } = useTranslation("app"); const { clearDownload, setLastPacket } = useDownload(); diff --git a/src/renderer/src/components/sidebar/sidebar-game-item.tsx b/src/renderer/src/components/sidebar/sidebar-game-item.tsx index 356aa913..add7e081 100644 --- a/src/renderer/src/components/sidebar/sidebar-game-item.tsx +++ b/src/renderer/src/components/sidebar/sidebar-game-item.tsx @@ -80,6 +80,13 @@ export function SidebarGameItem({ {getGameTitle(game)} + + {game.newDownloadOptionsCount && game.newDownloadOptionsCount > 0 && ( + +
+
+
{game.newDownloadOptionsCount}
+
+ )} diff --git a/src/renderer/src/components/sidebar/sidebar.scss b/src/renderer/src/components/sidebar/sidebar.scss index 49f6e007..9068e2b5 100644 --- a/src/renderer/src/components/sidebar/sidebar.scss +++ b/src/renderer/src/components/sidebar/sidebar.scss @@ -115,6 +115,26 @@ background-size: cover; } + &__game-badge { + background: rgba(255, 255, 255, 0.1);; + color: #fff; + font-size: 10px; + font-weight: bold; + padding: 4px 6px; + border-radius: 6px; + display: flex; + margin-left: auto; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); + gap: calc(globals.$spacing-unit * 0.35); + } + + &__game-badge-plus, + &__game-badge-count { + display: flex; + align-items: center; + justify-content: center; + } + &__section-header { display: flex; justify-content: space-between; diff --git a/src/renderer/src/declaration.d.ts b/src/renderer/src/declaration.d.ts index fa4ab3d6..30b5c67a 100644 --- a/src/renderer/src/declaration.d.ts +++ b/src/renderer/src/declaration.d.ts @@ -414,6 +414,11 @@ declare global { openEditorWindow: (themeId: string) => Promise; onCustomThemeUpdated: (cb: () => void) => () => Electron.IpcRenderer; closeEditorWindow: (themeId?: string) => Promise; + + /* Download Options */ + onNewDownloadOptions: ( + cb: (gamesWithNewOptions: { gameId: string; count: number }[]) => void + ) => () => Electron.IpcRenderer; } interface Window { diff --git a/src/renderer/src/features/library-slice.ts b/src/renderer/src/features/library-slice.ts index 6c95aa79..e92e6a25 100644 --- a/src/renderer/src/features/library-slice.ts +++ b/src/renderer/src/features/library-slice.ts @@ -18,7 +18,25 @@ export const librarySlice = createSlice({ setLibrary: (state, action: PayloadAction) => { state.value = action.payload; }, + updateGameNewDownloadOptions: ( + state, + action: PayloadAction<{ gameId: string; count: number }> + ) => { + const game = state.value.find((g) => g.id === action.payload.gameId); + if (game) { + game.newDownloadOptionsCount = action.payload.count; + } + }, + clearNewDownloadOptions: ( + state, + action: PayloadAction<{ gameId: string }> + ) => { + const game = state.value.find((g) => g.id === action.payload.gameId); + if (game) { + game.newDownloadOptionsCount = undefined; + } + }, }, }); -export const { setLibrary } = librarySlice.actions; +export const { setLibrary, updateGameNewDownloadOptions, clearNewDownloadOptions } = librarySlice.actions; diff --git a/src/renderer/src/hooks/index.ts b/src/renderer/src/hooks/index.ts index 73733e2b..2d2ee02f 100644 --- a/src/renderer/src/hooks/index.ts +++ b/src/renderer/src/hooks/index.ts @@ -6,3 +6,4 @@ export * from "./redux"; export * from "./use-user-details"; export * from "./use-format"; export * from "./use-feature"; +export * from "./use-download-options-listener"; diff --git a/src/renderer/src/hooks/use-download-options-listener.ts b/src/renderer/src/hooks/use-download-options-listener.ts new file mode 100644 index 00000000..f0268335 --- /dev/null +++ b/src/renderer/src/hooks/use-download-options-listener.ts @@ -0,0 +1,19 @@ +import { useEffect } from "react"; +import { useAppDispatch } from "./redux"; +import { updateGameNewDownloadOptions } from "@renderer/features"; + +export function useDownloadOptionsListener() { + const dispatch = useAppDispatch(); + + useEffect(() => { + const unsubscribe = window.electron.onNewDownloadOptions( + (gamesWithNewOptions) => { + gamesWithNewOptions.forEach(({ gameId, count }) => { + dispatch(updateGameNewDownloadOptions({ gameId, count })); + }); + } + ); + + return unsubscribe; + }, [dispatch]); +} \ No newline at end of file diff --git a/src/types/level.types.ts b/src/types/level.types.ts index 1df55b9e..ff602ac9 100644 --- a/src/types/level.types.ts +++ b/src/types/level.types.ts @@ -59,6 +59,7 @@ export interface Game { pinnedDate?: Date | null; automaticCloudSync?: boolean; hasManuallyUpdatedPlaytime?: boolean; + newDownloadOptionsCount?: number; } export interface Download {