mirror of
https://github.com/hydralauncher/hydra.git
synced 2026-01-19 01:03:57 +00:00
feat: sidebar badge on new game download option
This commit is contained in:
16
src/main/level/sublevels/downloadSourcesCheckTimestamp.ts
Normal file
16
src/main/level/sublevels/downloadSourcesCheckTimestamp.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { levelKeys } from "./keys";
|
||||
import { db } from "../level";
|
||||
|
||||
export const getLastDownloadSourcesCheck = async (): Promise<string | null> => {
|
||||
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<void> => {
|
||||
await db.put(levelKeys.lastDownloadSourcesCheck, timestamp);
|
||||
};
|
||||
@@ -7,3 +7,4 @@ export * from "./game-achievements";
|
||||
export * from "./keys";
|
||||
export * from "./themes";
|
||||
export * from "./download-sources";
|
||||
export * from "./downloadSourcesCheckTimestamp";
|
||||
|
||||
@@ -18,4 +18,5 @@ export const levelKeys = {
|
||||
screenState: "screenState",
|
||||
rpcPassword: "rpcPassword",
|
||||
downloadSources: "downloadSources",
|
||||
lastDownloadSourcesCheck: "lastDownloadSourcesCheck",
|
||||
};
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
|
||||
127
src/main/services/download-sources-checker.ts
Normal file
127
src/main/services/download-sources-checker.ts
Normal file
@@ -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<void> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<Array<{
|
||||
shop: string;
|
||||
objectId: string;
|
||||
newDownloadOptionsCount: number;
|
||||
downloadSourceIds: string[];
|
||||
}>>("/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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,3 +19,4 @@ export * from "./wine";
|
||||
export * from "./lock";
|
||||
export * from "./decky-plugin";
|
||||
export * from "./user";
|
||||
export * from "./download-sources-checker";
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
|
||||
@@ -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<HTMLDivElement>(null);
|
||||
const { updateLibrary, library } = useLibrary();
|
||||
|
||||
// Listen for new download options updates
|
||||
useDownloadOptionsListener();
|
||||
|
||||
const { t } = useTranslation("app");
|
||||
|
||||
const { clearDownload, setLastPacket } = useDownload();
|
||||
|
||||
@@ -80,6 +80,13 @@ export function SidebarGameItem({
|
||||
<span className="sidebar__menu-item-button-label">
|
||||
{getGameTitle(game)}
|
||||
</span>
|
||||
|
||||
{game.newDownloadOptionsCount && game.newDownloadOptionsCount > 0 && (
|
||||
<span className="sidebar__game-badge">
|
||||
<div className="sidebar__game-badge-plus">+</div>
|
||||
<div className="sidebar__game-badge-count">{game.newDownloadOptionsCount}</div>
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
</li>
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
5
src/renderer/src/declaration.d.ts
vendored
5
src/renderer/src/declaration.d.ts
vendored
@@ -414,6 +414,11 @@ declare global {
|
||||
openEditorWindow: (themeId: string) => Promise<void>;
|
||||
onCustomThemeUpdated: (cb: () => void) => () => Electron.IpcRenderer;
|
||||
closeEditorWindow: (themeId?: string) => Promise<void>;
|
||||
|
||||
/* Download Options */
|
||||
onNewDownloadOptions: (
|
||||
cb: (gamesWithNewOptions: { gameId: string; count: number }[]) => void
|
||||
) => () => Electron.IpcRenderer;
|
||||
}
|
||||
|
||||
interface Window {
|
||||
|
||||
@@ -18,7 +18,25 @@ export const librarySlice = createSlice({
|
||||
setLibrary: (state, action: PayloadAction<LibraryState["value"]>) => {
|
||||
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;
|
||||
|
||||
@@ -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";
|
||||
|
||||
19
src/renderer/src/hooks/use-download-options-listener.ts
Normal file
19
src/renderer/src/hooks/use-download-options-listener.ts
Normal file
@@ -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]);
|
||||
}
|
||||
@@ -59,6 +59,7 @@ export interface Game {
|
||||
pinnedDate?: Date | null;
|
||||
automaticCloudSync?: boolean;
|
||||
hasManuallyUpdatedPlaytime?: boolean;
|
||||
newDownloadOptionsCount?: number;
|
||||
}
|
||||
|
||||
export interface Download {
|
||||
|
||||
Reference in New Issue
Block a user