Compare commits

...

27 Commits

Author SHA1 Message Date
Zamitto
e089ca8705 Merge pull request #1773 from Lianela/main
update spanish translation
2025-06-26 20:04:49 -03:00
Zamitto
bf8fd0dacf feat: optimizations 2025-06-26 19:37:28 -03:00
Lianela
c9b289cbde Update translation.json 2025-06-20 02:08:04 -06:00
Lianela
45b822ba10 Update translation.json
fixed some mistakes / missing string
2025-06-20 02:07:31 -06:00
Zamitto
cb758cceda chore: change header 2025-06-11 07:13:57 -03:00
Zamitto
8b804271bd Merge pull request #1770 from hydrasources/patch-17
Update translation.json
2025-06-11 06:03:55 -03:00
Zamitto
57813784d2 Merge pull request #1759 from hydralauncher/feat/optimize-achievements-sync
feat: optimize achievements sync [HYD-863]
2025-06-11 06:03:44 -03:00
Zamitto
7cbb8a00c4 chor: bump version 2025-06-11 05:59:44 -03:00
Zamitto
4a4cb57348 feat: refactor 2025-06-11 05:58:56 -03:00
Zamitto
7a82467933 feat: refactor 2025-06-11 05:49:37 -03:00
Zamitto
6a65d191af fix: text alignment 2025-06-11 05:47:23 -03:00
hydrasources
d047d7a105 Update translation.json 2025-06-09 14:13:07 +03:00
Zamitto
f05d0c2047 chore: remove unwanted files 2025-06-06 17:13:27 -03:00
Zamitto
98e5b70f2e chore: unused import 2025-06-06 17:12:06 -03:00
Zamitto
100ddd79aa feat: add If-Modified-Since header to get-game-achievement-data 2025-06-06 16:52:40 -03:00
Zamitto
0b2d4e2ba0 feat: update CatalogueSearchResult types 2025-06-03 16:22:21 -03:00
Zamitto
0c379d6c49 feat: combined notification when add game to library 2025-06-03 16:04:29 -03:00
Zamitto
8e6f9fdb00 fix: syncing achievements when game is deleted 2025-06-03 15:53:40 -03:00
Zamitto
6b1713e54b fix: achievement notification showing 0 points 2025-06-03 15:32:50 -03:00
Zamitto
44db5f9813 feat: use promise all on pre search achievement 2025-06-03 15:32:30 -03:00
Zamitto
7d0fbbd960 feat: small refactor 2025-06-03 13:40:23 -03:00
Zamitto
4552256038 feat: small refactor 2025-06-03 12:23:50 -03:00
Zamitto
c5be5e94e8 feat: add delay to pre search achievements 2025-06-03 11:26:01 -03:00
Zamitto
3a6693c8b1 feat: small refactor to achievements 2025-06-03 09:47:08 -03:00
Zamitto
e9032ae6e4 feat: better friend code validation on add friend modal 2025-06-03 07:12:53 -03:00
Zamitto
7202f740d3 feat: sync on open game 2025-06-02 21:46:00 -03:00
Zamitto
2a74526b0f feat: optimize achievements sync 2025-06-02 21:42:21 -03:00
38 changed files with 288 additions and 212 deletions

View File

@@ -1,6 +1,6 @@
{ {
"name": "hydralauncher", "name": "hydralauncher",
"version": "3.6.0", "version": "3.6.2",
"description": "Hydra", "description": "Hydra",
"main": "./out/main/index.js", "main": "./out/main/index.js",
"author": "Los Broxas", "author": "Los Broxas",

View File

@@ -102,7 +102,12 @@ def process_list():
if auth_error: if auth_error:
return auth_error return auth_error
process_list = [proc.info for proc in psutil.process_iter(['exe', 'cwd', 'pid', 'name', 'environ'])] iter_list = ['exe', 'pid', 'name']
if sys.platform != 'win32':
iter_list.append('cwd')
iter_list.append('environ')
process_list = [proc.info for proc in psutil.process_iter(iter_list)]
return jsonify(process_list), 200 return jsonify(process_list), 200
@app.route("/profile-image", methods=["POST"]) @app.route("/profile-image", methods=["POST"])

View File

@@ -515,7 +515,8 @@
"earned_points": "Earned points", "earned_points": "Earned points",
"show_achievements_on_profile": "Show your achievements on your profile", "show_achievements_on_profile": "Show your achievements on your profile",
"show_points_on_profile": "Show your earned points on your profile", "show_points_on_profile": "Show your earned points on your profile",
"error_adding_friend": "Could not send friend request. Please check friend code" "error_adding_friend": "Could not send friend request. Please check friend code",
"friend_code_length_error": "Friend code must have 8 characters"
}, },
"achievement": { "achievement": {
"achievement_unlocked": "Achievement unlocked", "achievement_unlocked": "Achievement unlocked",

View File

@@ -141,7 +141,7 @@
"allow_nsfw_content": "Continuar", "allow_nsfw_content": "Continuar",
"refuse_nsfw_content": "No, gracias", "refuse_nsfw_content": "No, gracias",
"stats": "Estadísticas", "stats": "Estadísticas",
"download_count": "Downloads", "download_count": "Descargas",
"player_count": "Jugadores activos", "player_count": "Jugadores activos",
"download_error": "Esta opción de descarga no está disponible.", "download_error": "Esta opción de descarga no está disponible.",
"download": "Descargar", "download": "Descargar",
@@ -199,12 +199,12 @@
"download_error_gofile_quota_exceeded": "Has excedido la cuota mensual de Gofile. Por favor espera a que se reinicie la cuota.", "download_error_gofile_quota_exceeded": "Has excedido la cuota mensual de Gofile. Por favor espera a que se reinicie la cuota.",
"download_error_real_debrid_account_not_authorized": "Tu cuenta de Real-Debrid no está autorizada para nueva descargas. Por favor, revisa los ajustes de tu cuenta e intenta de nuevo.", "download_error_real_debrid_account_not_authorized": "Tu cuenta de Real-Debrid no está autorizada para nueva descargas. Por favor, revisa los ajustes de tu cuenta e intenta de nuevo.",
"download_error_not_cached_on_real_debrid": "Esta descarga no está disponible en Real-Debrid y el estado de descarga del sondeo de Real-Debrid aún no está disponible.", "download_error_not_cached_on_real_debrid": "Esta descarga no está disponible en Real-Debrid y el estado de descarga del sondeo de Real-Debrid aún no está disponible.",
"download_error_not_cached_on_torbox": "Esta descarga no está disponible en TorBox y el estado de descarga del sondeo aún no está disponible.", "download_error_not_cached_on_torbox": "Esta descarga no está disponible en TorBox y aún no se puede verificar el estado de la descarga.",
"game_added_to_favorites": "Juego añadido a favoritos", "game_added_to_favorites": "Juego añadido a favoritos",
"game_removed_from_favorites": "Juego removido de favoritos", "game_removed_from_favorites": "Juego removido de favoritos",
"invalid_wine_prefix_path": "Ruta de prefixo Wine inválida", "invalid_wine_prefix_path": "Ruta de prefijo de Wine inválida",
"invalid_wine_prefix_path_description": "La ruta al prefixo Wine es inválida. Por favor, verifica la ruta y vuelve a intentarlo.", "invalid_wine_prefix_path_description": "La ruta del prefijo Wine es inválida. Por favor, checa la ruta y vuelve a intentarlo.",
"missing_wine_prefix": "" "missing_wine_prefix": "Se requiere el prefijo Wine para crear una copia de seguridad en Linux"
}, },
"activation": { "activation": {
"title": "Activar Hydra", "title": "Activar Hydra",

View File

@@ -508,7 +508,8 @@
"earned_points": "Pontos ganhos", "earned_points": "Pontos ganhos",
"show_achievements_on_profile": "Exiba suas conquistas no perfil", "show_achievements_on_profile": "Exiba suas conquistas no perfil",
"show_points_on_profile": "Exiba seus pontos ganhos no perfil", "show_points_on_profile": "Exiba seus pontos ganhos no perfil",
"error_adding_friend": "Não foi possível enviar o pedido de amizade. Verifique o código de amizade inserido" "error_adding_friend": "Não foi possível enviar o pedido de amizade. Verifique o código de amizade inserido",
"friend_code_length_error": "Código de amigo deve ter 8 caracteres"
}, },
"achievement": { "achievement": {
"achievement_unlocked": "Conquista desbloqueada", "achievement_unlocked": "Conquista desbloqueada",

View File

@@ -365,14 +365,14 @@
"installing_common_redist": "Установка…", "installing_common_redist": "Установка…",
"show_download_speed_in_megabytes": "Показать скорость загрузки в мегабайтах в секунду", "show_download_speed_in_megabytes": "Показать скорость загрузки в мегабайтах в секунду",
"extract_files_by_default": "Извлекать файлы по умолчанию после загрузки", "extract_files_by_default": "Извлекать файлы по умолчанию после загрузки",
"achievement_custom_notification_position": "Позиция настраиваемых уведомлений о достижениях", "achievement_custom_notification_position": "Позиция уведомлений достижений",
"top-left": "Верхний левый угол", "top-left": "Верхний левый угол",
"top-center": "Верхний центр", "top-center": "Верхний центр",
"top-right": "Верхний правый угол", "top-right": "Верхний правый угол",
"bottom-left": "Нижний левый угол", "bottom-left": "Нижний левый угол",
"bottom-center": "Нижний центр", "bottom-center": "Нижний центр",
"bottom-right": "Нижний правый угол", "bottom-right": "Нижний правый угол",
"enable_achievement_custom_notifications": "Включить настраиваемые уведомления о достижениях", "enable_achievement_custom_notifications": "Включить уведомления о достижениях",
"alignment": "Выравнивание", "alignment": "Выравнивание",
"variation": "Вариация", "variation": "Вариация",
"default": "По умолчанию", "default": "По умолчанию",

View File

@@ -1,17 +1,38 @@
import type { GameShop, GameStats } from "@types"; import type { GameShop, GameStats } from "@types";
import { registerEvent } from "../register-event"; import { registerEvent } from "../register-event";
import { HydraApi } from "@main/services"; import { HydraApi } from "@main/services";
import { gamesStatsCacheSublevel, levelKeys } from "@main/level";
const LOCAL_CACHE_EXPIRATION = 1000 * 60 * 30; // 30 minutes
const getGameStats = async ( const getGameStats = async (
_event: Electron.IpcMainInvokeEvent, _event: Electron.IpcMainInvokeEvent,
objectId: string, objectId: string,
shop: GameShop shop: GameShop
) => { ) => {
const cachedStats = await gamesStatsCacheSublevel.get(
levelKeys.game(shop, objectId)
);
if (
cachedStats &&
cachedStats.updatedAt + LOCAL_CACHE_EXPIRATION > Date.now()
) {
return cachedStats;
}
return HydraApi.get<GameStats>( return HydraApi.get<GameStats>(
`/games/stats`, `/games/stats`,
{ objectId, shop }, { objectId, shop },
{ needsAuth: false } { needsAuth: false }
); ).then(async (data) => {
await gamesStatsCacheSublevel.put(levelKeys.game(shop, objectId), {
...data,
updatedAt: Date.now(),
});
return data;
});
}; };
registerEvent("getGameStats", getGameStats); registerEvent("getGameStats", getGameStats);

View File

@@ -8,7 +8,9 @@ const saveGameShopAssets = async (
shop: GameShop, shop: GameShop,
assets: ShopAssets assets: ShopAssets
): Promise<void> => { ): Promise<void> => {
return gamesShopAssetsSublevel.put(levelKeys.game(shop, objectId), assets); const key = levelKeys.game(shop, objectId);
const existingAssets = await gamesShopAssetsSublevel.get(key);
return gamesShopAssetsSublevel.put(key, { ...existingAssets, ...assets });
}; };
registerEvent("saveGameShopAssets", saveGameShopAssets); registerEvent("saveGameShopAssets", saveGameShopAssets);

View File

@@ -20,7 +20,6 @@ import "./library/create-game-shortcut";
import "./library/close-game"; import "./library/close-game";
import "./library/delete-game-folder"; import "./library/delete-game-folder";
import "./library/get-game-by-object-id"; import "./library/get-game-by-object-id";
import "./library/sync-game-by-object-id";
import "./library/get-library"; import "./library/get-library";
import "./library/extract-game-download"; import "./library/extract-game-download";
import "./library/open-game"; import "./library/open-game";

View File

@@ -1,13 +1,13 @@
import { registerEvent } from "../register-event"; import { registerEvent } from "../register-event";
import type { GameShop } from "@types"; import type { GameShop } from "@types";
import { createGame } from "@main/services/library-sync"; import { createGame } from "@main/services/library-sync";
import { updateLocalUnlockedAchievements } from "@main/services/achievements/update-local-unlocked-achivements";
import { import {
downloadsSublevel, downloadsSublevel,
gamesShopAssetsSublevel, gamesShopAssetsSublevel,
gamesSublevel, gamesSublevel,
levelKeys, levelKeys,
} from "@main/level"; } from "@main/level";
import { AchievementWatcherManager } from "@main/services/achievements/achievement-watcher-manager";
const addGameToLibrary = async ( const addGameToLibrary = async (
_event: Electron.IpcMainInvokeEvent, _event: Electron.IpcMainInvokeEvent,
@@ -43,7 +43,10 @@ const addGameToLibrary = async (
await createGame(game).catch(() => {}); await createGame(game).catch(() => {});
updateLocalUnlockedAchievements(game); AchievementWatcherManager.firstSyncWithRemoteIfNeeded(
game.shop,
game.objectId
);
}; };
registerEvent("addGameToLibrary", addGameToLibrary); registerEvent("addGameToLibrary", addGameToLibrary);

View File

@@ -16,7 +16,8 @@ const resetGameAchievements = async (
objectId: string objectId: string
) => { ) => {
try { try {
const game = await gamesSublevel.get(levelKeys.game(shop, objectId)); const levelKey = levelKeys.game(shop, objectId);
const game = await gamesSublevel.get(levelKey);
if (!game) return; if (!game) return;
@@ -29,8 +30,6 @@ const resetGameAchievements = async (
} }
} }
const levelKey = levelKeys.game(game.shop, game.objectId);
await gameAchievementsSublevel await gameAchievementsSublevel
.get(levelKey) .get(levelKey)
.then(async (gameAchievements) => { .then(async (gameAchievements) => {

View File

@@ -1,32 +0,0 @@
import { registerEvent } from "../register-event";
import { gamesSublevel, levelKeys } from "@main/level";
import { HydraApi } from "@main/services";
import type { GameShop, UserGameDetails } from "@types";
const syncGameByObjectId = async (
_event: Electron.IpcMainInvokeEvent,
shop: GameShop,
objectId: string
) => {
return HydraApi.get<UserGameDetails>(
`/profile/games/${shop}/${objectId}`
).then(async (res) => {
const { id, playTimeInSeconds, isFavorite, ...rest } = res;
const gameKey = levelKeys.game(shop, objectId);
const currentData = await gamesSublevel.get(gameKey);
await gamesSublevel.put(gameKey, {
...currentData,
...rest,
remoteId: id,
playTimeInMilliseconds: playTimeInSeconds * 1000,
favorite: isFavorite ?? currentData?.favorite,
});
return res;
});
};
registerEvent("syncGameByObjectId", syncGameByObjectId);

View File

@@ -3,6 +3,7 @@ import { registerEvent } from "../register-event";
import { HydraApi } from "@main/services"; import { HydraApi } from "@main/services";
import { db, levelKeys } from "@main/level"; import { db, levelKeys } from "@main/level";
import { AchievementWatcherManager } from "@main/services/achievements/achievement-watcher-manager";
const getComparedUnlockedAchievements = async ( const getComparedUnlockedAchievements = async (
_event: Electron.IpcMainInvokeEvent, _event: Electron.IpcMainInvokeEvent,
@@ -10,6 +11,8 @@ const getComparedUnlockedAchievements = async (
shop: GameShop, shop: GameShop,
userId: string userId: string
) => { ) => {
await AchievementWatcherManager.firstSyncWithRemoteIfNeeded(shop, objectId);
const userPreferences = await db.get<string, UserPreferences | null>( const userPreferences = await db.get<string, UserPreferences | null>(
levelKeys.userPreferences, levelKeys.userPreferences,
{ {

View File

@@ -2,6 +2,7 @@ import type { GameShop, UserAchievement, UserPreferences } from "@types";
import { registerEvent } from "../register-event"; import { registerEvent } from "../register-event";
import { getGameAchievementData } from "@main/services/achievements/get-game-achievement-data"; import { getGameAchievementData } from "@main/services/achievements/get-game-achievement-data";
import { db, gameAchievementsSublevel, levelKeys } from "@main/level"; import { db, gameAchievementsSublevel, levelKeys } from "@main/level";
import { AchievementWatcherManager } from "@main/services/achievements/achievement-watcher-manager";
export const getUnlockedAchievements = async ( export const getUnlockedAchievements = async (
objectId: string, objectId: string,
@@ -62,7 +63,7 @@ export const getUnlockedAchievements = async (
!achievementData.hidden || showHiddenAchievementsDescription !achievementData.hidden || showHiddenAchievementsDescription
? achievementData.description ? achievementData.description
: undefined, : undefined,
} as UserAchievement; };
}) })
.sort((a, b) => { .sort((a, b) => {
if (a.unlocked && !b.unlocked) return -1; if (a.unlocked && !b.unlocked) return -1;
@@ -79,6 +80,7 @@ const getUnlockedAchievementsEvent = async (
objectId: string, objectId: string,
shop: GameShop shop: GameShop
): Promise<UserAchievement[]> => { ): Promise<UserAchievement[]> => {
AchievementWatcherManager.firstSyncWithRemoteIfNeeded(shop, objectId);
return getUnlockedAchievements(objectId, shop, false); return getUnlockedAchievements(objectId, shop, false);
}; };

View File

@@ -0,0 +1,11 @@
import type { GameStats } from "@types";
import { db } from "../level";
import { levelKeys } from "./keys";
export const gamesStatsCacheSublevel = db.sublevel<
string,
GameStats & { updatedAt: number }
>(levelKeys.gameStatsCache, {
valueEncoding: "json",
});

View File

@@ -2,6 +2,7 @@ export * from "./downloads";
export * from "./games"; export * from "./games";
export * from "./game-shop-assets"; export * from "./game-shop-assets";
export * from "./game-shop-cache"; export * from "./game-shop-cache";
export * from "./game-stats-cache";
export * from "./game-achievements"; export * from "./game-achievements";
export * from "./keys"; export * from "./keys";
export * from "./themes"; export * from "./themes";

View File

@@ -7,6 +7,7 @@ export const levelKeys = {
auth: "auth", auth: "auth",
themes: "themes", themes: "themes",
gameShopAssets: "gameShopAssets", gameShopAssets: "gameShopAssets",
gameStatsCache: "gameStatsAssets",
gameShopCache: "gameShopCache", gameShopCache: "gameShopCache",
gameShopCacheItem: (shop: GameShop, objectId: string, language: string) => gameShopCacheItem: (shop: GameShop, objectId: string, language: string) =>
`${shop}:${objectId}:${language}`, `${shop}:${objectId}:${language}`,

View File

@@ -10,6 +10,7 @@ import {
import type { import type {
AchievementFile, AchievementFile,
Game, Game,
GameShop,
UnlockedAchievement, UnlockedAchievement,
UserPreferences, UserPreferences,
} from "@types"; } from "@types";
@@ -18,7 +19,7 @@ import { Cracker } from "@shared";
import { publishCombinedNewAchievementNotification } from "../notifications"; import { publishCombinedNewAchievementNotification } from "../notifications";
import { db, gamesSublevel, levelKeys } from "@main/level"; import { db, gamesSublevel, levelKeys } from "@main/level";
import { WindowManager } from "../window-manager"; import { WindowManager } from "../window-manager";
import { sleep } from "@main/helpers"; import { setTimeout } from "node:timers/promises";
const fileStats: Map<string, number> = new Map(); const fileStats: Map<string, number> = new Map();
const fltFiles: Map<string, Set<string>> = new Map(); const fltFiles: Map<string, Set<string>> = new Map();
@@ -37,7 +38,7 @@ const watchAchievementsWindows = async () => {
const gameAchievementFiles: AchievementFile[] = []; const gameAchievementFiles: AchievementFile[] = [];
for (const objectId of getAlternativeObjectIds(game.objectId)) { for (const objectId of getAlternativeObjectIds(game.objectId)) {
gameAchievementFiles.push(...(achievementFiles.get(objectId) || [])); gameAchievementFiles.push(...(achievementFiles.get(objectId) ?? []));
gameAchievementFiles.push( gameAchievementFiles.push(
...findAchievementFileInExecutableDirectory(game) ...findAchievementFileInExecutableDirectory(game)
@@ -127,6 +128,11 @@ const compareFile = (game: Game, file: AchievementFile) => {
); );
return processAchievementFileDiff(game, file); return processAchievementFileDiff(game, file);
} catch (err) { } catch (err) {
achievementsLogger.error(
"Error reading file",
file.filePath,
err instanceof Error ? err.message : err
);
fileStats.set(file.filePath, -1); fileStats.set(file.filePath, -1);
return; return;
} }
@@ -136,20 +142,69 @@ const processAchievementFileDiff = async (
game: Game, game: Game,
file: AchievementFile file: AchievementFile
) => { ) => {
const unlockedAchievements = parseAchievementFile(file.filePath, file.type); const parsedAchievements = parseAchievementFile(file.filePath, file.type);
if (unlockedAchievements.length) { if (parsedAchievements.length) {
return mergeAchievements(game, unlockedAchievements, true); return mergeAchievements(game, parsedAchievements, true);
} }
return 0; return 0;
}; };
export class AchievementWatcherManager { export class AchievementWatcherManager {
private static hasFinishedMergingWithRemote = false; private static _hasFinishedPreSearch = false;
public static get hasFinishedPreSearch() {
return this._hasFinishedPreSearch;
}
public static readonly alreadySyncedGames: Map<string, boolean> = new Map();
public static async firstSyncWithRemoteIfNeeded(
shop: GameShop,
objectId: string
) {
const gameKey = levelKeys.game(shop, objectId);
if (this.alreadySyncedGames.get(gameKey)) return;
const game = await gamesSublevel.get(gameKey).catch(() => null);
if (!game || game.isDeleted) return;
const gameAchievementFiles = findAchievementFiles(game);
const achievementFileInsideDirectory =
findAchievementFileInExecutableDirectory(game);
gameAchievementFiles.push(...achievementFileInsideDirectory);
const unlockedAchievements: UnlockedAchievement[] = [];
for (const achievementFile of gameAchievementFiles) {
const localAchievementFile = parseAchievementFile(
achievementFile.filePath,
achievementFile.type
);
if (localAchievementFile.length) {
unlockedAchievements.push(...localAchievementFile);
}
}
this.alreadySyncedGames.set(gameKey, true);
const newAchievements = await mergeAchievements(
game,
unlockedAchievements,
false
);
if (newAchievements > 0) {
this.notifyCombinedAchievementsUnlocked(1, newAchievements);
}
}
public static watchAchievements() { public static watchAchievements() {
if (!this.hasFinishedMergingWithRemote) return; if (!this.hasFinishedPreSearch) return;
if (process.platform === "win32") { if (process.platform === "win32") {
return watchAchievementsWindows(); return watchAchievementsWindows();
@@ -188,7 +243,11 @@ export class AchievementWatcherManager {
} }
} }
return mergeAchievements(game, unlockedAchievements, false); if (unlockedAchievements.length) {
return mergeAchievements(game, unlockedAchievements, false);
}
return 0;
} }
private static async getGameAchievementFilesWindows() { private static async getGameAchievementFilesWindows() {
@@ -237,25 +296,44 @@ export class AchievementWatcherManager {
); );
} }
public static async preSearchAchievements() { private static async notifyCombinedAchievementsUnlocked(
await sleep(2000); totalNewGamesWithAchievements: number,
totalNewAchievements: number
) {
const userPreferences = await db.get<string, UserPreferences>(
levelKeys.userPreferences,
{
valueEncoding: "json",
}
);
if (userPreferences.achievementCustomNotificationsEnabled !== false) {
WindowManager.notificationWindow?.webContents.send(
"on-combined-achievements-unlocked",
totalNewGamesWithAchievements,
totalNewAchievements,
userPreferences.achievementCustomNotificationPosition ?? "top-left"
);
} else {
publishCombinedNewAchievementNotification(
totalNewAchievements,
totalNewGamesWithAchievements
);
}
}
public static async preSearchAchievements() {
try { try {
const gameAchievementFiles = const gameAchievementFiles =
process.platform === "win32" process.platform === "win32"
? await this.getGameAchievementFilesWindows() ? await this.getGameAchievementFilesWindows()
: await this.getGameAchievementFilesLinux(); : await this.getGameAchievementFilesLinux();
const newAchievementsCount: number[] = []; const newAchievementsCount = await Promise.all(
gameAchievementFiles.map(({ game, achievementFiles }) => {
for (const { game, achievementFiles } of gameAchievementFiles) { return this.preProcessGameAchievementFiles(game, achievementFiles);
const result = await this.preProcessGameAchievementFiles( })
game, );
achievementFiles
);
newAchievementsCount.push(result);
}
const totalNewGamesWithAchievements = newAchievementsCount.filter( const totalNewGamesWithAchievements = newAchievementsCount.filter(
(achievements) => achievements (achievements) => achievements
@@ -267,34 +345,16 @@ export class AchievementWatcherManager {
); );
if (totalNewAchievements > 0) { if (totalNewAchievements > 0) {
const userPreferences = await db.get<string, UserPreferences>( await setTimeout(4000);
levelKeys.userPreferences, this.notifyCombinedAchievementsUnlocked(
{ totalNewGamesWithAchievements,
valueEncoding: "json", totalNewAchievements
}
); );
if (userPreferences.achievementNotificationsEnabled !== false) {
if (userPreferences.achievementCustomNotificationsEnabled !== false) {
WindowManager.notificationWindow?.webContents.send(
"on-combined-achievements-unlocked",
totalNewGamesWithAchievements,
totalNewAchievements,
userPreferences.achievementCustomNotificationPosition ??
"top-left"
);
} else {
publishCombinedNewAchievementNotification(
totalNewAchievements,
totalNewGamesWithAchievements
);
}
}
} }
} catch (err) { } catch (err) {
achievementsLogger.error("Error on preSearchAchievements", err); achievementsLogger.error("Error on preSearchAchievements", err);
} }
this.hasFinishedMergingWithRemote = true; this._hasFinishedPreSearch = true;
} }
} }

View File

@@ -303,7 +303,7 @@ export const findAchievementFileInExecutableDirectory = (
"achievements.ini" "achievements.ini"
), ),
}, },
]; ].filter((file) => fs.existsSync(file.filePath)) as AchievementFile[];
}; };
const mapFileLocationWithObjectId = ( const mapFileLocationWithObjectId = (

View File

@@ -1,24 +1,39 @@
import { HydraApi } from "../hydra-api"; import { HydraApi } from "../hydra-api";
import type { GameShop, SteamAchievement } from "@types"; import type { GameAchievement, GameShop, SteamAchievement } from "@types";
import { UserNotLoggedInError } from "@shared"; import { UserNotLoggedInError } from "@shared";
import { logger } from "../logger"; import { logger } from "../logger";
import { db, gameAchievementsSublevel, levelKeys } from "@main/level"; import { db, gameAchievementsSublevel, levelKeys } from "@main/level";
import { AxiosError } from "axios";
const LOCAL_CACHE_EXPIRATION = 1000 * 60 * 60; // 1 hour
const getModifiedSinceHeader = (
cachedAchievements: GameAchievement | undefined
): Date | undefined => {
if (!cachedAchievements) {
return undefined;
}
return cachedAchievements.updatedAt
? new Date(cachedAchievements.updatedAt)
: undefined;
};
export const getGameAchievementData = async ( export const getGameAchievementData = async (
objectId: string, objectId: string,
shop: GameShop, shop: GameShop,
useCachedData: boolean useCachedData: boolean
) => { ) => {
const cachedAchievements = await gameAchievementsSublevel.get( const gameKey = levelKeys.game(shop, objectId);
levelKeys.game(shop, objectId)
);
if (cachedAchievements && useCachedData) const cachedAchievements = await gameAchievementsSublevel.get(gameKey);
if (cachedAchievements?.achievements && useCachedData)
return cachedAchievements.achievements; return cachedAchievements.achievements;
if ( if (
cachedAchievements && cachedAchievements?.achievements &&
Date.now() < (cachedAchievements.cacheExpiresTimestamp ?? 0) Date.now() < (cachedAchievements.updatedAt ?? 0) + LOCAL_CACHE_EXPIRATION
) { ) {
return cachedAchievements.achievements; return cachedAchievements.achievements;
} }
@@ -29,18 +44,22 @@ export const getGameAchievementData = async (
}) })
.then((language) => language || "en"); .then((language) => language || "en");
return HydraApi.get<SteamAchievement[]>("/games/achievements", { return HydraApi.get<SteamAchievement[]>(
shop, "/games/achievements",
objectId, {
language, shop,
}) objectId,
language,
},
{
ifModifiedSince: getModifiedSinceHeader(cachedAchievements),
}
)
.then(async (achievements) => { .then(async (achievements) => {
await gameAchievementsSublevel.put(levelKeys.game(shop, objectId), { await gameAchievementsSublevel.put(gameKey, {
unlockedAchievements: cachedAchievements?.unlockedAchievements ?? [], unlockedAchievements: cachedAchievements?.unlockedAchievements ?? [],
achievements, achievements,
cacheExpiresTimestamp: achievements.length updatedAt: Date.now() + LOCAL_CACHE_EXPIRATION,
? Date.now() + 1000 * 60 * 30 // 30 minutes
: undefined,
}); });
return achievements; return achievements;
@@ -50,8 +69,14 @@ export const getGameAchievementData = async (
throw err; throw err;
} }
const isNotModified = (err as AxiosError)?.response?.status === 304;
if (isNotModified) {
return cachedAchievements?.achievements ?? [];
}
logger.error("Failed to get game achievements for", objectId, err); logger.error("Failed to get game achievements for", objectId, err);
return []; return cachedAchievements?.achievements ?? [];
}); });
}; };

View File

@@ -14,6 +14,7 @@ import { SubscriptionRequiredError } from "@shared";
import { achievementsLogger } from "../logger"; import { achievementsLogger } from "../logger";
import { db, gameAchievementsSublevel, levelKeys } from "@main/level"; import { db, gameAchievementsSublevel, levelKeys } from "@main/level";
import { getGameAchievementData } from "./get-game-achievement-data"; import { getGameAchievementData } from "./get-game-achievement-data";
import { AchievementWatcherManager } from "./achievement-watcher-manager";
const isRareAchievement = (points: number) => { const isRareAchievement = (points: number) => {
const rawPercentage = (50 - Math.sqrt(points)) * 2; const rawPercentage = (50 - Math.sqrt(points)) * 2;
@@ -35,7 +36,7 @@ const saveAchievementsOnLocal = async (
await gameAchievementsSublevel.put(levelKey, { await gameAchievementsSublevel.put(levelKey, {
achievements: gameAchievement?.achievements ?? [], achievements: gameAchievement?.achievements ?? [],
unlockedAchievements: unlockedAchievements, unlockedAchievements: unlockedAchievements,
cacheExpiresTimestamp: gameAchievement?.cacheExpiresTimestamp, updatedAt: gameAchievement?.updatedAt,
}); });
if (!sendUpdateEvent) return; if (!sendUpdateEvent) return;
@@ -56,9 +57,9 @@ export const mergeAchievements = async (
achievements: UnlockedAchievement[], achievements: UnlockedAchievement[],
publishNotification: boolean publishNotification: boolean
) => { ) => {
let localGameAchievement = await gameAchievementsSublevel.get( const gameKey = levelKeys.game(game.shop, game.objectId);
levelKeys.game(game.shop, game.objectId)
); let localGameAchievement = await gameAchievementsSublevel.get(gameKey);
const userPreferences = await db.get<string, UserPreferences>( const userPreferences = await db.get<string, UserPreferences>(
levelKeys.userPreferences, levelKeys.userPreferences,
{ {
@@ -67,10 +68,8 @@ export const mergeAchievements = async (
); );
if (!localGameAchievement) { if (!localGameAchievement) {
await getGameAchievementData(game.objectId, game.shop, true); await getGameAchievementData(game.objectId, game.shop, false);
localGameAchievement = await gameAchievementsSublevel.get( localGameAchievement = await gameAchievementsSublevel.get(gameKey);
levelKeys.game(game.shop, game.objectId)
);
} }
const achievementsData = localGameAchievement?.achievements ?? []; const achievementsData = localGameAchievement?.achievements ?? [];
@@ -136,6 +135,12 @@ export const mergeAchievements = async (
}; };
}); });
achievementsLogger.log(
"Publishing achievement notification",
game.objectId,
game.title
);
if (userPreferences.achievementCustomNotificationsEnabled !== false) { if (userPreferences.achievementCustomNotificationsEnabled !== false) {
WindowManager.notificationWindow?.webContents.send( WindowManager.notificationWindow?.webContents.send(
"on-achievement-unlocked", "on-achievement-unlocked",
@@ -153,7 +158,11 @@ export const mergeAchievements = async (
} }
} }
if (game.remoteId) { const shouldSyncWithRemote =
game.remoteId &&
(newAchievements.length || AchievementWatcherManager.hasFinishedPreSearch);
if (shouldSyncWithRemote) {
await HydraApi.put<UpdatedUnlockedAchievements | undefined>( await HydraApi.put<UpdatedUnlockedAchievements | undefined>(
"/profile/games/achievements", "/profile/games/achievements",
{ {
@@ -194,8 +203,11 @@ export const mergeAchievements = async (
mergedLocalAchievements, mergedLocalAchievements,
publishNotification publishNotification
); );
})
.finally(() => {
AchievementWatcherManager.alreadySyncedGames.set(gameKey, true);
}); });
} else { } else if (newAchievements.length) {
await saveAchievementsOnLocal( await saveAchievementsOnLocal(
game.objectId, game.objectId,
game.shop, game.shop,

View File

@@ -1,31 +0,0 @@
import {
findAchievementFiles,
findAchievementFileInExecutableDirectory,
} from "./find-achivement-files";
import { parseAchievementFile } from "./parse-achievement-file";
import { mergeAchievements } from "./merge-achievements";
import type { Game, UnlockedAchievement } from "@types";
export const updateLocalUnlockedAchievements = async (game: Game) => {
const gameAchievementFiles = findAchievementFiles(game);
const achievementFileInsideDirectory =
findAchievementFileInExecutableDirectory(game);
gameAchievementFiles.push(...achievementFileInsideDirectory);
const unlockedAchievements: UnlockedAchievement[] = [];
for (const achievementFile of gameAchievementFiles) {
const localAchievementFile = parseAchievementFile(
achievementFile.filePath,
achievementFile.type
);
if (localAchievementFile.length) {
unlockedAchievements.push(...localAchievementFile);
}
}
mergeAchievements(game, unlockedAchievements, false);
};

View File

@@ -31,8 +31,8 @@ export interface ProcessPayload {
exe: string | null; exe: string | null;
pid: number; pid: number;
name: string; name: string;
environ: Record<string, string> | null; environ?: Record<string, string> | null;
cwd: string | null; cwd?: string | null;
} }
export interface PauseSeedingPayload { export interface PauseSeedingPayload {

View File

@@ -16,6 +16,7 @@ import { WSClient } from "./ws/ws-client";
interface HydraApiOptions { interface HydraApiOptions {
needsAuth?: boolean; needsAuth?: boolean;
needsSubscription?: boolean; needsSubscription?: boolean;
ifModifiedSince?: Date;
} }
interface HydraApiUserAuth { interface HydraApiUserAuth {
@@ -337,8 +338,13 @@ export class HydraApi {
) { ) {
await this.validateOptions(options); await this.validateOptions(options);
const headers = {
...this.getAxiosConfig().headers,
"Hydra-If-Modified-Since": options?.ifModifiedSince?.toUTCString(),
};
return this.instance return this.instance
.get<T>(url, { params, ...this.getAxiosConfig() }) .get<T>(url, { params, ...this.getAxiosConfig(), headers })
.then((response) => response.data) .then((response) => response.data)
.catch(this.handleUnauthorizedError); .catch(this.handleUnauthorizedError);
} }

View File

@@ -13,9 +13,8 @@ export const mergeWithRemoteGames = async () => {
return HydraApi.get<ProfileGame[]>("/profile/games") return HydraApi.get<ProfileGame[]>("/profile/games")
.then(async (response) => { .then(async (response) => {
for (const game of response) { for (const game of response) {
const localGame = await gamesSublevel.get( const gameKey = levelKeys.game(game.shop, game.objectId);
levelKeys.game(game.shop, game.objectId) const localGame = await gamesSublevel.get(gameKey);
);
if (localGame) { if (localGame) {
const updatedLastTimePlayed = const updatedLastTimePlayed =
@@ -30,7 +29,7 @@ export const mergeWithRemoteGames = async () => {
? game.playTimeInMilliseconds ? game.playTimeInMilliseconds
: localGame.playTimeInMilliseconds; : localGame.playTimeInMilliseconds;
await gamesSublevel.put(levelKeys.game(game.shop, game.objectId), { await gamesSublevel.put(gameKey, {
...localGame, ...localGame,
remoteId: game.id, remoteId: game.id,
lastTimePlayed: updatedLastTimePlayed, lastTimePlayed: updatedLastTimePlayed,
@@ -38,7 +37,7 @@ export const mergeWithRemoteGames = async () => {
favorite: game.isFavorite ?? localGame.favorite, favorite: game.isFavorite ?? localGame.favorite,
}); });
} else { } else {
await gamesSublevel.put(levelKeys.game(game.shop, game.objectId), { await gamesSublevel.put(gameKey, {
objectId: game.objectId, objectId: game.objectId,
title: game.title, title: game.title,
remoteId: game.id, remoteId: game.id,
@@ -51,20 +50,17 @@ export const mergeWithRemoteGames = async () => {
}); });
} }
await gamesShopAssetsSublevel.put( await gamesShopAssetsSublevel.put(gameKey, {
levelKeys.game(game.shop, game.objectId), shop: game.shop,
{ objectId: game.objectId,
shop: game.shop, title: game.title,
objectId: game.objectId, coverImageUrl: game.coverImageUrl,
title: game.title, libraryHeroImageUrl: game.libraryHeroImageUrl,
coverImageUrl: game.coverImageUrl, libraryImageUrl: game.libraryImageUrl,
libraryHeroImageUrl: game.libraryHeroImageUrl, logoImageUrl: game.logoImageUrl,
libraryImageUrl: game.libraryImageUrl, iconUrl: game.iconUrl,
logoImageUrl: game.logoImageUrl, logoPosition: game.logoPosition,
iconUrl: game.iconUrl, });
logoPosition: game.logoPosition,
}
);
} }
}) })
.catch(() => {}); .catch(() => {});

View File

@@ -34,9 +34,7 @@ export const uploadGamesBatch = async () => {
await mergeWithRemoteGames(); await mergeWithRemoteGames();
if (HydraApi.isLoggedIn()) { AchievementWatcherManager.preSearchAchievements();
AchievementWatcherManager.preSearchAchievements();
}
if (WindowManager.mainWindow) if (WindowManager.mainWindow)
WindowManager.mainWindow.webContents.send("on-library-batch-complete"); WindowManager.mainWindow.webContents.send("on-library-batch-complete");

View File

@@ -8,6 +8,7 @@ import { gamesSublevel, levelKeys } from "@main/level";
import { CloudSync } from "./cloud-sync"; import { CloudSync } from "./cloud-sync";
import { logger } from "./logger"; import { logger } from "./logger";
import path from "path"; import path from "path";
import { AchievementWatcherManager } from "./achievements/achievement-watcher-manager";
export const gamesPlaytime = new Map< export const gamesPlaytime = new Map<
string, string,
@@ -24,7 +25,7 @@ interface GameExecutables {
[key: string]: ExecutableInfo[]; [key: string]: ExecutableInfo[];
} }
const TICKS_TO_UPDATE_API = 80; const TICKS_TO_UPDATE_API = 120;
let currentTick = 1; let currentTick = 1;
const platform = process.platform; const platform = process.platform;
@@ -190,6 +191,11 @@ export const watchProcesses = async () => {
function onOpenGame(game: Game) { function onOpenGame(game: Game) {
const now = performance.now(); const now = performance.now();
AchievementWatcherManager.firstSyncWithRemoteIfNeeded(
game.shop,
game.objectId
);
gamesPlaytime.set(levelKeys.game(game.shop, game.objectId), { gamesPlaytime.set(levelKeys.game(game.shop, game.objectId), {
lastTick: now, lastTick: now,
firstTick: now, firstTick: now,

View File

@@ -370,14 +370,11 @@ export class WindowManager {
}, },
}); });
this.notificationWindow.setIgnoreMouseEvents(true); this.notificationWindow.setIgnoreMouseEvents(true);
// this.notificationWindow.setVisibleOnAllWorkspaces(true, {
// visibleOnFullScreen: true,
// });
this.notificationWindow.setAlwaysOnTop(true, "screen-saver", 1); this.notificationWindow.setAlwaysOnTop(true, "screen-saver", 1);
this.loadNotificationWindowURL(); this.loadNotificationWindowURL();
if (isStaging) { if (!app.isPackaged || isStaging) {
this.notificationWindow.webContents.openDevTools(); this.notificationWindow.webContents.openDevTools();
} }
} }
@@ -464,7 +461,7 @@ export class WindowManager {
editorWindow.once("ready-to-show", () => { editorWindow.once("ready-to-show", () => {
editorWindow.show(); editorWindow.show();
this.mainWindow?.webContents.openDevTools(); this.mainWindow?.webContents.openDevTools();
if (isStaging) { if (!app.isPackaged || isStaging) {
editorWindow.webContents.openDevTools(); editorWindow.webContents.openDevTools();
} }
}); });

View File

@@ -187,8 +187,6 @@ contextBridge.exposeInMainWorld("electron", {
ipcRenderer.invoke("deleteGameFolder", shop, objectId), ipcRenderer.invoke("deleteGameFolder", shop, objectId),
getGameByObjectId: (shop: GameShop, objectId: string) => getGameByObjectId: (shop: GameShop, objectId: string) =>
ipcRenderer.invoke("getGameByObjectId", shop, objectId), ipcRenderer.invoke("getGameByObjectId", shop, objectId),
syncGameByObjectId: (shop: GameShop, objectId: string) =>
ipcRenderer.invoke("syncGameByObjectId", shop, objectId),
resetGameAchievements: (shop: GameShop, objectId: string) => resetGameAchievements: (shop: GameShop, objectId: string) =>
ipcRenderer.invoke("resetGameAchievements", shop, objectId), ipcRenderer.invoke("resetGameAchievements", shop, objectId),
extractGameDownload: (shop: GameShop, objectId: string) => extractGameDownload: (shop: GameShop, objectId: string) =>

View File

@@ -74,6 +74,6 @@
} }
&__error-label { &__error-label {
color: globals.$danger-color; color: globals.$error-color;
} }
} }

View File

@@ -182,11 +182,6 @@ export function GameDetailsContextProvider({
}) })
.catch(() => {}); .catch(() => {});
} }
window.electron.syncGameByObjectId(shop, objectId).then(() => {
if (abortController.signal.aborted) return;
updateGame();
});
}, [ }, [
updateGame, updateGame,
dispatch, dispatch,

View File

@@ -155,7 +155,6 @@ declare global {
shop: GameShop, shop: GameShop,
objectId: string objectId: string
) => Promise<LibraryGame | null>; ) => Promise<LibraryGame | null>;
syncGameByObjectId: (shop: GameShop, objectId: string) => Promise<void>;
onGamesRunning: ( onGamesRunning: (
cb: ( cb: (
gamesRunning: Pick<GameRunning, "id" | "sessionDurationInMillis">[] gamesRunning: Pick<GameRunning, "id" | "sessionDurationInMillis">[]

View File

@@ -158,8 +158,8 @@ $logo-max-width: 200px;
&-points { &-points {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: end;
gap: 4px; gap: 4px;
margin-right: 4px;
font-weight: 600; font-weight: 600;
&--locked { &--locked {

View File

@@ -55,7 +55,6 @@ export function AchievementNotification() {
isHidden: false, isHidden: false,
isRare: false, isRare: false,
isPlatinum: false, isPlatinum: false,
points: 0,
iconUrl: "https://cdn.losbroxas.org/favicon.svg", iconUrl: "https://cdn.losbroxas.org/favicon.svg",
}, },
]); ]);

View File

@@ -51,6 +51,14 @@ export const UserFriendModalAddFriend = ({
} }
}; };
const validateFriendCode = (callback: () => void) => {
if (friendCode.length === 8) {
return callback();
}
showErrorToast(t("friend_code_length_error"));
};
const handleCancelFriendRequest = (userId: string) => { const handleCancelFriendRequest = (userId: string) => {
updateFriendRequestState(userId, "CANCEL").catch(() => { updateFriendRequestState(userId, "CANCEL").catch(() => {
showErrorToast(t("try_again")); showErrorToast(t("try_again"));
@@ -91,13 +99,13 @@ export const UserFriendModalAddFriend = ({
disabled={isAddingFriend} disabled={isAddingFriend}
className="user-friend-modal-add-friend__button" className="user-friend-modal-add-friend__button"
type="button" type="button"
onClick={handleClickAddFriend} onClick={() => validateFriendCode(handleClickAddFriend)}
> >
{isAddingFriend ? t("sending") : t("add")} {isAddingFriend ? t("sending") : t("add")}
</Button> </Button>
<Button <Button
onClick={handleClickSeeProfile} onClick={() => validateFriendCode(handleClickSeeProfile)}
disabled={isAddingFriend} disabled={isAddingFriend}
className="user-friend-modal-add-friend__button" className="user-friend-modal-add-friend__button"
type="button" type="button"

View File

@@ -7,6 +7,7 @@ $body-color: #8e919b;
$border-color: rgba(255, 255, 255, 0.15); $border-color: rgba(255, 255, 255, 0.15);
$success-color: #1c9749; $success-color: #1c9749;
$danger-color: #801d1e; $danger-color: #801d1e;
$error-color: #e11d48;
$warning-color: #ffc107; $warning-color: #ffc107;
$brand-teal: #16b195; $brand-teal: #16b195;

View File

@@ -112,8 +112,6 @@ export interface UserFriend {
id: string; id: string;
displayName: string; displayName: string;
profileImageUrl: string | null; profileImageUrl: string | null;
createdAt: string;
updatedAt: string;
currentGame: currentGame:
| (ShopAssets & { | (ShopAssets & {
sessionDurationInSeconds: number; sessionDurationInSeconds: number;
@@ -146,8 +144,6 @@ export interface UserRelation {
AId: string; AId: string;
BId: string; BId: string;
status: "ACCEPTED" | "PENDING"; status: "ACCEPTED" | "PENDING";
createdAt: string;
updatedAt: string;
} }
export type UserProfileCurrentGame = GameRunning & export type UserProfileCurrentGame = GameRunning &
@@ -326,17 +322,11 @@ export interface CatalogueSearchPayload {
export type CatalogueSearchResult = { export type CatalogueSearchResult = {
id: string; id: string;
tags: string[];
genres: string[];
objectId: string; objectId: string;
shop: GameShop;
createdAt: Date;
updatedAt: Date;
title: string; title: string;
installCount: number; shop: GameShop;
achievementCount: number; genres: string[];
shopData: string; } & Pick<ShopAssets, "libraryImageUrl">;
} & ShopAssets;
export type LibraryGame = Game & export type LibraryGame = Game &
Partial<ShopAssets> & { Partial<ShopAssets> & {

View File

@@ -68,7 +68,7 @@ export interface Download {
export interface GameAchievement { export interface GameAchievement {
achievements: SteamAchievement[]; achievements: SteamAchievement[];
unlockedAchievements: UnlockedAchievement[]; unlockedAchievements: UnlockedAchievement[];
cacheExpiresTimestamp: number | undefined; updatedAt: number | undefined;
} }
export type AchievementCustomNotificationPosition = export type AchievementCustomNotificationPosition =