diff --git a/.cursorrules b/.cursorrules
index 0b0c009c..5015ab7e 100644
--- a/.cursorrules
+++ b/.cursorrules
@@ -27,3 +27,11 @@
- Follow TypeScript strict mode conventions
- Use async/await instead of promises when possible
- Prefer named exports over default exports for utilities and services
+
+## Comments
+
+- Keep comments concise and purposeful; avoid verbose explanations.
+- Focus on the "why" or non-obvious context, not restating the code.
+- Prefer self-explanatory naming and structure over excessive comments.
+- Do not comment every line or obvious behavior; remove stale comments.
+- Use docblocks only where they add value (public APIs, complex logic).
diff --git a/.github/workflows/update-aur.yml b/.github/workflows/update-aur.yml
index 52fe907e..fa12b500 100644
--- a/.github/workflows/update-aur.yml
+++ b/.github/workflows/update-aur.yml
@@ -95,6 +95,8 @@ jobs:
- name: Update PKGBUILD and .SRCINFO
if: steps.check-update.outputs.update_needed == 'true'
run: |
+ # sleeps for 1 minute to be sure GH updated the release info
+ sleep 60
# Update pkgver in PKGBUILD
cd hydra-launcher-bin
NEW_VERSION="${{ steps.get-version.outputs.version }}"
diff --git a/package.json b/package.json
index 5d84e763..ee039574 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "hydralauncher",
- "version": "3.7.2",
+ "version": "3.7.3",
"description": "Hydra",
"main": "./out/main/index.js",
"author": "Los Broxas",
diff --git a/src/main/services/achievements/achievement-watcher-manager.ts b/src/main/services/achievements/achievement-watcher-manager.ts
index b862abbe..dd65165a 100644
--- a/src/main/services/achievements/achievement-watcher-manager.ts
+++ b/src/main/services/achievements/achievement-watcher-manager.ts
@@ -167,6 +167,8 @@ export class AchievementWatcherManager {
shop: GameShop,
objectId: string
) {
+ if (shop === "custom") return;
+
const gameKey = levelKeys.game(shop, objectId);
if (this.alreadySyncedGames.get(gameKey)) return;
diff --git a/src/main/services/library-sync/create-game.ts b/src/main/services/library-sync/create-game.ts
index a346d3b4..e9ec9612 100644
--- a/src/main/services/library-sync/create-game.ts
+++ b/src/main/services/library-sync/create-game.ts
@@ -3,6 +3,10 @@ import { HydraApi } from "../hydra-api";
import { gamesSublevel, levelKeys } from "@main/level";
export const createGame = async (game: Game) => {
+ if (game.shop === "custom") {
+ return;
+ }
+
return HydraApi.post(`/profile/games`, {
objectId: game.objectId,
playTimeInMilliseconds: Math.trunc(game.playTimeInMilliseconds ?? 0),
diff --git a/src/main/services/library-sync/update-game-playtime.ts b/src/main/services/library-sync/update-game-playtime.ts
index 3689b302..a669a363 100644
--- a/src/main/services/library-sync/update-game-playtime.ts
+++ b/src/main/services/library-sync/update-game-playtime.ts
@@ -1,12 +1,16 @@
import type { Game } from "@types";
import { HydraApi } from "../hydra-api";
-export const updateGamePlaytime = async (
+export const trackGamePlaytime = async (
game: Game,
deltaInMillis: number,
lastTimePlayed: Date
) => {
- return HydraApi.put(`/profile/games/${game.remoteId}`, {
+ if (game.shop === "custom") {
+ return;
+ }
+
+ return HydraApi.put(`/profile/games/${game.shop}/${game.objectId}`, {
playTimeDeltaInSeconds: Math.trunc(deltaInMillis / 1000),
lastTimePlayed,
});
diff --git a/src/main/services/process-watcher.ts b/src/main/services/process-watcher.ts
index 6408c30d..db5bbee1 100644
--- a/src/main/services/process-watcher.ts
+++ b/src/main/services/process-watcher.ts
@@ -1,5 +1,5 @@
import { WindowManager } from "./window-manager";
-import { createGame, updateGamePlaytime } from "./library-sync";
+import { createGame, trackGamePlaytime } from "./library-sync";
import type { Game, GameRunning, UserPreferences } from "@types";
import { PythonRPC } from "./python-rpc";
import axios from "axios";
@@ -198,11 +198,6 @@ export const watchProcesses = async () => {
function onOpenGame(game: Game) {
const now = performance.now();
- AchievementWatcherManager.firstSyncWithRemoteIfNeeded(
- game.shop,
- game.objectId
- );
-
gamesPlaytime.set(levelKeys.game(game.shop, game.objectId), {
lastTick: now,
firstTick: now,
@@ -220,8 +215,15 @@ function onOpenGame(game: Game) {
})
.catch(() => {});
+ if (game.shop === "custom") return;
+
+ AchievementWatcherManager.firstSyncWithRemoteIfNeeded(
+ game.shop,
+ game.objectId
+ );
+
if (game.remoteId) {
- updateGamePlaytime(
+ trackGamePlaytime(
game,
game.unsyncedDeltaPlayTimeInMilliseconds ?? 0,
new Date()
@@ -255,43 +257,46 @@ function onTickGame(game: Game) {
const delta = now - gamePlaytime.lastTick;
- gamesSublevel.put(levelKeys.game(game.shop, game.objectId), {
+ const updatedGame: Game = {
...game,
playTimeInMilliseconds: (game.playTimeInMilliseconds ?? 0) + delta,
lastTimePlayed: new Date(),
- });
+ };
+
+ gamesSublevel.put(levelKeys.game(game.shop, game.objectId), updatedGame);
gamesPlaytime.set(levelKeys.game(game.shop, game.objectId), {
...gamePlaytime,
lastTick: now,
});
- if (currentTick % TICKS_TO_UPDATE_API === 0) {
+ if (currentTick % TICKS_TO_UPDATE_API === 0 && game.shop !== "custom") {
const deltaToSync =
now -
gamePlaytime.lastSyncTick +
(game.unsyncedDeltaPlayTimeInMilliseconds ?? 0);
const gamePromise = game.remoteId
- ? updateGamePlaytime(game, deltaToSync, game.lastTimePlayed!)
+ ? trackGamePlaytime(game, deltaToSync, game.lastTimePlayed!)
: createGame(game);
gamePromise
.then(() => {
gamesSublevel.put(levelKeys.game(game.shop, game.objectId), {
- ...game,
+ ...updatedGame,
unsyncedDeltaPlayTimeInMilliseconds: 0,
});
})
.catch(() => {
gamesSublevel.put(levelKeys.game(game.shop, game.objectId), {
- ...game,
+ ...updatedGame,
unsyncedDeltaPlayTimeInMilliseconds: deltaToSync,
});
})
.finally(() => {
gamesPlaytime.set(levelKeys.game(game.shop, game.objectId), {
...gamePlaytime,
+ lastTick: now,
lastSyncTick: now,
});
});
@@ -299,11 +304,24 @@ function onTickGame(game: Game) {
}
const onCloseGame = (game: Game) => {
+ const now = performance.now();
const gamePlaytime = gamesPlaytime.get(
levelKeys.game(game.shop, game.objectId)
)!;
gamesPlaytime.delete(levelKeys.game(game.shop, game.objectId));
+ const delta = now - gamePlaytime.lastTick;
+
+ const updatedGame: Game = {
+ ...game,
+ playTimeInMilliseconds: (game.playTimeInMilliseconds ?? 0) + delta,
+ lastTimePlayed: new Date(),
+ };
+
+ gamesSublevel.put(levelKeys.game(game.shop, game.objectId), updatedGame);
+
+ if (game.shop === "custom") return;
+
if (game.remoteId) {
if (game.automaticCloudSync) {
CloudSync.uploadSaveGame(
@@ -315,20 +333,20 @@ const onCloseGame = (game: Game) => {
}
const deltaToSync =
- performance.now() -
+ now -
gamePlaytime.lastSyncTick +
(game.unsyncedDeltaPlayTimeInMilliseconds ?? 0);
- return updateGamePlaytime(game, deltaToSync, game.lastTimePlayed!)
+ return trackGamePlaytime(game, deltaToSync, game.lastTimePlayed!)
.then(() => {
return gamesSublevel.put(levelKeys.game(game.shop, game.objectId), {
- ...game,
+ ...updatedGame,
unsyncedDeltaPlayTimeInMilliseconds: 0,
});
})
.catch(() => {
return gamesSublevel.put(levelKeys.game(game.shop, game.objectId), {
- ...game,
+ ...updatedGame,
unsyncedDeltaPlayTimeInMilliseconds: deltaToSync,
});
});
diff --git a/src/main/services/window-manager.ts b/src/main/services/window-manager.ts
index 673bf1a0..b11b4a9b 100644
--- a/src/main/services/window-manager.ts
+++ b/src/main/services/window-manager.ts
@@ -289,12 +289,6 @@ export class WindowManager {
}
}
- private static loadNotificationWindowURL() {
- if (this.notificationWindow) {
- this.loadWindowURL(this.notificationWindow, "achievement-notification");
- }
- }
-
private static readonly NOTIFICATION_WINDOW_WIDTH = 360;
private static readonly NOTIFICATION_WINDOW_HEIGHT = 140;
@@ -302,46 +296,58 @@ export class WindowManager {
position: AchievementCustomNotificationPosition | undefined
) {
const display = screen.getPrimaryDisplay();
- const { width, height } = display.workAreaSize;
+ const {
+ x: displayX,
+ y: displayY,
+ width: displayWidth,
+ height: displayHeight,
+ } = display.bounds;
if (position === "bottom-left") {
return {
- x: 0,
- y: height - this.NOTIFICATION_WINDOW_HEIGHT,
+ x: displayX,
+ y: displayY + displayHeight - this.NOTIFICATION_WINDOW_HEIGHT,
};
}
if (position === "bottom-center") {
return {
- x: (width - this.NOTIFICATION_WINDOW_WIDTH) / 2,
- y: height - this.NOTIFICATION_WINDOW_HEIGHT,
+ x: displayX + (displayWidth - this.NOTIFICATION_WINDOW_WIDTH) / 2,
+ y: displayY + displayHeight - this.NOTIFICATION_WINDOW_HEIGHT,
};
}
if (position === "bottom-right") {
return {
- x: width - this.NOTIFICATION_WINDOW_WIDTH,
- y: height - this.NOTIFICATION_WINDOW_HEIGHT,
+ x: displayX + displayWidth - this.NOTIFICATION_WINDOW_WIDTH,
+ y: displayY + displayHeight - this.NOTIFICATION_WINDOW_HEIGHT,
+ };
+ }
+
+ if (position === "top-left") {
+ return {
+ x: displayX,
+ y: displayY,
};
}
if (position === "top-center") {
return {
- x: (width - this.NOTIFICATION_WINDOW_WIDTH) / 2,
- y: 0,
+ x: displayX + (displayWidth - this.NOTIFICATION_WINDOW_WIDTH) / 2,
+ y: displayY,
};
}
if (position === "top-right") {
return {
- x: width - this.NOTIFICATION_WINDOW_WIDTH,
- y: 0,
+ x: displayX + displayWidth - this.NOTIFICATION_WINDOW_WIDTH,
+ y: displayY,
};
}
return {
- x: 0,
- y: 0,
+ x: displayX,
+ y: displayY,
};
}
@@ -387,7 +393,7 @@ export class WindowManager {
this.notificationWindow.setIgnoreMouseEvents(true);
this.notificationWindow.setAlwaysOnTop(true, "screen-saver", 1);
- this.loadNotificationWindowURL();
+ this.loadWindowURL(this.notificationWindow, "achievement-notification");
if (!app.isPackaged || isStaging) {
this.notificationWindow.webContents.openDevTools();
diff --git a/src/renderer/src/pages/catalogue/pagination.tsx b/src/renderer/src/pages/catalogue/pagination.tsx
index 9febc8f8..ecc2afe3 100644
--- a/src/renderer/src/pages/catalogue/pagination.tsx
+++ b/src/renderer/src/pages/catalogue/pagination.tsx
@@ -29,9 +29,11 @@ function JumpControl({
return isOpen ? (
) {
const { formatNumber } = useFormat();
const [isJumpOpen, setIsJumpOpen] = useState(false);
@@ -87,13 +89,15 @@ export function Pagination({
}
const onJumpChange = (e: ChangeEvent) => {
- const val = e.target.value;
- if (val === "") {
+ const raw = e.target.value;
+ const digitsOnly = raw.replaceAll(/\D+/g, "");
+ if (digitsOnly === "") {
setJumpValue("");
return;
}
- const num = Number(val);
+ const num = Number.parseInt(digitsOnly, 10);
if (Number.isNaN(num)) {
+ setJumpValue("");
return;
}
if (num < 1) {
@@ -104,19 +108,36 @@ export function Pagination({
setJumpValue(String(totalPages));
return;
}
- setJumpValue(val);
+ setJumpValue(String(num));
};
const onJumpKeyDown = (e: KeyboardEvent) => {
+ const controlKeys = [
+ "Backspace",
+ "Delete",
+ "Tab",
+ "ArrowLeft",
+ "ArrowRight",
+ "Home",
+ "End",
+ ];
+
+ if (controlKeys.includes(e.key) || e.ctrlKey || e.metaKey) {
+ return;
+ }
+
if (e.key === "Enter") {
- if (jumpValue.trim() === "") return;
- const parsed = Number(jumpValue);
+ const sanitized = jumpValue.replaceAll(/\D+/g, "");
+ if (sanitized.trim() === "") return;
+ const parsed = Number.parseInt(sanitized, 10);
if (Number.isNaN(parsed)) return;
const target = Math.max(1, Math.min(totalPages, parsed));
onPageChange(target);
setIsJumpOpen(false);
} else if (e.key === "Escape") {
setIsJumpOpen(false);
+ } else if (!/^\d$/.test(e.key)) {
+ e.preventDefault();
}
};
diff --git a/src/renderer/src/pages/game-details/review-item.tsx b/src/renderer/src/pages/game-details/review-item.tsx
index 0d834779..e2582f04 100644
--- a/src/renderer/src/pages/game-details/review-item.tsx
+++ b/src/renderer/src/pages/game-details/review-item.tsx
@@ -83,7 +83,8 @@ export function ReviewItem({
const needsTranslation =
!isOwnReview && isDifferentLanguage && review.translations[userLanguage];
- const getLanguageName = (languageCode: string) => {
+ const getLanguageName = (languageCode: string | null) => {
+ if (!languageCode) return "";
try {
const displayNames = new Intl.DisplayNames([i18n.language], {
type: "language",
@@ -184,7 +185,7 @@ export function ReviewItem({
{showOriginal
? t("hide_original")
: t("show_original_translated_from", {
- language: getLanguageName(review.detectedLanguage!),
+ language: getLanguageName(review.detectedLanguage),
})}
{showOriginal && (
diff --git a/src/renderer/src/pages/settings/settings-download-sources.tsx b/src/renderer/src/pages/settings/settings-download-sources.tsx
index 75f0cc73..675919e3 100644
--- a/src/renderer/src/pages/settings/settings-download-sources.tsx
+++ b/src/renderer/src/pages/settings/settings-download-sources.tsx
@@ -89,7 +89,7 @@ export function SettingsDownloadSources() {
try {
await window.electron.removeDownloadSource(false, downloadSource.id);
const sources = await window.electron.getDownloadSources();
- setDownloadSources(sources as DownloadSource[]);
+ setDownloadSources(sources);
showSuccessToast(t("removed_download_source"));
} catch (error) {
logger.error("Failed to remove download source:", error);
@@ -104,7 +104,7 @@ export function SettingsDownloadSources() {
try {
await window.electron.removeDownloadSource(true);
const sources = await window.electron.getDownloadSources();
- setDownloadSources(sources as DownloadSource[]);
+ setDownloadSources(sources);
showSuccessToast(t("removed_all_download_sources"));
} catch (error) {
logger.error("Failed to remove all download sources:", error);
@@ -117,7 +117,7 @@ export function SettingsDownloadSources() {
const handleAddDownloadSource = async () => {
try {
const sources = await window.electron.getDownloadSources();
- setDownloadSources(sources as DownloadSource[]);
+ setDownloadSources(sources);
} catch (error) {
logger.error("Failed to refresh download sources:", error);
}
@@ -128,7 +128,7 @@ export function SettingsDownloadSources() {
try {
await window.electron.syncDownloadSources();
const sources = await window.electron.getDownloadSources();
- setDownloadSources(sources as DownloadSource[]);
+ setDownloadSources(sources);
showSuccessToast(t("download_sources_synced_successfully"));
} finally {
diff --git a/src/types/index.ts b/src/types/index.ts
index 4b13c496..c04b6232 100644
--- a/src/types/index.ts
+++ b/src/types/index.ts
@@ -252,7 +252,7 @@ export interface GameReview {
translations: {
[key: string]: string;
};
- detectedLanguage: string;
+ detectedLanguage: string | null;
}
export interface TrendingGame extends ShopAssets {