diff --git a/electron.vite.config.ts b/electron.vite.config.ts index 08bd00f9..c7833bf1 100644 --- a/electron.vite.config.ts +++ b/electron.vite.config.ts @@ -7,7 +7,6 @@ import { } from "electron-vite"; import react from "@vitejs/plugin-react"; import svgr from "vite-plugin-svgr"; -import { sentryVitePlugin } from "@sentry/vite-plugin"; export default defineConfig(({ mode }) => { loadEnv(mode); @@ -48,15 +47,7 @@ export default defineConfig(({ mode }) => { "@shared": resolve("src/shared"), }, }, - plugins: [ - svgr(), - react(), - sentryVitePlugin({ - authToken: process.env.SENTRY_AUTH_TOKEN, - org: "hydra-launcher", - project: "hydra-renderer", - }), - ], + plugins: [svgr(), react()], }, }; }); diff --git a/package.json b/package.json index 54438fe2..ed7e31e0 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "hydralauncher", - "version": "3.6.0", + "version": "3.6.4", "description": "Hydra", "main": "./out/main/index.js", "author": "Los Broxas", @@ -40,8 +40,6 @@ "@primer/octicons-react": "^19.9.0", "@radix-ui/react-dropdown-menu": "^2.1.2", "@reduxjs/toolkit": "^2.2.3", - "@sentry/react": "^8.47.0", - "@sentry/vite-plugin": "^2.22.7", "auto-launch": "^5.0.6", "axios": "^1.7.9", "axios-cookiejar-support": "^5.0.5", diff --git a/python_rpc/main.py b/python_rpc/main.py index 43972afa..152f8ffb 100644 --- a/python_rpc/main.py +++ b/python_rpc/main.py @@ -101,8 +101,13 @@ def process_list(): auth_error = validate_rpc_password() if auth_error: return auth_error + + 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(['exe', 'cwd', 'pid', 'name', 'environ'])] + process_list = [proc.info for proc in psutil.process_iter(iter_list)] return jsonify(process_list), 200 @app.route("/profile-image", methods=["POST"]) diff --git a/src/locales/en/translation.json b/src/locales/en/translation.json index 34754a62..96f72e1b 100644 --- a/src/locales/en/translation.json +++ b/src/locales/en/translation.json @@ -380,6 +380,7 @@ "installing_common_redist": "Installing…", "show_download_speed_in_megabytes": "Show download speed in megabytes per second", "extract_files_by_default": "Extract files by default after download", + "enable_steam_achievements": "Enable search for Steam achievements", "achievement_custom_notification_position": "Achievement custom notification position", "top-left": "Top left", "top-center": "Top center", @@ -516,7 +517,8 @@ "earned_points": "Earned points", "show_achievements_on_profile": "Show your achievements 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_unlocked": "Achievement unlocked", diff --git a/src/locales/es/translation.json b/src/locales/es/translation.json index f91a9809..7f54925a 100644 --- a/src/locales/es/translation.json +++ b/src/locales/es/translation.json @@ -141,7 +141,7 @@ "allow_nsfw_content": "Continuar", "refuse_nsfw_content": "No, gracias", "stats": "Estadísticas", - "download_count": "Downloads", + "download_count": "Descargas", "player_count": "Jugadores activos", "download_error": "Esta opción de descarga no está disponible.", "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_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_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_removed_from_favorites": "Juego removido de favoritos", - "invalid_wine_prefix_path": "Ruta de prefixo 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.", - "missing_wine_prefix": "" + "invalid_wine_prefix_path": "Ruta de prefijo de Wine inválida", + "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": "Se requiere el prefijo Wine para crear una copia de seguridad en Linux" }, "activation": { "title": "Activar Hydra", diff --git a/src/locales/pt-BR/translation.json b/src/locales/pt-BR/translation.json index eb5fac13..22f5b533 100644 --- a/src/locales/pt-BR/translation.json +++ b/src/locales/pt-BR/translation.json @@ -365,6 +365,7 @@ "installing_common_redist": "Instalando…", "show_download_speed_in_megabytes": "Exibir taxas de download em megabytes por segundo", "extract_files_by_default": "Extrair arquivos automaticamente após o download", + "enable_steam_achievements": "Habilitar busca por conquistas da Steam", "enable_achievement_custom_notifications": "Habilitar notificações customizadas de conquistas", "top-left": "Superior esquerdo", "top-center": "Superior central", @@ -509,7 +510,8 @@ "earned_points": "Pontos ganhos", "show_achievements_on_profile": "Exiba suas conquistas 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_unlocked": "Conquista desbloqueada", diff --git a/src/locales/ru/translation.json b/src/locales/ru/translation.json index 7724e10d..c7857e81 100644 --- a/src/locales/ru/translation.json +++ b/src/locales/ru/translation.json @@ -365,14 +365,14 @@ "installing_common_redist": "Установка…", "show_download_speed_in_megabytes": "Показать скорость загрузки в мегабайтах в секунду", "extract_files_by_default": "Извлекать файлы по умолчанию после загрузки", - "achievement_custom_notification_position": "Позиция настраиваемых уведомлений о достижениях", + "achievement_custom_notification_position": "Позиция уведомлений достижений", "top-left": "Верхний левый угол", "top-center": "Верхний центр", "top-right": "Верхний правый угол", "bottom-left": "Нижний левый угол", "bottom-center": "Нижний центр", "bottom-right": "Нижний правый угол", - "enable_achievement_custom_notifications": "Включить настраиваемые уведомления о достижениях", + "enable_achievement_custom_notifications": "Включить уведомления о достижениях", "alignment": "Выравнивание", "variation": "Вариация", "default": "По умолчанию", diff --git a/src/locales/zh/translation.json b/src/locales/zh/translation.json index 01605142..0323d991 100644 --- a/src/locales/zh/translation.json +++ b/src/locales/zh/translation.json @@ -1,5 +1,5 @@ { - "language_name": "中文", + "language_name": "简体中文", "app": { "successfully_signed_in": "已成功登录" }, @@ -26,7 +26,9 @@ "game_has_no_executable": "未选择游戏的可执行文件", "sign_in": "登入", "friends": "好友", - "favorites": "收藏" + "favorites": "收藏", + "need_help": "需要帮助?", + "playable_button_title": "仅显示现在可以游玩的游戏" }, "header": { "search": "搜索游戏", @@ -43,11 +45,23 @@ "downloading_metadata": "正在下载{{title}}的元数据…", "downloading": "正在下载{{title}}… ({{percentage}}完成) - 剩余时间{{eta}} - 速度{{speed}}", "calculating_eta": "正在下载 {{title}}… (已完成{{percentage}}.) - 正在计算剩余时间...", - "checking_files": "正在校验 {{title}} 的文件... ({{percentage}} 已完成)" + "checking_files": "正在校验 {{title}} 的文件... ({{percentage}} 已完成)", + "installation_complete": "安装完成", + "installation_complete_message": "通用可再发行组件安装成功", + "installing_common_redist": "{{log}}…" }, "catalogue": { "next_page": "下一页", - "previous_page": "上一页" + "previous_page": "上一页", + "clear_filters": "清除已选的 {{filterCount}} 项", + "developers": "开发商", + "download_sources": "下载源", + "filter_count": "{{filterCount}} 项可用", + "genres": "类型", + "publishers": "发行商", + "result_count": "{{resultCount}} 个结果", + "search": "筛选…", + "tags": "标签" }, "game_details": { "open_download_options": "打开下载菜单", @@ -166,7 +180,48 @@ "manage_files_description": "管理哪些文件要备份和恢复", "select_folder": "选择文件夹", "backup_from": "{{date}} 时备份", - "custom_backup_location_set": "自定义备份文件位置" + "custom_backup_location_set": "自定义备份文件位置", + "artifact_name_label": "备份名称", + "artifact_name_placeholder": "为备份输入名称", + "artifact_renamed": "备份重命名成功", + "automatic_backup_from": "{{date}} 的自动备份", + "automatically_extract_downloaded_files": "自动解压下载的文件", + "backup_freeze_failed": "固定备份失败", + "backup_freeze_failed_description": "您必须至少保留一个空位用于自动备份", + "backup_frozen": "备份已固定", + "backup_unfrozen": "备份已取消固定", + "clear": "清除", + "create_start_menu_shortcut": "创建开始菜单快捷方式", + "create_steam_shortcut": "创建Steam快捷方式", + "download_error_gofile_quota_exceeded": "您已超出Gofile的月度配额。请等待配额重置。", + "download_error_not_cached_on_hydra": "此下载在Nimbus上不可用。", + "download_error_not_cached_on_real_debrid": "此下载在Real-Debrid上不可用,且暂不支持从Real-Debrid轮询下载状态。", + "download_error_not_cached_on_torbox": "此下载在TorBox上不可用,且暂不支持从TorBox轮询下载状态。", + "download_error_real_debrid_account_not_authorized": "您的Real-Debrid账户未被授权进行新下载。请检查您的账户设置并重试。", + "enable_automatic_cloud_sync": "启用自动云同步", + "freeze_backup": "固定以免被自动备份覆盖", + "game_added_to_favorites": "游戏已添加到收藏", + "game_removed_from_favorites": "游戏已从收藏中移除", + "invalid_wine_prefix_path": "无效的Wine前置路径", + "invalid_wine_prefix_path_description": "Wine前置的路径无效。请检查路径并重试。", + "launch_options": "启动选项", + "launch_options_description": "高级用户可以选择修改启动选项(实验性功能)", + "launch_options_placeholder": "未指定参数", + "max_length_field": "此字段必须少于 {{length}} 个字符", + "missing_wine_prefix": "在Linux上创建备份需要Wine前置", + "no_directory_selected": "未选择目录", + "no_write_permission": "无法下载到此目录。点击此处了解更多。", + "rename_artifact": "重命名备份", + "rename_artifact_description": "将备份重命名为更具描述性的名称", + "required_field": "此字段为必填项", + "reset_achievements": "重置成就", + "reset_achievements_description": "这将重置 {{game}} 的所有成就", + "reset_achievements_error": "重置成就失败", + "reset_achievements_success": "成就重置成功", + "reset_achievements_title": "您确定吗?", + "save_changes": "保存更改", + "unfreeze_backup": "取消固定", + "you_might_need_to_restart_steam": "您可能需要重启Steam才能看到更改" }, "activation": { "title": "激活 Hydra", @@ -199,7 +254,13 @@ "queued": "下载列表", "no_downloads_title": "空空如也", "no_downloads_description": "你还未使用Hydra下载任何游戏,但什么时候开始,都为时不晚。", - "checking_files": "正在校验文件…" + "checking_files": "正在校验文件…", + "extract": "解压文件", + "extracting": "正在解压文件…", + "options": "管理", + "resume_seeding": "恢复做种", + "seeding": "做种中", + "stop_seeding": "停止做种" }, "settings": { "downloads_path": "下载路径", @@ -260,7 +321,83 @@ "must_be_valid_url": "来源必须是有效的 URL", "blocked_users": "已屏蔽用户", "user_unblocked": "用户已经被屏蔽", - "enable_achievement_notifications": "当成就解锁时" + "enable_achievement_notifications": "当成就解锁时", + "account": "账户", + "account_data_updated_successfully": "账户数据更新成功", + "achievement_custom_notification_position": "成就自定义通知位置", + "alignment": "对齐", + "appearance": "外观", + "become_subscriber": "成为Hydra Cloud用户", + "bill_sent_until": "您的下一张账单将在此日期前发送", + "bottom-center": "底部中央", + "bottom-left": "底部左侧", + "bottom-right": "底部右侧", + "cancel": "取消", + "clear_themes": "清除", + "common_redist": "通用可再发行组件", + "common_redist_description": "运行某些游戏需要通用可再发行组件。建议安装以避免问题。", + "create_real_debrid_account": "如果您还没有Real-Debrid账户,请点击此处", + "create_theme": "创建", + "create_theme_modal_description": "创建新主题以自定义Hydra的外观", + "create_theme_modal_title": "创建自定义主题", + "create_torbox_account": "如果您还没有TorBox账户,请点击此处", + "current_email": "当前邮箱:", + "default": "默认", + "delete_all_themes": "删除所有主题", + "delete_all_themes_description": "这将删除所有您的自定义主题", + "delete_theme": "删除主题", + "delete_theme_description": "这将删除主题 {{theme}}", + "disable_nsfw_alert": "禁用NSFW警告", + "edit_theme": "编辑主题", + "editor_tab_code": "代码", + "editor_tab_info": "信息", + "editor_tab_save": "保存", + "enable_achievement_custom_notifications": "启用成就自定义通知", + "enable_auto_install": "自动下载更新", + "enable_friend_request_notifications": "当收到好友请求时", + "enable_friend_start_game_notifications": "当好友开始游戏时", + "enable_torbox": "启用TorBox", + "error_importing_theme": "导入主题时出错", + "extract_files_by_default": "下载后默认解压文件", + "hidden": "隐藏", + "import_theme": "导入主题", + "import_theme_description": "您将从主题商店导入 {{theme}}", + "insert_theme_name": "输入主题名称", + "install_common_redist": "安装", + "installing_common_redist": "正在安装…", + "launch_minimized": "最小化启动Hydra", + "manage_subscription": "管理订阅", + "name_min_length": "主题名称必须至少3个字符长", + "no_email_account": "您尚未设置邮箱", + "no_subscription": "以最佳方式享受Hydra", + "no_themes": "看起来您还没有任何主题,但别担心,点击这里创建您的第一个杰作。", + "no_users_blocked": "您没有屏蔽任何用户", + "notification_preview": "成就通知预览", + "platinum": "白金", + "rare": "稀有", + "real_debrid_account_linked": "Real-Debrid账户已连接", + "renew_subscription": "续费Hydra Cloud", + "seed_after_download_complete": "下载完成后做种", + "set_theme": "设置主题", + "show_download_speed_in_megabytes": "以兆字节每秒显示下载速度", + "show_hidden_achievement_description": "在解锁前显示隐藏成就描述", + "subscription_active_until": "您的Hydra Cloud活跃至 {{date}}", + "subscription_expired_at": "您的订阅已于 {{date}} 到期", + "subscription_renew_cancelled": "自动续费已禁用", + "subscription_renews_on": "您的订阅将于 {{date}} 续费", + "test_notification": "测试通知", + "theme_imported": "主题导入成功", + "theme_name": "名称", + "top-center": "顶部中央", + "top-left": "顶部左侧", + "top-right": "顶部右侧", + "torbox_account_linked": "TorBox账户已连接", + "torbox_description": "TorBox是您的高级种子盒服务,甚至可与市场上最好的服务器相媲美。", + "unset_theme": "取消设置主题", + "update_email": "更新邮箱", + "update_password": "更新密码", + "variation": "变体", + "web_store": "网络商店" }, "notifications": { "download_complete": "下载完成", @@ -271,14 +408,23 @@ "new_update_available": "版本 {{version}} 可用", "restart_to_install_update": "重启 Hydra 以安装更新", "notification_achievement_unlocked_title": "{{game}} 的成绩已解锁", - "notification_achievement_unlocked_body": "{{achievement}} 和其他 {{count}} 已解锁" + "notification_achievement_unlocked_body": "{{achievement}} 和其他 {{count}} 已解锁", + "extraction_complete": "解压完成", + "friend_started_playing_game": "{{displayName}} 开始玩游戏", + "game_extracted": "{{title}} 解压成功", + "new_friend_request_description": "{{displayName}} 向您发送了好友请求", + "new_friend_request_title": "新好友请求", + "test_achievement_notification_description": "非常酷,对吧?", + "test_achievement_notification_title": "这是一个测试通知" }, "system_tray": { "open": "打开 Hydra", "quit": "退出" }, "game_card": { - "no_downloads": "无可用下载选项" + "no_downloads": "无可用下载选项", + "available_one": "可用", + "available_other": "可用" }, "binary_not_found_modal": { "title": "程序未安装", @@ -351,7 +497,7 @@ "report_description": "额外信息", "report_description_placeholder": "额外信息", "report": "举报", - "report_reason_hate": "Hate speech", + "report_reason_hate": "仇恨言论", "report_reason_sexual_content": "色情内容", "report_reason_violence": "暴力", "report_reason_spam": "骚扰", @@ -360,7 +506,19 @@ "your_friend_code": "你的好友代码:", "upload_banner": "上传横幅", "uploading_banner": "上传横幅中…", - "background_image_updated": "背景图片已更新" + "background_image_updated": "背景图片已更新", + "achievements": "成就", + "achievements_unlocked": "成就已解锁", + "earned_points": "获得积分", + "error_adding_friend": "无法发送好友请求。请检查好友代码", + "friend_code_length_error": "好友代码必须为8个字符", + "games": "游戏", + "playing": "正在玩 {{game}}", + "ranking_updated_weekly": "排名每周更新", + "show_achievements_on_profile": "在您的个人资料上显示成就", + "show_points_on_profile": "在您的个人资料上显示获得的积分", + "stats": "统计", + "top_percentile": "前 {{percentile}}%" }, "achievement": { "achievement_unlocked": "成就已解锁", @@ -368,7 +526,14 @@ "your_achievements": "你的成就", "unlocked_at": "解锁于: {{date}}", "subscription_needed": "需要订阅 Hydra Cloud 才能看到此内容", - "new_achievements_unlocked": "从 {{gameCount}} 游戏中解锁 {{achievementCount}} 新成就" + "new_achievements_unlocked": "从 {{gameCount}} 游戏中解锁 {{achievementCount}} 新成就", + "achievement_earn_points": "通过此成就获得 {{points}} 积分", + "achievement_progress": "{{unlockedCount}}/{{totalCount}} 成就", + "achievements_unlocked_for_game": "为 {{gameTitle}} 解锁了 {{achievementCount}} 个新成就", + "available_points": "可用积分:", + "earned_points": "获得积分:", + "hidden_achievement_tooltip": "这是一个隐藏成就", + "how_to_earn_achievements_points": "如何获得成就积分?" }, "hydra_cloud": { "subscription_tour_title": "Hydra 云订阅", @@ -378,6 +543,10 @@ "animated_profile_picture": "动画头像", "premium_support": "高级技术支持", "show_and_compare_achievements": "展示并与其他用户比较您的成就", - "animated_profile_banner": "动态个人简介横幅" + "animated_profile_banner": "动态个人简介横幅", + "debrid_description": "使用Nimbus下载速度提升4倍", + "hydra_cloud": "Hydra Cloud", + "hydra_cloud_feature_found": "您刚刚发现了一个Hydra Cloud功能!", + "learn_more": "了解更多" } } diff --git a/src/main/constants.ts b/src/main/constants.ts index 16642d50..b067be80 100644 --- a/src/main/constants.ts +++ b/src/main/constants.ts @@ -41,4 +41,4 @@ export const appVersion = app.getVersion() + (isStaging ? "-staging" : ""); export const ASSETS_PATH = path.join(SystemPath.getPath("userData"), "Assets"); -export const MAIN_LOOP_INTERVAL = 1500; +export const MAIN_LOOP_INTERVAL = 2000; diff --git a/src/main/events/catalogue/get-game-stats.ts b/src/main/events/catalogue/get-game-stats.ts index 9961a0b2..559d3a7d 100644 --- a/src/main/events/catalogue/get-game-stats.ts +++ b/src/main/events/catalogue/get-game-stats.ts @@ -1,17 +1,38 @@ import type { GameShop, GameStats } from "@types"; import { registerEvent } from "../register-event"; import { HydraApi } from "@main/services"; +import { gamesStatsCacheSublevel, levelKeys } from "@main/level"; + +const LOCAL_CACHE_EXPIRATION = 1000 * 60 * 30; // 30 minutes const getGameStats = async ( _event: Electron.IpcMainInvokeEvent, objectId: string, 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( `/games/stats`, { objectId, shop }, { needsAuth: false } - ); + ).then(async (data) => { + await gamesStatsCacheSublevel.put(levelKeys.game(shop, objectId), { + ...data, + updatedAt: Date.now(), + }); + + return data; + }); }; registerEvent("getGameStats", getGameStats); diff --git a/src/main/events/catalogue/save-game-shop-assets.ts b/src/main/events/catalogue/save-game-shop-assets.ts index 5fdba628..f0ec4343 100644 --- a/src/main/events/catalogue/save-game-shop-assets.ts +++ b/src/main/events/catalogue/save-game-shop-assets.ts @@ -8,7 +8,9 @@ const saveGameShopAssets = async ( shop: GameShop, assets: ShopAssets ): Promise => { - 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); diff --git a/src/main/events/index.ts b/src/main/events/index.ts index e4e6ed2e..d640e251 100644 --- a/src/main/events/index.ts +++ b/src/main/events/index.ts @@ -20,7 +20,6 @@ import "./library/create-game-shortcut"; import "./library/close-game"; import "./library/delete-game-folder"; import "./library/get-game-by-object-id"; -import "./library/sync-game-by-object-id"; import "./library/get-library"; import "./library/extract-game-download"; import "./library/open-game"; diff --git a/src/main/events/library/add-game-to-library.ts b/src/main/events/library/add-game-to-library.ts index 5b74cc8c..01495a39 100644 --- a/src/main/events/library/add-game-to-library.ts +++ b/src/main/events/library/add-game-to-library.ts @@ -1,13 +1,13 @@ import { registerEvent } from "../register-event"; import type { GameShop } from "@types"; import { createGame } from "@main/services/library-sync"; -import { updateLocalUnlockedAchievements } from "@main/services/achievements/update-local-unlocked-achivements"; import { downloadsSublevel, gamesShopAssetsSublevel, gamesSublevel, levelKeys, } from "@main/level"; +import { AchievementWatcherManager } from "@main/services/achievements/achievement-watcher-manager"; const addGameToLibrary = async ( _event: Electron.IpcMainInvokeEvent, @@ -43,7 +43,10 @@ const addGameToLibrary = async ( await createGame(game).catch(() => {}); - updateLocalUnlockedAchievements(game); + AchievementWatcherManager.firstSyncWithRemoteIfNeeded( + game.shop, + game.objectId + ); }; registerEvent("addGameToLibrary", addGameToLibrary); diff --git a/src/main/events/library/reset-game-achievements.ts b/src/main/events/library/reset-game-achievements.ts index b3d2daa2..e63afcec 100644 --- a/src/main/events/library/reset-game-achievements.ts +++ b/src/main/events/library/reset-game-achievements.ts @@ -16,7 +16,8 @@ const resetGameAchievements = async ( objectId: string ) => { 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; @@ -29,8 +30,6 @@ const resetGameAchievements = async ( } } - const levelKey = levelKeys.game(game.shop, game.objectId); - await gameAchievementsSublevel .get(levelKey) .then(async (gameAchievements) => { diff --git a/src/main/events/library/sync-game-by-object-id.ts b/src/main/events/library/sync-game-by-object-id.ts deleted file mode 100644 index 4365b33c..00000000 --- a/src/main/events/library/sync-game-by-object-id.ts +++ /dev/null @@ -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( - `/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); diff --git a/src/main/events/user/get-compared-unlocked-achievements.ts b/src/main/events/user/get-compared-unlocked-achievements.ts index 697ad716..be641f2a 100644 --- a/src/main/events/user/get-compared-unlocked-achievements.ts +++ b/src/main/events/user/get-compared-unlocked-achievements.ts @@ -3,6 +3,7 @@ import { registerEvent } from "../register-event"; import { HydraApi } from "@main/services"; import { db, levelKeys } from "@main/level"; +import { AchievementWatcherManager } from "@main/services/achievements/achievement-watcher-manager"; const getComparedUnlockedAchievements = async ( _event: Electron.IpcMainInvokeEvent, @@ -10,6 +11,8 @@ const getComparedUnlockedAchievements = async ( shop: GameShop, userId: string ) => { + await AchievementWatcherManager.firstSyncWithRemoteIfNeeded(shop, objectId); + const userPreferences = await db.get( levelKeys.userPreferences, { diff --git a/src/main/events/user/get-unlocked-achievements.ts b/src/main/events/user/get-unlocked-achievements.ts index 21aad7a0..9e6f044d 100644 --- a/src/main/events/user/get-unlocked-achievements.ts +++ b/src/main/events/user/get-unlocked-achievements.ts @@ -2,6 +2,7 @@ import type { GameShop, UserAchievement, UserPreferences } from "@types"; import { registerEvent } from "../register-event"; import { getGameAchievementData } from "@main/services/achievements/get-game-achievement-data"; import { db, gameAchievementsSublevel, levelKeys } from "@main/level"; +import { AchievementWatcherManager } from "@main/services/achievements/achievement-watcher-manager"; export const getUnlockedAchievements = async ( objectId: string, @@ -62,7 +63,7 @@ export const getUnlockedAchievements = async ( !achievementData.hidden || showHiddenAchievementsDescription ? achievementData.description : undefined, - } as UserAchievement; + }; }) .sort((a, b) => { if (a.unlocked && !b.unlocked) return -1; @@ -79,6 +80,7 @@ const getUnlockedAchievementsEvent = async ( objectId: string, shop: GameShop ): Promise => { + await AchievementWatcherManager.firstSyncWithRemoteIfNeeded(shop, objectId); return getUnlockedAchievements(objectId, shop, false); }; diff --git a/src/main/level/sublevels/game-stats-cache.ts b/src/main/level/sublevels/game-stats-cache.ts new file mode 100644 index 00000000..05fa0cb3 --- /dev/null +++ b/src/main/level/sublevels/game-stats-cache.ts @@ -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", +}); diff --git a/src/main/level/sublevels/index.ts b/src/main/level/sublevels/index.ts index 0bf742cb..f78f09b8 100644 --- a/src/main/level/sublevels/index.ts +++ b/src/main/level/sublevels/index.ts @@ -2,6 +2,7 @@ export * from "./downloads"; export * from "./games"; export * from "./game-shop-assets"; export * from "./game-shop-cache"; +export * from "./game-stats-cache"; export * from "./game-achievements"; export * from "./keys"; export * from "./themes"; diff --git a/src/main/level/sublevels/keys.ts b/src/main/level/sublevels/keys.ts index cab7c4f7..bba35169 100644 --- a/src/main/level/sublevels/keys.ts +++ b/src/main/level/sublevels/keys.ts @@ -7,6 +7,7 @@ export const levelKeys = { auth: "auth", themes: "themes", gameShopAssets: "gameShopAssets", + gameStatsCache: "gameStatsAssets", gameShopCache: "gameShopCache", gameShopCacheItem: (shop: GameShop, objectId: string, language: string) => `${shop}:${objectId}:${language}`, diff --git a/src/main/services/achievements/achievement-watcher-manager.ts b/src/main/services/achievements/achievement-watcher-manager.ts index 5cf09d4f..b862abbe 100644 --- a/src/main/services/achievements/achievement-watcher-manager.ts +++ b/src/main/services/achievements/achievement-watcher-manager.ts @@ -3,6 +3,7 @@ import { mergeAchievements } from "./merge-achievements"; import fs, { readdirSync } from "node:fs"; import { findAchievementFileInExecutableDirectory, + findAchievementFileInSteamPath, findAchievementFiles, findAllAchievementFiles, getAlternativeObjectIds, @@ -10,6 +11,7 @@ import { import type { AchievementFile, Game, + GameShop, UnlockedAchievement, UserPreferences, } from "@types"; @@ -18,7 +20,7 @@ import { Cracker } from "@shared"; import { publishCombinedNewAchievementNotification } from "../notifications"; import { db, gamesSublevel, levelKeys } from "@main/level"; import { WindowManager } from "../window-manager"; -import { sleep } from "@main/helpers"; +import { setTimeout } from "node:timers/promises"; const fileStats: Map = new Map(); const fltFiles: Map> = new Map(); @@ -37,15 +39,19 @@ const watchAchievementsWindows = async () => { const gameAchievementFiles: AchievementFile[] = []; for (const objectId of getAlternativeObjectIds(game.objectId)) { - gameAchievementFiles.push(...(achievementFiles.get(objectId) || [])); + gameAchievementFiles.push(...(achievementFiles.get(objectId) ?? [])); gameAchievementFiles.push( ...findAchievementFileInExecutableDirectory(game) ); + + gameAchievementFiles.push( + ...(await findAchievementFileInSteamPath(game)) + ); } for (const file of gameAchievementFiles) { - compareFile(game, file); + await compareFile(game, file); } } }; @@ -60,13 +66,11 @@ const watchAchievementsWithWine = async () => { for (const game of games) { const gameAchievementFiles = findAchievementFiles(game); - const achievementFileInsideDirectory = - findAchievementFileInExecutableDirectory(game); - gameAchievementFiles.push(...achievementFileInsideDirectory); + gameAchievementFiles.push(...(await findAchievementFileInSteamPath(game))); for (const file of gameAchievementFiles) { - compareFile(game, file); + await compareFile(game, file); } } }; @@ -127,6 +131,11 @@ const compareFile = (game: Game, file: AchievementFile) => { ); return processAchievementFileDiff(game, file); } catch (err) { + achievementsLogger.error( + "Error reading file", + file.filePath, + err instanceof Error ? err.message : err + ); fileStats.set(file.filePath, -1); return; } @@ -136,20 +145,66 @@ const processAchievementFileDiff = async ( game: Game, file: AchievementFile ) => { - const unlockedAchievements = parseAchievementFile(file.filePath, file.type); + const parsedAchievements = parseAchievementFile(file.filePath, file.type); - if (unlockedAchievements.length) { - return mergeAchievements(game, unlockedAchievements, true); + if (parsedAchievements.length) { + return mergeAchievements(game, parsedAchievements, true); } return 0; }; export class AchievementWatcherManager { - private static hasFinishedMergingWithRemote = false; + private static _hasFinishedPreSearch = false; + + public static get hasFinishedPreSearch() { + return this._hasFinishedPreSearch; + } + + public static readonly alreadySyncedGames: Map = new Map(); + + public static async firstSyncWithRemoteIfNeeded( + shop: GameShop, + objectId: string + ) { + const gameKey = levelKeys.game(shop, objectId); + if (this.alreadySyncedGames.get(gameKey)) return; + + this.alreadySyncedGames.set(gameKey, true); + + const game = await gamesSublevel.get(gameKey).catch(() => null); + if (!game || game.isDeleted) return; + + const gameAchievementFiles = findAchievementFiles(game); + + gameAchievementFiles.push(...(await findAchievementFileInSteamPath(game))); + + const unlockedAchievements: UnlockedAchievement[] = []; + + for (const achievementFile of gameAchievementFiles) { + const localAchievementFile = parseAchievementFile( + achievementFile.filePath, + achievementFile.type + ); + + if (localAchievementFile.length) { + unlockedAchievements.push(...localAchievementFile); + } + } + + const newAchievements = await mergeAchievements( + game, + unlockedAchievements, + false + ); + + if (newAchievements > 0) { + this.notifyCombinedAchievementsUnlocked(1, newAchievements); + } + } public static watchAchievements() { - if (!this.hasFinishedMergingWithRemote) return; + if (!this.hasFinishedPreSearch) return; if (process.platform === "win32") { 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() { @@ -200,7 +259,7 @@ export class AchievementWatcherManager { const gameAchievementFilesMap = findAllAchievementFiles(); return Promise.all( - games.map((game) => { + games.map(async (game) => { const achievementFiles: AchievementFile[] = []; for (const objectId of getAlternativeObjectIds(game.objectId)) { @@ -211,6 +270,10 @@ export class AchievementWatcherManager { achievementFiles.push( ...findAchievementFileInExecutableDirectory(game) ); + + achievementFiles.push( + ...(await findAchievementFileInSteamPath(game)) + ); } return { game, achievementFiles }; @@ -225,37 +288,54 @@ export class AchievementWatcherManager { .then((games) => games.filter((game) => !game.isDeleted)); return Promise.all( - games.map((game) => { + games.map(async (game) => { const achievementFiles = findAchievementFiles(game); - const achievementFileInsideDirectory = - findAchievementFileInExecutableDirectory(game); - achievementFiles.push(...achievementFileInsideDirectory); + achievementFiles.push(...(await findAchievementFileInSteamPath(game))); return { game, achievementFiles }; }) ); } - public static async preSearchAchievements() { - await sleep(2000); + private static async notifyCombinedAchievementsUnlocked( + totalNewGamesWithAchievements: number, + totalNewAchievements: number + ) { + const userPreferences = await db.get( + 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 { const gameAchievementFiles = process.platform === "win32" ? await this.getGameAchievementFilesWindows() : await this.getGameAchievementFilesLinux(); - const newAchievementsCount: number[] = []; - - for (const { game, achievementFiles } of gameAchievementFiles) { - const result = await this.preProcessGameAchievementFiles( - game, - achievementFiles - ); - - newAchievementsCount.push(result); - } + const newAchievementsCount = await Promise.all( + gameAchievementFiles.map(({ game, achievementFiles }) => { + return this.preProcessGameAchievementFiles(game, achievementFiles); + }) + ); const totalNewGamesWithAchievements = newAchievementsCount.filter( (achievements) => achievements @@ -267,34 +347,16 @@ export class AchievementWatcherManager { ); if (totalNewAchievements > 0) { - const userPreferences = await db.get( - levelKeys.userPreferences, - { - valueEncoding: "json", - } + await setTimeout(4000); + this.notifyCombinedAchievementsUnlocked( + totalNewGamesWithAchievements, + 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) { achievementsLogger.error("Error on preSearchAchievements", err); } - this.hasFinishedMergingWithRemote = true; + this._hasFinishedPreSearch = true; } } diff --git a/src/main/services/achievements/find-achivement-files.ts b/src/main/services/achievements/find-achivement-files.ts index 9fb1977d..26d44170 100644 --- a/src/main/services/achievements/find-achivement-files.ts +++ b/src/main/services/achievements/find-achivement-files.ts @@ -1,9 +1,11 @@ import path from "node:path"; import fs from "node:fs"; -import type { Game, AchievementFile } from "@types"; +import type { Game, AchievementFile, UserPreferences } from "@types"; import { Cracker } from "@shared"; import { achievementsLogger } from "../logger"; import { SystemPath } from "../system-path"; +import { getSteamLocation, getSteamUsersIds } from "../steam"; +import { db, levelKeys } from "@main/level"; const getAppDataPath = () => { if (process.platform === "win32") { @@ -270,6 +272,51 @@ export const findAchievementFiles = (game: Game) => { } } + const achievementFileInsideDirectory = + findAchievementFileInExecutableDirectory(game); + + return achievementFiles.concat(achievementFileInsideDirectory); +}; + +const steamUserIds = await getSteamUsersIds(); +const steamPath = await getSteamLocation(); + +export const findAchievementFileInSteamPath = async (game: Game) => { + if (!steamUserIds.length) { + return []; + } + + const userPreferences = await db.get( + levelKeys.userPreferences, + { + valueEncoding: "json", + } + ); + + if (!userPreferences?.enableSteamAchievements) { + return []; + } + + const achievementFiles: AchievementFile[] = []; + + for (const steamUserId of steamUserIds) { + const gameAchievementPath = path.join( + steamPath, + "userdata", + steamUserId.toString(), + "config", + "librarycache", + `${game.objectId}.json` + ); + + if (fs.existsSync(gameAchievementPath)) { + achievementFiles.push({ + type: Cracker.Steam, + filePath: gameAchievementPath, + }); + } + } + return achievementFiles; }; @@ -303,7 +350,7 @@ export const findAchievementFileInExecutableDirectory = ( "achievements.ini" ), }, - ]; + ].filter((file) => fs.existsSync(file.filePath)) as AchievementFile[]; }; const mapFileLocationWithObjectId = ( diff --git a/src/main/services/achievements/get-game-achievement-data.ts b/src/main/services/achievements/get-game-achievement-data.ts index f4d66b6a..e2b663d8 100644 --- a/src/main/services/achievements/get-game-achievement-data.ts +++ b/src/main/services/achievements/get-game-achievement-data.ts @@ -1,24 +1,39 @@ import { HydraApi } from "../hydra-api"; -import type { GameShop, SteamAchievement } from "@types"; +import type { GameAchievement, GameShop, SteamAchievement } from "@types"; import { UserNotLoggedInError } from "@shared"; import { logger } from "../logger"; 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 ( objectId: string, shop: GameShop, useCachedData: boolean ) => { - const cachedAchievements = await gameAchievementsSublevel.get( - levelKeys.game(shop, objectId) - ); + const gameKey = levelKeys.game(shop, objectId); - if (cachedAchievements && useCachedData) + const cachedAchievements = await gameAchievementsSublevel.get(gameKey); + + if (cachedAchievements?.achievements && useCachedData) return cachedAchievements.achievements; if ( - cachedAchievements && - Date.now() < (cachedAchievements.cacheExpiresTimestamp ?? 0) + cachedAchievements?.achievements && + Date.now() < (cachedAchievements.updatedAt ?? 0) + LOCAL_CACHE_EXPIRATION ) { return cachedAchievements.achievements; } @@ -29,18 +44,22 @@ export const getGameAchievementData = async ( }) .then((language) => language || "en"); - return HydraApi.get("/games/achievements", { - shop, - objectId, - language, - }) + return HydraApi.get( + "/games/achievements", + { + shop, + objectId, + language, + }, + { + ifModifiedSince: getModifiedSinceHeader(cachedAchievements), + } + ) .then(async (achievements) => { - await gameAchievementsSublevel.put(levelKeys.game(shop, objectId), { + await gameAchievementsSublevel.put(gameKey, { unlockedAchievements: cachedAchievements?.unlockedAchievements ?? [], achievements, - cacheExpiresTimestamp: achievements.length - ? Date.now() + 1000 * 60 * 30 // 30 minutes - : undefined, + updatedAt: Date.now() + LOCAL_CACHE_EXPIRATION, }); return achievements; @@ -50,8 +69,14 @@ export const getGameAchievementData = async ( 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); - return []; + return cachedAchievements?.achievements ?? []; }); }; diff --git a/src/main/services/achievements/merge-achievements.ts b/src/main/services/achievements/merge-achievements.ts index 2674e451..f2ea03ac 100644 --- a/src/main/services/achievements/merge-achievements.ts +++ b/src/main/services/achievements/merge-achievements.ts @@ -14,6 +14,7 @@ import { SubscriptionRequiredError } from "@shared"; import { achievementsLogger } from "../logger"; import { db, gameAchievementsSublevel, levelKeys } from "@main/level"; import { getGameAchievementData } from "./get-game-achievement-data"; +import { AchievementWatcherManager } from "./achievement-watcher-manager"; const isRareAchievement = (points: number) => { const rawPercentage = (50 - Math.sqrt(points)) * 2; @@ -35,7 +36,7 @@ const saveAchievementsOnLocal = async ( await gameAchievementsSublevel.put(levelKey, { achievements: gameAchievement?.achievements ?? [], unlockedAchievements: unlockedAchievements, - cacheExpiresTimestamp: gameAchievement?.cacheExpiresTimestamp, + updatedAt: gameAchievement?.updatedAt, }); if (!sendUpdateEvent) return; @@ -56,9 +57,9 @@ export const mergeAchievements = async ( achievements: UnlockedAchievement[], publishNotification: boolean ) => { - let localGameAchievement = await gameAchievementsSublevel.get( - levelKeys.game(game.shop, game.objectId) - ); + const gameKey = levelKeys.game(game.shop, game.objectId); + + let localGameAchievement = await gameAchievementsSublevel.get(gameKey); const userPreferences = await db.get( levelKeys.userPreferences, { @@ -67,10 +68,8 @@ export const mergeAchievements = async ( ); if (!localGameAchievement) { - await getGameAchievementData(game.objectId, game.shop, true); - localGameAchievement = await gameAchievementsSublevel.get( - levelKeys.game(game.shop, game.objectId) - ); + await getGameAchievementData(game.objectId, game.shop, false); + localGameAchievement = await gameAchievementsSublevel.get(gameKey); } 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) { WindowManager.notificationWindow?.webContents.send( "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( "/profile/games/achievements", { @@ -194,8 +203,11 @@ export const mergeAchievements = async ( mergedLocalAchievements, publishNotification ); + }) + .finally(() => { + AchievementWatcherManager.alreadySyncedGames.set(gameKey, true); }); - } else { + } else if (newAchievements.length) { await saveAchievementsOnLocal( game.objectId, game.shop, diff --git a/src/main/services/achievements/parse-achievement-file.ts b/src/main/services/achievements/parse-achievement-file.ts index 726d8d0f..44827782 100644 --- a/src/main/services/achievements/parse-achievement-file.ts +++ b/src/main/services/achievements/parse-achievement-file.ts @@ -75,6 +75,11 @@ export const parseAchievementFile = ( return processRazor1911(filePath); } + if (type === Cracker.Steam) { + const parsed = jsonParse(filePath); + return processSteamCacheAchievement(parsed); + } + achievementsLogger.log( `Unprocessed ${type} achievements found on ${filePath}` ); @@ -234,6 +239,35 @@ const processGoldberg = (unlockedAchievements: any): UnlockedAchievement[] => { return newUnlockedAchievements; }; +const processSteamCacheAchievement = ( + unlockedAchievements: any[] +): UnlockedAchievement[] => { + const newUnlockedAchievements: UnlockedAchievement[] = []; + + const achievementIndex = unlockedAchievements.findIndex( + (element) => element[0] === "achievements" + ); + + if (achievementIndex === -1) { + achievementsLogger.info("No achievements found in Steam cache file"); + return []; + } + + const unlockedAchievementsData = + unlockedAchievements[achievementIndex][1]["data"]["vecHighlight"]; + + for (const achievement of unlockedAchievementsData) { + if (achievement.bAchieved) { + newUnlockedAchievements.push({ + name: achievement.strID, + unlockTime: achievement.rtUnlocked * 1000, + }); + } + } + + return newUnlockedAchievements; +}; + const process3DM = (unlockedAchievements: any): UnlockedAchievement[] => { const newUnlockedAchievements: UnlockedAchievement[] = []; diff --git a/src/main/services/achievements/update-local-unlocked-achivements.ts b/src/main/services/achievements/update-local-unlocked-achivements.ts deleted file mode 100644 index 44f2693a..00000000 --- a/src/main/services/achievements/update-local-unlocked-achivements.ts +++ /dev/null @@ -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); -}; diff --git a/src/main/services/download/types.ts b/src/main/services/download/types.ts index 7cecb103..e07c39a9 100644 --- a/src/main/services/download/types.ts +++ b/src/main/services/download/types.ts @@ -31,8 +31,8 @@ export interface ProcessPayload { exe: string | null; pid: number; name: string; - environ: Record | null; - cwd: string | null; + environ?: Record | null; + cwd?: string | null; } export interface PauseSeedingPayload { diff --git a/src/main/services/hydra-api.ts b/src/main/services/hydra-api.ts index 0f5a4d21..16a7e541 100644 --- a/src/main/services/hydra-api.ts +++ b/src/main/services/hydra-api.ts @@ -16,6 +16,7 @@ import { WSClient } from "./ws/ws-client"; interface HydraApiOptions { needsAuth?: boolean; needsSubscription?: boolean; + ifModifiedSince?: Date; } interface HydraApiUserAuth { @@ -337,8 +338,13 @@ export class HydraApi { ) { await this.validateOptions(options); + const headers = { + ...this.getAxiosConfig().headers, + "Hydra-If-Modified-Since": options?.ifModifiedSince?.toUTCString(), + }; + return this.instance - .get(url, { params, ...this.getAxiosConfig() }) + .get(url, { params, ...this.getAxiosConfig(), headers }) .then((response) => response.data) .catch(this.handleUnauthorizedError); } diff --git a/src/main/services/library-sync/merge-with-remote-games.ts b/src/main/services/library-sync/merge-with-remote-games.ts index 74ef9c77..b9d10f52 100644 --- a/src/main/services/library-sync/merge-with-remote-games.ts +++ b/src/main/services/library-sync/merge-with-remote-games.ts @@ -13,9 +13,8 @@ export const mergeWithRemoteGames = async () => { return HydraApi.get("/profile/games") .then(async (response) => { for (const game of response) { - const localGame = await gamesSublevel.get( - levelKeys.game(game.shop, game.objectId) - ); + const gameKey = levelKeys.game(game.shop, game.objectId); + const localGame = await gamesSublevel.get(gameKey); if (localGame) { const updatedLastTimePlayed = @@ -30,7 +29,7 @@ export const mergeWithRemoteGames = async () => { ? game.playTimeInMilliseconds : localGame.playTimeInMilliseconds; - await gamesSublevel.put(levelKeys.game(game.shop, game.objectId), { + await gamesSublevel.put(gameKey, { ...localGame, remoteId: game.id, lastTimePlayed: updatedLastTimePlayed, @@ -38,7 +37,7 @@ export const mergeWithRemoteGames = async () => { favorite: game.isFavorite ?? localGame.favorite, }); } else { - await gamesSublevel.put(levelKeys.game(game.shop, game.objectId), { + await gamesSublevel.put(gameKey, { objectId: game.objectId, title: game.title, remoteId: game.id, @@ -51,20 +50,17 @@ export const mergeWithRemoteGames = async () => { }); } - await gamesShopAssetsSublevel.put( - levelKeys.game(game.shop, game.objectId), - { - shop: game.shop, - objectId: game.objectId, - title: game.title, - coverImageUrl: game.coverImageUrl, - libraryHeroImageUrl: game.libraryHeroImageUrl, - libraryImageUrl: game.libraryImageUrl, - logoImageUrl: game.logoImageUrl, - iconUrl: game.iconUrl, - logoPosition: game.logoPosition, - } - ); + await gamesShopAssetsSublevel.put(gameKey, { + shop: game.shop, + objectId: game.objectId, + title: game.title, + coverImageUrl: game.coverImageUrl, + libraryHeroImageUrl: game.libraryHeroImageUrl, + libraryImageUrl: game.libraryImageUrl, + logoImageUrl: game.logoImageUrl, + iconUrl: game.iconUrl, + logoPosition: game.logoPosition, + }); } }) .catch(() => {}); diff --git a/src/main/services/library-sync/upload-games-batch.ts b/src/main/services/library-sync/upload-games-batch.ts index 984521db..837fb48a 100644 --- a/src/main/services/library-sync/upload-games-batch.ts +++ b/src/main/services/library-sync/upload-games-batch.ts @@ -34,9 +34,7 @@ export const uploadGamesBatch = async () => { await mergeWithRemoteGames(); - if (HydraApi.isLoggedIn()) { - AchievementWatcherManager.preSearchAchievements(); - } + AchievementWatcherManager.preSearchAchievements(); if (WindowManager.mainWindow) WindowManager.mainWindow.webContents.send("on-library-batch-complete"); diff --git a/src/main/services/process-watcher.ts b/src/main/services/process-watcher.ts index 7a2433bc..8c407ad5 100644 --- a/src/main/services/process-watcher.ts +++ b/src/main/services/process-watcher.ts @@ -8,6 +8,8 @@ import { gamesSublevel, levelKeys } from "@main/level"; import { CloudSync } from "./cloud-sync"; import { logger } from "./logger"; import path from "path"; +import { AchievementWatcherManager } from "./achievements/achievement-watcher-manager"; +import { MAIN_LOOP_INTERVAL } from "@main/constants"; export const gamesPlaytime = new Map< string, @@ -24,7 +26,7 @@ interface GameExecutables { [key: string]: ExecutableInfo[]; } -const TICKS_TO_UPDATE_API = 80; +const TICKS_TO_UPDATE_API = (3 * 60 * 1000) / MAIN_LOOP_INTERVAL; // 3 minutes let currentTick = 1; const platform = process.platform; @@ -190,6 +192,11 @@ 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, diff --git a/src/main/services/window-manager.ts b/src/main/services/window-manager.ts index 3d84d6f3..dfcfc3ba 100644 --- a/src/main/services/window-manager.ts +++ b/src/main/services/window-manager.ts @@ -370,14 +370,11 @@ export class WindowManager { }, }); this.notificationWindow.setIgnoreMouseEvents(true); - // this.notificationWindow.setVisibleOnAllWorkspaces(true, { - // visibleOnFullScreen: true, - // }); this.notificationWindow.setAlwaysOnTop(true, "screen-saver", 1); this.loadNotificationWindowURL(); - if (isStaging) { + if (!app.isPackaged || isStaging) { this.notificationWindow.webContents.openDevTools(); } } @@ -464,7 +461,7 @@ export class WindowManager { editorWindow.once("ready-to-show", () => { editorWindow.show(); this.mainWindow?.webContents.openDevTools(); - if (isStaging) { + if (!app.isPackaged || isStaging) { editorWindow.webContents.openDevTools(); } }); @@ -585,7 +582,7 @@ export class WindowManager { tray.popUpContextMenu(contextMenu); }; - tray.setToolTip("Hydra"); + tray.setToolTip("Hydra Launcher"); if (process.platform !== "darwin") { await updateSystemTray(); diff --git a/src/preload/index.ts b/src/preload/index.ts index 2a8297f1..ab2beae2 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -187,8 +187,6 @@ contextBridge.exposeInMainWorld("electron", { ipcRenderer.invoke("deleteGameFolder", shop, objectId), getGameByObjectId: (shop: GameShop, objectId: string) => ipcRenderer.invoke("getGameByObjectId", shop, objectId), - syncGameByObjectId: (shop: GameShop, objectId: string) => - ipcRenderer.invoke("syncGameByObjectId", shop, objectId), resetGameAchievements: (shop: GameShop, objectId: string) => ipcRenderer.invoke("resetGameAchievements", shop, objectId), extractGameDownload: (shop: GameShop, objectId: string) => diff --git a/src/renderer/index.html b/src/renderer/index.html index 5d62f4c5..42166e56 100644 --- a/src/renderer/index.html +++ b/src/renderer/index.html @@ -3,7 +3,7 @@ - Hydra + Hydra Launcher {}); } - - window.electron.syncGameByObjectId(shop, objectId).then(() => { - if (abortController.signal.aborted) return; - updateGame(); - }); }, [ updateGame, dispatch, diff --git a/src/renderer/src/declaration.d.ts b/src/renderer/src/declaration.d.ts index 79c72a1c..7b2f9412 100644 --- a/src/renderer/src/declaration.d.ts +++ b/src/renderer/src/declaration.d.ts @@ -155,7 +155,6 @@ declare global { shop: GameShop, objectId: string ) => Promise; - syncGameByObjectId: (shop: GameShop, objectId: string) => Promise; onGamesRunning: ( cb: ( gamesRunning: Pick[] diff --git a/src/renderer/src/hooks/use-user-details.ts b/src/renderer/src/hooks/use-user-details.ts index 5f7fdd66..a35a760b 100644 --- a/src/renderer/src/hooks/use-user-details.ts +++ b/src/renderer/src/hooks/use-user-details.ts @@ -12,7 +12,6 @@ import type { UpdateProfileRequest, UserDetails, } from "@types"; -import * as Sentry from "@sentry/react"; import { UserFriendModalTab } from "@renderer/pages/shared-modals/user-friend-modal"; export function useUserDetails() { @@ -29,8 +28,6 @@ export function useUserDetails() { } = useAppSelector((state) => state.userDetails); const clearUserDetails = useCallback(async () => { - Sentry.setUser(null); - dispatch(setUserDetails(null)); dispatch(setProfileBackground(null)); @@ -45,12 +42,6 @@ export function useUserDetails() { const updateUserDetails = useCallback( async (userDetails: UserDetails) => { - Sentry.setUser({ - id: userDetails.id, - username: userDetails.username, - email: userDetails.email ?? undefined, - }); - dispatch(setUserDetails(userDetails)); window.localStorage.setItem("userDetails", JSON.stringify(userDetails)); }, diff --git a/src/renderer/src/main.tsx b/src/renderer/src/main.tsx index c4cc1fef..a1b5f7d0 100644 --- a/src/renderer/src/main.tsx +++ b/src/renderer/src/main.tsx @@ -21,7 +21,6 @@ import resources from "@locales"; import { logger } from "./logger"; import { addCookieInterceptor } from "./cookies"; -import * as Sentry from "@sentry/react"; import Catalogue from "./pages/catalogue/catalogue"; import Home from "./pages/home/home"; import Downloads from "./pages/downloads/downloads"; @@ -32,18 +31,6 @@ import Achievements from "./pages/achievements/achievements"; import ThemeEditor from "./pages/theme-editor/theme-editor"; import { AchievementNotification } from "./pages/achievements/notification/achievement-notification"; -Sentry.init({ - dsn: import.meta.env.RENDERER_VITE_SENTRY_DSN, - integrations: [ - Sentry.browserTracingIntegration(), - Sentry.replayIntegration(), - ], - tracesSampleRate: 1.0, - replaysSessionSampleRate: 0.1, - replaysOnErrorSampleRate: 1.0, - release: await window.electron.getVersion(), -}); - console.log = logger.log; const isStaging = await window.electron.isStaging(); diff --git a/src/renderer/src/pages/achievements/achievements.scss b/src/renderer/src/pages/achievements/achievements.scss index e208a810..9b1deea5 100644 --- a/src/renderer/src/pages/achievements/achievements.scss +++ b/src/renderer/src/pages/achievements/achievements.scss @@ -158,8 +158,8 @@ $logo-max-width: 200px; &-points { display: flex; align-items: center; + justify-content: end; gap: 4px; - margin-right: 4px; font-weight: 600; &--locked { diff --git a/src/renderer/src/pages/achievements/notification/achievement-notification.tsx b/src/renderer/src/pages/achievements/notification/achievement-notification.tsx index dcbdc4ac..c5c37933 100644 --- a/src/renderer/src/pages/achievements/notification/achievement-notification.tsx +++ b/src/renderer/src/pages/achievements/notification/achievement-notification.tsx @@ -55,7 +55,6 @@ export function AchievementNotification() { isHidden: false, isRare: false, isPlatinum: false, - points: 0, iconUrl: "https://cdn.losbroxas.org/favicon.svg", }, ]); diff --git a/src/renderer/src/pages/catalogue/game-item.tsx b/src/renderer/src/pages/catalogue/game-item.tsx index 25813d5b..542a7f96 100644 --- a/src/renderer/src/pages/catalogue/game-item.tsx +++ b/src/renderer/src/pages/catalogue/game-item.tsx @@ -72,6 +72,11 @@ export function GameItem({ game }: GameItemProps) { ); if (index !== undefined && steamGenres[language] && steamGenres[language][index]) { + if ( + index !== undefined && + steamGenres[language] && + steamGenres[language][index] + ) { return steamGenres[language][index]; } diff --git a/src/renderer/src/pages/settings/settings-behavior.scss b/src/renderer/src/pages/settings/settings-behavior.scss index 693ef08a..2ebe3cc0 100644 --- a/src/renderer/src/pages/settings/settings-behavior.scss +++ b/src/renderer/src/pages/settings/settings-behavior.scss @@ -9,5 +9,16 @@ opacity: 1; cursor: pointer; } + + &--with-tooltip { + display: flex; + flex-direction: row; + gap: 8px; + align-items: center; + } + + &--tooltip { + cursor: pointer; + } } } diff --git a/src/renderer/src/pages/settings/settings-behavior.tsx b/src/renderer/src/pages/settings/settings-behavior.tsx index 4fcdb92d..64df52d7 100644 --- a/src/renderer/src/pages/settings/settings-behavior.tsx +++ b/src/renderer/src/pages/settings/settings-behavior.tsx @@ -5,6 +5,7 @@ import { CheckboxField } from "@renderer/components"; import { useAppSelector } from "@renderer/hooks"; import { settingsContext } from "@renderer/context"; import "./settings-behavior.scss"; +import { QuestionIcon } from "@primer/octicons-react"; export function SettingsBehavior() { const userPreferences = useAppSelector( @@ -25,6 +26,7 @@ export function SettingsBehavior() { showHiddenAchievementsDescription: false, showDownloadSpeedInMegabytes: false, extractFilesByDefault: true, + enableSteamAchievements: false, }); const { t } = useTranslation("settings"); @@ -45,6 +47,8 @@ export function SettingsBehavior() { showDownloadSpeedInMegabytes: userPreferences.showDownloadSpeedInMegabytes ?? false, extractFilesByDefault: userPreferences.extractFilesByDefault ?? true, + enableSteamAchievements: + userPreferences.enableSteamAchievements ?? false, }); } }, [userPreferences]); @@ -164,6 +168,25 @@ export function SettingsBehavior() { }) } /> + +
+ + handleChange({ + enableSteamAchievements: !form.enableSteamAchievements, + }) + } + /> + + + + +
); } diff --git a/src/renderer/src/pages/shared-modals/user-friend-modal/user-friend-modal-add-friend.tsx b/src/renderer/src/pages/shared-modals/user-friend-modal/user-friend-modal-add-friend.tsx index a597c809..84248522 100644 --- a/src/renderer/src/pages/shared-modals/user-friend-modal/user-friend-modal-add-friend.tsx +++ b/src/renderer/src/pages/shared-modals/user-friend-modal/user-friend-modal-add-friend.tsx @@ -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) => { updateFriendRequestState(userId, "CANCEL").catch(() => { showErrorToast(t("try_again")); @@ -91,13 +99,13 @@ export const UserFriendModalAddFriend = ({ disabled={isAddingFriend} className="user-friend-modal-add-friend__button" type="button" - onClick={handleClickAddFriend} + onClick={() => validateFriendCode(handleClickAddFriend)} > {isAddingFriend ? t("sending") : t("add")}