Compare commits

..

4 Commits

Author SHA1 Message Date
Chubby Granny Chaser
379c211472 feat: merge with v3.7.4
Some checks failed
Build Renderer / build (push) Has been cancelled
Release / build (ubuntu-latest) (push) Has been cancelled
Release / build (windows-2022) (push) Has been cancelled
2025-11-29 01:33:23 +00:00
Chubby Granny Chaser
e2f30a28e4 fix: fixing labels 2025-11-02 04:14:39 +00:00
Chubby Granny Chaser
1d6acab5da Merge branch 'main' of https://github.com/hydralauncher/hydra into release/v3.7.2 2025-11-02 04:03:18 +00:00
Chubby Granny Chaser
9046bec7da fix: fixing review item 2025-11-02 04:03:06 +00:00
172 changed files with 1933 additions and 6981 deletions

View File

@@ -28,26 +28,6 @@
- Use async/await instead of promises when possible - Use async/await instead of promises when possible
- Prefer named exports over default exports for utilities and services - Prefer named exports over default exports for utilities and services
## ESLint Issues
- **Always try to fix ESLint errors properly before disabling rules**
- When encountering ESLint errors, explore these solutions in order:
1. **Fix the code to comply with the rule** (e.g., add missing required elements, fix accessibility issues)
2. **Use minimal markup to satisfy the rule** (e.g., add empty `<track>` elements for videos without captions, add `role` attributes)
3. **Only disable the rule as a last resort** when no reasonable solution exists
- When disabling a rule, always include a comment explaining why it's necessary
- Examples of proper fixes:
- For `jsx-a11y/media-has-caption`: Add `<track kind="captions" />` even if no captions are available
- For `jsx-a11y/alt-text`: Add meaningful alt text or `alt=""` for decorative images
- For accessibility rules: Add appropriate ARIA attributes rather than disabling
## TypeScript Array Syntax
- **Always use `T[]` syntax instead of `Array<T>`** for array types
- Prefer: `string[]`, `number[]`, `MyType[]`
- Avoid: `Array<string>`, `Array<number>`, `Array<MyType>`
- This applies to all type annotations, type assertions, and generic type parameters
## Comments ## Comments
- Keep comments concise and purposeful; avoid verbose explanations. - Keep comments concise and purposeful; avoid verbose explanations.

View File

@@ -1,6 +1,6 @@
<div align="center"> <div align="center">
[<img src="https://raw.githubusercontent.com/hydralauncher/hydra/refs/heads/main/resources/icon.png" width="144"/>](https://help.hydralauncher.gg) [<img src="./resources/icon.png" width="144"/>](https://help.hydralauncher.gg)
<h1 align="center">Hydra Launcher</h1> <h1 align="center">Hydra Launcher</h1>
@@ -10,7 +10,6 @@
[![build](https://img.shields.io/github/actions/workflow/status/hydralauncher/hydra/build.yml)](https://github.com/hydralauncher/hydra/actions) [![build](https://img.shields.io/github/actions/workflow/status/hydralauncher/hydra/build.yml)](https://github.com/hydralauncher/hydra/actions)
[![release](https://img.shields.io/github/package-json/v/hydralauncher/hydra)](https://github.com/hydralauncher/hydra/releases) [![release](https://img.shields.io/github/package-json/v/hydralauncher/hydra)](https://github.com/hydralauncher/hydra/releases)
[![chocolatey](https://img.shields.io/chocolatey/v/hydralauncher.svg)](https://community.chocolatey.org/packages/hydralauncher)
![Hydra Launcher Home Page](./docs/screenshot.png) ![Hydra Launcher Home Page](./docs/screenshot.png)

View File

@@ -1,6 +1,6 @@
{ {
"name": "hydralauncher", "name": "hydralauncher",
"version": "3.7.6", "version": "3.7.4",
"description": "Hydra", "description": "Hydra",
"main": "./out/main/index.js", "main": "./out/main/index.js",
"author": "Los Broxas", "author": "Los Broxas",
@@ -63,14 +63,12 @@
"embla-carousel-react": "^8.6.0", "embla-carousel-react": "^8.6.0",
"file-type": "^20.5.0", "file-type": "^20.5.0",
"framer-motion": "^12.15.0", "framer-motion": "^12.15.0",
"hls.js": "^1.5.12",
"i18next": "^23.11.2", "i18next": "^23.11.2",
"i18next-browser-languagedetector": "^7.2.1", "i18next-browser-languagedetector": "^7.2.1",
"jsdom": "^24.0.0", "jsdom": "^24.0.0",
"jsonwebtoken": "^9.0.2", "jsonwebtoken": "^9.0.2",
"lodash-es": "^4.17.21", "lodash-es": "^4.17.21",
"lucide-react": "^0.544.0", "lucide-react": "^0.544.0",
"node-7z": "^3.0.0",
"parse-torrent": "^11.0.18", "parse-torrent": "^11.0.18",
"rc-virtual-list": "^3.18.3", "rc-virtual-list": "^3.18.3",
"react-dnd": "^16.0.1", "react-dnd": "^16.0.1",
@@ -86,7 +84,7 @@
"sound-play": "^1.1.0", "sound-play": "^1.1.0",
"steam-shortcut-editor": "https://github.com/hydralauncher/steam-shortcut-editor", "steam-shortcut-editor": "https://github.com/hydralauncher/steam-shortcut-editor",
"sudo-prompt": "^9.2.1", "sudo-prompt": "^9.2.1",
"tar": "^7.5.2", "tar": "^7.4.3",
"tough-cookie": "^5.1.1", "tough-cookie": "^5.1.1",
"user-agents": "^1.1.387", "user-agents": "^1.1.387",
"uuid": "^13.0.0", "uuid": "^13.0.0",

2
proto

Submodule proto updated: 6f11c99c57...7a23620f93

View File

@@ -26,7 +26,6 @@
"game_has_no_executable": "Game has no executable selected", "game_has_no_executable": "Game has no executable selected",
"sign_in": "Sign in", "sign_in": "Sign in",
"friends": "Friends", "friends": "Friends",
"notifications": "Notifications",
"need_help": "Need help?", "need_help": "Need help?",
"favorites": "Favorites", "favorites": "Favorites",
"playable_button_title": "Show only games you can play now", "playable_button_title": "Show only games you can play now",
@@ -97,7 +96,7 @@
"search_library": "Search library", "search_library": "Search library",
"recent_searches": "Recent Searches", "recent_searches": "Recent Searches",
"suggestions": "Suggestions", "suggestions": "Suggestions",
"clear_history": "Clear", "clear_history": "Clear history",
"remove_from_history": "Remove from history", "remove_from_history": "Remove from history",
"loading": "Loading...", "loading": "Loading...",
"no_results": "No results", "no_results": "No results",
@@ -116,7 +115,6 @@
"downloading": "Downloading {{title}}… ({{percentage}} complete) - Completion {{eta}} - {{speed}}", "downloading": "Downloading {{title}}… ({{percentage}} complete) - Completion {{eta}} - {{speed}}",
"calculating_eta": "Downloading {{title}}… ({{percentage}} complete) - Calculating remaining time…", "calculating_eta": "Downloading {{title}}… ({{percentage}} complete) - Calculating remaining time…",
"checking_files": "Checking {{title}} files… ({{percentage}} complete)", "checking_files": "Checking {{title}} files… ({{percentage}} complete)",
"extracting": "Extracting {{title}}… ({{percentage}} complete)",
"installing_common_redist": "{{log}}…", "installing_common_redist": "{{log}}…",
"installation_complete": "Installation complete", "installation_complete": "Installation complete",
"installation_complete_message": "Common redistributables installed successfully" "installation_complete_message": "Common redistributables installed successfully"
@@ -204,7 +202,6 @@
"danger_zone_section_description": "Remove this game from your library or the files downloaded by Hydra", "danger_zone_section_description": "Remove this game from your library or the files downloaded by Hydra",
"download_in_progress": "Download in progress", "download_in_progress": "Download in progress",
"download_paused": "Download paused", "download_paused": "Download paused",
"extracting": "Extracting",
"last_downloaded_option": "Last downloaded option", "last_downloaded_option": "Last downloaded option",
"new_download_option": "New", "new_download_option": "New",
"create_steam_shortcut": "Create Steam shortcut", "create_steam_shortcut": "Create Steam shortcut",
@@ -417,13 +414,7 @@
"resume_seeding": "Resume seeding", "resume_seeding": "Resume seeding",
"options": "Manage", "options": "Manage",
"extract": "Extract files", "extract": "Extract files",
"extracting": "Extracting files…", "extracting": "Extracting files…"
"delete_archive_title": "Would you like to delete {{fileName}}?",
"delete_archive_description": "The file has been successfully extracted and it's no longer needed.",
"yes": "Yes",
"no": "No",
"network": "NETWORK",
"peak": "PEAK"
}, },
"settings": { "settings": {
"downloads_path": "Downloads path", "downloads_path": "Downloads path",
@@ -559,7 +550,6 @@
"show_download_speed_in_megabytes": "Show download speed in megabytes per second", "show_download_speed_in_megabytes": "Show download speed in megabytes per second",
"extract_files_by_default": "Extract files by default after download", "extract_files_by_default": "Extract files by default after download",
"enable_steam_achievements": "Enable search for Steam achievements", "enable_steam_achievements": "Enable search for Steam achievements",
"enable_new_download_options_badges": "Show new download options badges",
"achievement_custom_notification_position": "Achievement custom notification position", "achievement_custom_notification_position": "Achievement custom notification position",
"top-left": "Top left", "top-left": "Top left",
"top-center": "Top center", "top-center": "Top center",
@@ -664,7 +654,6 @@
"sending": "Sending", "sending": "Sending",
"friend_request_sent": "Friend request sent", "friend_request_sent": "Friend request sent",
"friends": "Friends", "friends": "Friends",
"badges": "Badges",
"friends_list": "Friends list", "friends_list": "Friends list",
"user_not_found": "User not found", "user_not_found": "User not found",
"block_user": "Block user", "block_user": "Block user",
@@ -675,16 +664,12 @@
"ignore_request": "Ignore request", "ignore_request": "Ignore request",
"cancel_request": "Cancel request", "cancel_request": "Cancel request",
"undo_friendship": "Undo friendship", "undo_friendship": "Undo friendship",
"friendship_removed": "Friend removed",
"request_accepted": "Request accepted", "request_accepted": "Request accepted",
"user_blocked_successfully": "User blocked successfully", "user_blocked_successfully": "User blocked successfully",
"user_block_modal_text": "This will block {{displayName}}", "user_block_modal_text": "This will block {{displayName}}",
"blocked_users": "Blocked users", "blocked_users": "Blocked users",
"unblock": "Unblock", "unblock": "Unblock",
"no_friends_added": "You have no added friends", "no_friends_added": "You have no added friends",
"view_all": "View all",
"load_more": "Load more",
"loading": "Loading",
"pending": "Pending", "pending": "Pending",
"no_pending_invites": "You have no pending invites", "no_pending_invites": "You have no pending invites",
"no_blocked_users": "You have no blocked users", "no_blocked_users": "You have no blocked users",
@@ -708,7 +693,6 @@
"report_reason_other": "Other", "report_reason_other": "Other",
"profile_reported": "Profile reported", "profile_reported": "Profile reported",
"your_friend_code": "Your friend code:", "your_friend_code": "Your friend code:",
"copy_friend_code": "Copy friend code",
"upload_banner": "Upload banner", "upload_banner": "Upload banner",
"uploading_banner": "Uploading banner…", "uploading_banner": "Uploading banner…",
"background_image_updated": "Background image updated", "background_image_updated": "Background image updated",
@@ -731,10 +715,7 @@
"karma_description": "Earned from positive likes on reviews", "karma_description": "Earned from positive likes on reviews",
"user_reviews": "Reviews", "user_reviews": "Reviews",
"delete_review": "Delete Review", "delete_review": "Delete Review",
"loading_reviews": "Loading reviews...", "loading_reviews": "Loading reviews..."
"wrapped_2025": "Wrapped 2025",
"view_my_wrapped_button": "View My Wrapped 2025",
"view_wrapped_button": "View {{displayName}}'s Wrapped 2025"
}, },
"library": { "library": {
"library": "Library", "library": "Library",
@@ -785,40 +766,5 @@
"hydra_cloud_feature_found": "You've just discovered a Hydra Cloud feature!", "hydra_cloud_feature_found": "You've just discovered a Hydra Cloud feature!",
"learn_more": "Learn More", "learn_more": "Learn More",
"debrid_description": "Download up to 4x faster with Nimbus" "debrid_description": "Download up to 4x faster with Nimbus"
},
"notifications_page": {
"title": "Notifications",
"mark_all_as_read": "Mark all as read",
"clear_all": "Clear All",
"loading": "Loading...",
"empty_title": "No notifications",
"empty_description": "You're all caught up! Check back later for new updates.",
"empty_filter_description": "No notifications match this filter.",
"filter_all": "All",
"filter_friends": "Friends",
"filter_badges": "Badges",
"filter_upvotes": "Upvotes",
"filter_local": "Local",
"load_more": "Load more",
"dismiss": "Dismiss",
"accept": "Accept",
"refuse": "Refuse",
"notification": "Notification",
"friend_request_received_title": "New friend request!",
"friend_request_received_description": "{{displayName}} wants to be your friend",
"friend_request_accepted_title": "Friend request accepted!",
"friend_request_accepted_description": "{{displayName}} accepted your friend request",
"badge_received_title": "You got a new badge!",
"badge_received_description": "{{badgeName}}",
"review_upvote_title": "Your review for {{gameTitle}} got upvotes!",
"review_upvote_description": "Your review received {{count}} new upvotes",
"marked_all_as_read": "All notifications marked as read",
"failed_to_mark_as_read": "Failed to mark notifications as read",
"cleared_all": "All notifications cleared",
"failed_to_clear": "Failed to clear notifications",
"failed_to_load": "Failed to load notifications",
"failed_to_dismiss": "Failed to dismiss notification",
"friend_request_accepted": "Friend request accepted",
"friend_request_refused": "Friend request refused"
} }
} }

View File

@@ -93,16 +93,8 @@
}, },
"header": { "header": {
"search": "Buscar juegos", "search": "Buscar juegos",
"search_library": "Buscar en la librería",
"recent_searches": "Búsquedas Recientes",
"suggestions": "Sugerencias",
"clear_history": "Limpiar",
"remove_from_history": "Eliminar del historial",
"loading": "Cargando...",
"no_results": "Sin resultados",
"home": "Inicio", "home": "Inicio",
"catalogue": "Catálogo", "catalogue": "Catálogo",
"library": "Librería",
"downloads": "Descargas", "downloads": "Descargas",
"search_results": "Resultados de búsqueda", "search_results": "Resultados de búsqueda",
"settings": "Ajustes", "settings": "Ajustes",
@@ -370,6 +362,7 @@
"write_review_placeholder": "Compartí tus pensamientos sobre este juego...", "write_review_placeholder": "Compartí tus pensamientos sobre este juego...",
"yes": "Si", "yes": "Si",
"you_seemed_to_enjoy_this_game": "Parece que has disfrutado de este juego", "you_seemed_to_enjoy_this_game": "Parece que has disfrutado de este juego",
"review_played_for": "Jugado por",
"language": "Idioma", "language": "Idioma",
"caption": "Subtítulo", "caption": "Subtítulo",
"audio": "Audio", "audio": "Audio",
@@ -458,7 +451,6 @@
"description_confirmation_delete_all_sources": "Vas a eliminar todas las fuentes de descargas", "description_confirmation_delete_all_sources": "Vas a eliminar todas las fuentes de descargas",
"button_delete_all_sources": "Eliminar todo", "button_delete_all_sources": "Eliminar todo",
"added_download_source": "Añadir fuente de descarga", "added_download_source": "Añadir fuente de descarga",
"adding": "Añadiendo…",
"download_sources_synced": "Todas las fuentes de descarga están sincronizadas", "download_sources_synced": "Todas las fuentes de descarga están sincronizadas",
"insert_valid_json_url": "Introducí una URL de json válida", "insert_valid_json_url": "Introducí una URL de json válida",
"found_download_option_zero": "Sin opciones de descargas encontrada", "found_download_option_zero": "Sin opciones de descargas encontrada",
@@ -564,19 +556,6 @@
"debrid_description": "Los servicios Debrid son descargadores premium sin restricciones que te dejan descargar más rápido archivos alojados en servicios de alojamiento siendo que la única limitación es tu velocidad de internet.", "debrid_description": "Los servicios Debrid son descargadores premium sin restricciones que te dejan descargar más rápido archivos alojados en servicios de alojamiento siendo que la única limitación es tu velocidad de internet.",
"enable_friend_start_game_notifications": "Cuando un amigo está jugando un juego", "enable_friend_start_game_notifications": "Cuando un amigo está jugando un juego",
"autoplay_trailers_on_game_page": "Reproducir trailers automáticamente en la página del juego", "autoplay_trailers_on_game_page": "Reproducir trailers automáticamente en la página del juego",
"change_achievement_sound": "Cambiar sonido de logro",
"download_source_already_exists": "Esta fuente de descarga URL ya existe.",
"download_source_failed": "Error",
"download_source_matched": "Actualizado",
"download_source_matching": "Actualizando",
"download_source_no_information": "Sin información disponible",
"download_source_pending_matching": "Actualizando pronto",
"download_sources_synced_successfully": "Todas las fuentes de descarga están sincronizadas",
"failed_add_download_source": "Error al añadir la fuente de descarga. Por favor intentá de nuevo.",
"hydra_cloud": "Hydra Cloud",
"preview_sound": "Vista previa de sonido",
"remove_achievement_sound": "Eliminar sonido de logros",
"removed_all_download_sources": "Todas las fuentes de descarga eliminadas",
"hide_to_tray_on_game_start": "Ocultar Hydra en la bandeja al iniciar un juego" "hide_to_tray_on_game_start": "Ocultar Hydra en la bandeja al iniciar un juego"
}, },
"notifications": { "notifications": {

View File

@@ -27,69 +27,7 @@
"friends": "Amis", "friends": "Amis",
"need_help": "Besoin d'aide ?", "need_help": "Besoin d'aide ?",
"favorites": "Favoris", "favorites": "Favoris",
"playable_button_title": "Afficher uniquement les jeux que vous pouvez jouer maintenant", "playable_button_title": "Afficher uniquement les jeux que vous pouvez jouer maintenant"
"library": "Bibliothèque",
"add_custom_game_tooltip": "Ajouter un jeu personnalisé",
"show_playable_only_tooltip": "Afficher uniquement les jeux jouables",
"custom_game_modal": "Ajouter un jeu personnalisé",
"custom_game_modal_description": "Ajoutez un jeu personnalisé à votre bibliothèque en sélectionnant un fichier exécutable",
"custom_game_modal_executable_path": "Chemin de l'exécutable",
"custom_game_modal_select_executable": "Sélectionner un fichier exécutable",
"custom_game_modal_title": "Titre",
"custom_game_modal_enter_title": "Entrer le titre",
"custom_game_modal_browse": "Parcourir",
"custom_game_modal_cancel": "Annuler",
"custom_game_modal_add": "Ajouter le jeu",
"custom_game_modal_adding": "Ajout du jeu…",
"custom_game_modal_success": "Jeu personnalisé ajouté avec succès",
"custom_game_modal_failed": "Échec de lajout du jeu personnalisé",
"custom_game_modal_executable": "Exécutable",
"edit_game_modal": "Personnaliser les ressources",
"edit_game_modal_description": "Personnalisez les ressources et les détails du jeu",
"edit_game_modal_title": "Titre",
"edit_game_modal_enter_title": "Entrer le titre",
"edit_game_modal_image": "Image",
"edit_game_modal_select_image": "Sélectionner une image",
"edit_game_modal_browse": "Parcourir",
"edit_game_modal_image_preview": "Aperçu de limage",
"edit_game_modal_icon": "Icône",
"edit_game_modal_select_icon": "Sélectionner une icône",
"edit_game_modal_icon_preview": "Aperçu de licône",
"edit_game_modal_logo": "Logo",
"edit_game_modal_select_logo": "Sélectionner un logo",
"edit_game_modal_logo_preview": "Aperçu du logo",
"edit_game_modal_hero": "Bannière de la bibliothèque",
"edit_game_modal_select_hero": "Sélectionner limage de bannière",
"edit_game_modal_hero_preview": "Aperçu de la bannière",
"edit_game_modal_cancel": "Annuler",
"edit_game_modal_update": "Mettre à jour",
"edit_game_modal_updating": "Mise à jour…",
"edit_game_modal_fill_required": "Veuillez remplir tous les champs requis",
"edit_game_modal_success": "Ressources mises à jour avec succès",
"edit_game_modal_failed": "Échec de la mise à jour des ressources",
"edit_game_modal_image_filter": "Image",
"edit_game_modal_icon_resolution": "Résolution recommandée : 256x256px",
"edit_game_modal_logo_resolution": "Résolution recommandée : 640x360px",
"edit_game_modal_hero_resolution": "Résolution recommandée : 1920x620px",
"edit_game_modal_assets": "Ressources",
"edit_game_modal_drop_icon_image_here": "Déposez limage de licône ici",
"edit_game_modal_drop_logo_image_here": "Déposez limage du logo ici",
"edit_game_modal_drop_hero_image_here": "Déposez limage de la bannière ici",
"edit_game_modal_drop_to_replace_icon": "Déposez pour remplacer licône",
"edit_game_modal_drop_to_replace_logo": "Déposez pour remplacer le logo",
"edit_game_modal_drop_to_replace_hero": "Déposez pour remplacer la bannière",
"install_decky_plugin": "Installer le plugin Decky",
"update_decky_plugin": "Mettre à jour le plugin Decky",
"decky_plugin_installed_version": "Plugin Decky (v{{version}})",
"install_decky_plugin_title": "Installer le plugin Decky Hydra",
"install_decky_plugin_message": "Cela téléchargera et installera le plugin Hydra pour Decky Loader. Des permissions élevées peuvent être requises. Continuer ?",
"update_decky_plugin_title": "Mettre à jour le plugin Decky Hydra",
"update_decky_plugin_message": "Une nouvelle version du plugin Decky Hydra est disponible. Souhaitez-vous la mettre à jour maintenant ?",
"decky_plugin_installed": "Plugin Decky v{{version}} installé avec succès",
"decky_plugin_installation_failed": "Échec de linstallation du plugin Decky : {{error}}",
"decky_plugin_installation_error": "Erreur lors de linstallation du plugin Decky : {{error}}",
"confirm": "Confirmer",
"cancel": "Annuler"
}, },
"header": { "header": {
"search": "Rechercher", "search": "Rechercher",
@@ -99,15 +37,7 @@
"search_results": "Résultats de la recherche", "search_results": "Résultats de la recherche",
"settings": "Paramètres", "settings": "Paramètres",
"version_available_install": "Version {{version}} disponible. Cliquez ici pour redémarrer et installer.", "version_available_install": "Version {{version}} disponible. Cliquez ici pour redémarrer et installer.",
"version_available_download": "Version {{version}} disponible. Cliquez ici pour télécharger.", "version_available_download": "Version {{version}} disponible. Cliquez ici pour télécharger."
"search_library": "Rechercher dans la bibliothèque",
"recent_searches": "Recherches récentes",
"suggestions": "Suggestions",
"clear_history": "Effacer",
"remove_from_history": "Supprimer de l'historique",
"loading": "Chargement…",
"no_results": "Aucun résultat",
"library": "Bibliothèque"
}, },
"bottom_panel": { "bottom_panel": {
"no_downloads_in_progress": "Aucun téléchargement en cours", "no_downloads_in_progress": "Aucun téléchargement en cours",
@@ -117,8 +47,7 @@
"checking_files": "Vérification des fichiers de {{title}}… ({{percentage}} terminé)", "checking_files": "Vérification des fichiers de {{title}}… ({{percentage}} terminé)",
"installing_common_redist": "{{log}}…", "installing_common_redist": "{{log}}…",
"installation_complete": "Installation terminée", "installation_complete": "Installation terminée",
"installation_complete_message": "Redistribuables communs installés avec succès", "installation_complete_message": "Redistribuables communs installés avec succès"
"extracting": "Extraction de {{title}}… ({{percentage}} terminé)"
}, },
"catalogue": { "catalogue": {
"search": "Filtrer…", "search": "Filtrer…",
@@ -269,113 +198,7 @@
"download_error_not_cached_on_hydra": "Ce téléchargement n'est pas disponible sur Nimbus.", "download_error_not_cached_on_hydra": "Ce téléchargement n'est pas disponible sur Nimbus.",
"game_removed_from_favorites": "Jeu retiré des favoris", "game_removed_from_favorites": "Jeu retiré des favoris",
"game_added_to_favorites": "Jeu ajouté aux favoris", "game_added_to_favorites": "Jeu ajouté aux favoris",
"automatically_extract_downloaded_files": "Extraire automatiquement les fichiers téléchargés", "automatically_extract_downloaded_files": "Extraire automatiquement les fichiers téléchargés"
"already_in_library": "Déjà dans la bibliothèque",
"create_shortcut_simple": "Créer un raccourci",
"properties": "Propriétés",
"extracting": "Extraction en cours",
"new_download_option": "Nouveau",
"create_steam_shortcut": "Créer un raccourci Steam",
"you_might_need_to_restart_steam": "Vous devrez peut-être redémarrer Steam pour voir les changements",
"add_to_favorites": "Ajouter aux favoris",
"remove_from_favorites": "Retirer des favoris",
"failed_update_favorites": "Échec de la mise à jour des favoris",
"game_removed_from_library": "Jeu retiré de la bibliothèque",
"failed_remove_from_library": "Échec de la suppression du jeu de la bibliothèque",
"files_removed_success": "Fichiers supprimés avec succès",
"failed_remove_files": "Échec de la suppression des fichiers",
"rating_count": "Évaluations",
"show_more": "Afficher plus",
"show_less": "Afficher moins",
"reviews": "Avis",
"review_played_for": "Temps de jeu",
"leave_a_review": "Laisser un avis",
"write_review_placeholder": "Partagez votre avis sur ce jeu…",
"sort_newest": "Les plus récents",
"sort_oldest": "Les plus anciens",
"sort_highest_score": "Meilleure note",
"sort_lowest_score": "Note la plus basse",
"sort_most_voted": "Les plus votés",
"no_reviews_yet": "Aucun avis pour le moment",
"be_first_to_review": "Soyez le premier à donner votre avis !",
"rating": "Note",
"rating_stats": "Évaluation",
"rating_very_negative": "Très négatif",
"rating_negative": "Négatif",
"rating_neutral": "Neutre",
"rating_positive": "Positif",
"rating_very_positive": "Très positif",
"submit_review": "Envoyer",
"submitting": "Envoi…",
"review_submitted_successfully": "Avis envoyé avec succès !",
"review_submission_failed": "Échec de lenvoi de lavis. Veuillez réessayer.",
"review_cannot_be_empty": "Le champ de lavis ne peut pas être vide.",
"review_deleted_successfully": "Avis supprimé avec succès.",
"review_deletion_failed": "Échec de la suppression de lavis.",
"loading_reviews": "Chargement des avis…",
"loading_more_reviews": "Chargement de plus davis…",
"load_more_reviews": "Charger plus davis",
"you_seemed_to_enjoy_this_game": "Vous semblez avoir apprécié ce jeu",
"would_you_recommend_this_game": "Souhaitez-vous laisser un avis sur ce jeu ?",
"yes": "Oui",
"maybe_later": "Peut-être plus tard",
"backup_failed": "Échec de la sauvegarde",
"update_playtime_title": "Mettre à jour le temps de jeu",
"update_playtime_description": "Mettre à jour manuellement le temps de jeu pour {{game}}",
"update_playtime": "Mettre à jour le temps de jeu",
"update_playtime_success": "Temps de jeu mis à jour avec succès",
"update_playtime_error": "Échec de la mise à jour du temps de jeu",
"update_game_playtime": "Mettre à jour le temps de jeu",
"manual_playtime_warning": "Vos heures seront marquées comme modifiées manuellement et cela ne peut pas être annulé.",
"manual_playtime_tooltip": "Ce temps de jeu a été modifié manuellement",
"game_removed_from_pinned": "Jeu retiré des épinglés",
"game_added_to_pinned": "Jeu ajouté aux épinglés",
"create_start_menu_shortcut": "Créer un raccourci dans le menu Démarrer",
"invalid_wine_prefix_path": "Chemin du préfixe Wine invalide",
"invalid_wine_prefix_path_description": "Le chemin du préfixe Wine est invalide. Veuillez vérifier et réessayer.",
"missing_wine_prefix": "Un préfixe Wine est requis pour créer une sauvegarde sous Linux",
"artifact_renamed": "Sauvegarde renommée avec succès",
"rename_artifact": "Renommer la sauvegarde",
"rename_artifact_description": "Renommez la sauvegarde avec un nom plus descriptif",
"artifact_name_label": "Nom de la sauvegarde",
"artifact_name_placeholder": "Entrez un nom pour la sauvegarde",
"save_changes": "Enregistrer les modifications",
"required_field": "Ce champ est requis",
"max_length_field": "Ce champ doit contenir moins de {{length}} caractères",
"freeze_backup": "Épingler pour éviter lécrasement automatique",
"unfreeze_backup": "Désépingler",
"backup_frozen": "Sauvegarde épinglée",
"backup_unfrozen": "Sauvegarde désépinglée",
"backup_freeze_failed": "Échec de lépinglage de la sauvegarde",
"backup_freeze_failed_description": "Vous devez laisser au moins un emplacement libre pour les sauvegardes automatiques",
"edit_game_modal_button": "Personnaliser les ressources du jeu",
"game_details": "Détails du jeu",
"prices": "Prix",
"no_prices_found": "Aucun prix trouvé",
"view_all_prices": "Cliquer pour voir tous les prix",
"retail_price": "Prix officiel",
"keyshop_price": "Prix Keyshop",
"historical_retail": "Historique officiel",
"historical_keyshop": "Historique Keyshop",
"language": "Langue",
"caption": "Sous-titres",
"audio": "Audio",
"filter_by_source": "Filtrer par source",
"no_repacks_found": "Aucune source trouvée pour ce jeu",
"delete_review": "Supprimer lavis",
"remove_review": "Retirer lavis",
"delete_review_modal_title": "Voulez-vous vraiment supprimer votre avis ?",
"delete_review_modal_description": "Cette action est irréversible.",
"delete_review_modal_delete_button": "Supprimer",
"delete_review_modal_cancel_button": "Annuler",
"vote_failed": "Échec de lenregistrement de votre vote. Veuillez réessayer.",
"show_original": "Afficher loriginal",
"show_translation": "Afficher la traduction",
"show_original_translated_from": "Afficher loriginal (traduit depuis {{language}})",
"hide_original": "Masquer loriginal",
"review_from_blocked_user": "Avis dun utilisateur bloqué",
"show": "Afficher",
"hide": "Masquer"
}, },
"activation": { "activation": {
"title": "Activer Hydra", "title": "Activer Hydra",
@@ -414,11 +237,7 @@
"resume_seeding": "Reprendre le partage", "resume_seeding": "Reprendre le partage",
"options": "Gérer", "options": "Gérer",
"extract": "Extraire les fichiers", "extract": "Extraire les fichiers",
"extracting": "Extraction des fichiers…", "extracting": "Extraction des fichiers…"
"delete_archive_title": "Voulez-vous supprimer {{fileName}} ?",
"delete_archive_description": "Le fichier a été extrait avec succès et nest plus nécessaire.",
"yes": "Oui",
"no": "Non"
}, },
"settings": { "settings": {
"downloads_path": "Chemin des téléchargements", "downloads_path": "Chemin des téléchargements",
@@ -547,40 +366,7 @@
"bottom-left": "En bas à gauche", "bottom-left": "En bas à gauche",
"bottom-center": "En bas au centre", "bottom-center": "En bas au centre",
"bottom-right": "En bas à droite", "bottom-right": "En bas à droite",
"enable_friend_start_game_notifications": "Quand un ami commence à jouer à un jeu", "enable_friend_start_game_notifications": "Quand un ami commence à jouer à un jeu"
"adding": "Ajout…",
"failed_add_download_source": "Échec de lajout de la source de téléchargement. Veuillez réessayer.",
"download_source_already_exists": "Cette URL de source existe déjà",
"download_source_pending_matching": "Mise à jour imminente",
"download_source_matched": "À jour",
"download_source_matching": "Mise à jour",
"download_source_failed": "Erreur",
"download_source_no_information": "Aucune information disponible",
"removed_all_download_sources": "Toutes les sources de téléchargement supprimées",
"download_sources_synced_successfully": "Toutes les sources de téléchargement ont été synchronisées",
"importing": "Importation…",
"hydra_cloud": "Hydra Cloud",
"debrid": "Debrid",
"enable_steam_achievements": "Activer la recherche de succès Steam",
"alignment": "Alignement",
"variation": "Variation",
"default": "Par défaut",
"rare": "Rare",
"platinum": "Platine",
"hidden": "Caché",
"test_notification": "Notification de test",
"achievement_sound_volume": "Volume du son de succès",
"select_achievement_sound": "Sélectionner un son de succès",
"change_achievement_sound": "Changer le son de succès",
"remove_achievement_sound": "Supprimer le son de succès",
"preview_sound": "Prévisualiser le son",
"select": "Sélectionner",
"preview": "Aperçu",
"remove": "Supprimer",
"no_sound_file_selected": "Aucun fichier sonore sélectionné",
"notification_preview": "Aperçu de la notification de succès",
"autoplay_trailers_on_game_page": "Lire automatiquement les bandes-annonces sur la page du jeu",
"hide_to_tray_on_game_start": "Réduire Hydra dans la barre système au lancement dun jeu"
}, },
"notifications": { "notifications": {
"download_complete": "Téléchargement terminé", "download_complete": "Téléchargement terminé",

View File

@@ -22,7 +22,7 @@
"downloading": "{{title}} ({{percentage}} - Letöltés…)", "downloading": "{{title}} ({{percentage}} - Letöltés…)",
"filter": "Könyvtár szűrése", "filter": "Könyvtár szűrése",
"home": "Főoldal", "home": "Főoldal",
"queued": "{{title}} (Várakozásban)", "queued": "A(z) {{title}} (Várakozósorban van)",
"game_has_no_executable": "A játékhoz nincs tallózva futtatható fájl", "game_has_no_executable": "A játékhoz nincs tallózva futtatható fájl",
"sign_in": "Bejelentkezés", "sign_in": "Bejelentkezés",
"friends": "Barátok", "friends": "Barátok",
@@ -94,12 +94,6 @@
"header": { "header": {
"search": "Keresés", "search": "Keresés",
"search_library": "Könyvtár böngészése", "search_library": "Könyvtár böngészése",
"recent_searches": "Korábbi Keresések",
"suggestions": "Találatok",
"clear_history": "Törlés",
"remove_from_history": "Törlés az előzményekből",
"loading": "Töltés...",
"no_results": "Nincs találat",
"home": "Főoldal", "home": "Főoldal",
"catalogue": "Katalógus", "catalogue": "Katalógus",
"library": "Könyvtár", "library": "Könyvtár",
@@ -115,7 +109,6 @@
"downloading": "{{title}} letöltése… ({{percentage}} kész) - Befejezés {{eta}} - {{speed}}", "downloading": "{{title}} letöltése… ({{percentage}} kész) - Befejezés {{eta}} - {{speed}}",
"calculating_eta": "{{title}} letöltése… ({{percentage}} kész) - Hátralévő idő…", "calculating_eta": "{{title}} letöltése… ({{percentage}} kész) - Hátralévő idő…",
"checking_files": "A(z) {{title}} fájljaiból… ({{percentage}} kész)", "checking_files": "A(z) {{title}} fájljaiból… ({{percentage}} kész)",
"extracting": "{{title}} kicsomagolása… ({{percentage}} kicsomagolva)",
"installing_common_redist": "{{log}}…", "installing_common_redist": "{{log}}…",
"installation_complete": "Telepítés befejezve", "installation_complete": "Telepítés befejezve",
"installation_complete_message": "A(z) Alapvető segédprogramok sikeresen telepítve" "installation_complete_message": "A(z) Alapvető segédprogramok sikeresen telepítve"
@@ -172,7 +165,7 @@
"playing_now": "Játékban: ", "playing_now": "Játékban: ",
"change": "Változtatás", "change": "Változtatás",
"repacks_modal_description": "Válaszd ki a repacket amit leszeretnél tölteni", "repacks_modal_description": "Válaszd ki a repacket amit leszeretnél tölteni",
"select_folder_hint": "A letöltési mappát a <0>Beállításokban</0> változtathatod meg", "select_folder_hint": "A letöltési mappát a <0>Beállítások</0> menüjében változtathatod meg",
"download_now": "Letöltés", "download_now": "Letöltés",
"no_shop_details": "A bolt adatai nem érhetőek el.", "no_shop_details": "A bolt adatai nem érhetőek el.",
"download_options": "Letöltési opciók", "download_options": "Letöltési opciók",
@@ -203,7 +196,6 @@
"danger_zone_section_description": "Itt eltávolítható a játék a könyvtáradból, vagy a fájlok amelyek a Hydra által lettek letöltve", "danger_zone_section_description": "Itt eltávolítható a játék a könyvtáradból, vagy a fájlok amelyek a Hydra által lettek letöltve",
"download_in_progress": "Letöltés folyamatban", "download_in_progress": "Letöltés folyamatban",
"download_paused": "Letöltés szüneteltetve", "download_paused": "Letöltés szüneteltetve",
"extracting": "Kicsomagolás",
"last_downloaded_option": "Utoljára letöltött", "last_downloaded_option": "Utoljára letöltött",
"new_download_option": "Új", "new_download_option": "Új",
"create_steam_shortcut": "Steam parancsikon létrehozása", "create_steam_shortcut": "Steam parancsikon létrehozása",
@@ -405,7 +397,7 @@
"delete_modal_description": "Ez eltávolítja a telepítési fájlokat a számítógépedről", "delete_modal_description": "Ez eltávolítja a telepítési fájlokat a számítógépedről",
"install": "Telepít", "install": "Telepít",
"download_in_progress": "Folyamatban lévő", "download_in_progress": "Folyamatban lévő",
"queued_downloads": "Várakozásban lévő letöltések", "queued_downloads": "Várakozósoron lévő letöltések",
"downloads_completed": "Befejezett", "downloads_completed": "Befejezett",
"queued": "Várakozásban", "queued": "Várakozásban",
"no_downloads_title": "Oly üres..", "no_downloads_title": "Oly üres..",
@@ -416,11 +408,7 @@
"resume_seeding": "Seedelés folytatása", "resume_seeding": "Seedelés folytatása",
"options": "Kezelés", "options": "Kezelés",
"extract": "Fájlok kibontása", "extract": "Fájlok kibontása",
"extracting": "Fájlok kibontása…", "extracting": "Fájlok kibontása…"
"delete_archive_title": "Szeretnéd törölni ezt a fájlt? {{fileName}}",
"delete_archive_description": "A tömörített fájl ki lett csomagolva, s többé nincs rá szükség. ",
"yes": "Igen",
"no": "Nem"
}, },
"settings": { "settings": {
"downloads_path": "Letöltési útvonalak", "downloads_path": "Letöltési útvonalak",
@@ -681,7 +669,7 @@
"no_blocked_users": "Nincs letiltott felhasználó", "no_blocked_users": "Nincs letiltott felhasználó",
"friend_code_copied": "Barát kód kimásolva", "friend_code_copied": "Barát kód kimásolva",
"undo_friendship_modal_text": "Ezáltal megszünteted a barátságod vele: {{displayName}}", "undo_friendship_modal_text": "Ezáltal megszünteted a barátságod vele: {{displayName}}",
"privacy_hint": "Hogy beállítsd ki láthassa ezt, menj a <0>Beállításokba</0>", "privacy_hint": "Hogy beállítsd ki láthassa ezt, menj a <0>Beállítások</0> menüjébe",
"locked_profile": "Ez a profil privát", "locked_profile": "Ez a profil privát",
"image_process_failure": "Hiba a kép feldolgozása közben", "image_process_failure": "Hiba a kép feldolgozása közben",
"required_field": "Ez a mező kötelező", "required_field": "Ez a mező kötelező",

View File

@@ -93,19 +93,11 @@
}, },
"header": { "header": {
"search": "Buscar jogos", "search": "Buscar jogos",
"search_library": "Buscar na biblioteca",
"recent_searches": "Buscas Recentes",
"suggestions": "Sugestões",
"clear_history": "Limpar",
"remove_from_history": "Remover do histórico",
"loading": "Carregando...",
"no_results": "Sem resultados",
"home": "Início",
"catalogue": "Catálogo", "catalogue": "Catálogo",
"library": "Biblioteca",
"downloads": "Downloads", "downloads": "Downloads",
"search_results": "Resultados da busca", "search_results": "Resultados da busca",
"settings": "Ajustes", "settings": "Ajustes",
"home": "Início",
"version_available_install": "Versão {{version}} disponível. Clique aqui para reiniciar e instalar.", "version_available_install": "Versão {{version}} disponível. Clique aqui para reiniciar e instalar.",
"version_available_download": "Versão {{version}} disponível. Clique aqui para fazer o download." "version_available_download": "Versão {{version}} disponível. Clique aqui para fazer o download."
}, },
@@ -115,7 +107,6 @@
"downloading": "Baixando {{title}}… ({{percentage}} concluído) - Conclusão {{eta}} - {{speed}}", "downloading": "Baixando {{title}}… ({{percentage}} concluído) - Conclusão {{eta}} - {{speed}}",
"calculating_eta": "Baixando {{title}}… ({{percentage}} concluído) - Calculando tempo restante…", "calculating_eta": "Baixando {{title}}… ({{percentage}} concluído) - Calculando tempo restante…",
"checking_files": "Verificando arquivos de {{title}}…", "checking_files": "Verificando arquivos de {{title}}…",
"extracting": "Extraindo {{title}}… ({{percentage}} concluído)",
"installing_common_redist": "{{log}}…", "installing_common_redist": "{{log}}…",
"installation_complete": "Instalação concluída", "installation_complete": "Instalação concluída",
"installation_complete_message": "Componentes recomendados instalados com sucesso" "installation_complete_message": "Componentes recomendados instalados com sucesso"
@@ -191,7 +182,6 @@
"danger_zone_section_description": "Remova o jogo da sua biblioteca ou os arquivos que foram baixados pelo Hydra", "danger_zone_section_description": "Remova o jogo da sua biblioteca ou os arquivos que foram baixados pelo Hydra",
"download_in_progress": "Download em andamento", "download_in_progress": "Download em andamento",
"download_paused": "Download pausado", "download_paused": "Download pausado",
"extracting": "Extraindo",
"last_downloaded_option": "Última opção baixada", "last_downloaded_option": "Última opção baixada",
"new_download_option": "Novo", "new_download_option": "Novo",
"create_steam_shortcut": "Criar atalho na Steam", "create_steam_shortcut": "Criar atalho na Steam",
@@ -321,6 +311,7 @@
"show_more": "Mostrar mais", "show_more": "Mostrar mais",
"show_less": "Mostrar menos", "show_less": "Mostrar menos",
"reviews": "Avaliações", "reviews": "Avaliações",
"review_played_for": "Jogou por",
"leave_a_review": "Deixar uma Avaliação", "leave_a_review": "Deixar uma Avaliação",
"write_review_placeholder": "Compartilhe seus pensamentos sobre este jogo...", "write_review_placeholder": "Compartilhe seus pensamentos sobre este jogo...",
"sort_newest": "Mais Recentes", "sort_newest": "Mais Recentes",
@@ -404,13 +395,7 @@
"resume_seeding": "Semear", "resume_seeding": "Semear",
"options": "Gerenciar", "options": "Gerenciar",
"extract": "Extrair arquivos", "extract": "Extrair arquivos",
"extracting": "Extraindo arquivos…", "extracting": "Extraindo arquivos…"
"delete_archive_title": "Deseja deletar {{fileName}}?",
"delete_archive_description": "O arquivo foi extraído com sucesso e não é mais necessário.",
"yes": "Sim",
"no": "Não",
"network": "REDE",
"peak": "PICO"
}, },
"settings": { "settings": {
"downloads_path": "Diretório dos downloads", "downloads_path": "Diretório dos downloads",

View File

@@ -30,19 +30,11 @@
}, },
"header": { "header": {
"search": "Procurar jogos", "search": "Procurar jogos",
"search_library": "Procurar na biblioteca",
"recent_searches": "Pesquisas Recentes",
"suggestions": "Sugestões",
"clear_history": "Limpar",
"remove_from_history": "Remover do histórico",
"loading": "A carregar...",
"no_results": "Sem resultados",
"home": "Início",
"catalogue": "Catálogo", "catalogue": "Catálogo",
"library": "Biblioteca",
"downloads": "Transferências", "downloads": "Transferências",
"search_results": "Resultados da pesquisa", "search_results": "Resultados da pesquisa",
"settings": "Definições", "settings": "Definições",
"home": "Início",
"version_available_install": "Versão {{version}} disponível. Clica aqui para reiniciar e instalar.", "version_available_install": "Versão {{version}} disponível. Clica aqui para reiniciar e instalar.",
"version_available_download": "Versão {{version}} disponível. Clica aqui para fazer o download." "version_available_download": "Versão {{version}} disponível. Clica aqui para fazer o download."
}, },
@@ -190,6 +182,7 @@
"game_added_to_favorites": "Jogo adicionado aos favoritos", "game_added_to_favorites": "Jogo adicionado aos favoritos",
"create_start_menu_shortcut": "Criar atalho no Menu Iniciar", "create_start_menu_shortcut": "Criar atalho no Menu Iniciar",
"review_from_blocked_user": "Avaliação de utilizador bloqueado", "review_from_blocked_user": "Avaliação de utilizador bloqueado",
"review_played_for": "Jogaste por",
"show": "Mostrar", "show": "Mostrar",
"hide": "Ocultar", "hide": "Ocultar",
"review_played_for": "Jogado por" "review_played_for": "Jogado por"

View File

@@ -93,16 +93,8 @@
}, },
"header": { "header": {
"search": "Поиск", "search": "Поиск",
"search_library": "Поиск в библиотеке",
"recent_searches": "Недавние поиски",
"suggestions": "Предложения",
"clear_history": "Очистить",
"remove_from_history": "Удалить из истории",
"loading": "Загрузка...",
"no_results": "Нет результатов",
"home": "Главная", "home": "Главная",
"catalogue": "Каталог", "catalogue": "Каталог",
"library": "Библиотека",
"downloads": "Загрузки", "downloads": "Загрузки",
"search_results": "Результаты поиска", "search_results": "Результаты поиска",
"settings": "Настройки", "settings": "Настройки",
@@ -233,6 +225,7 @@
"show_more": "Показать больше", "show_more": "Показать больше",
"show_less": "Показать меньше", "show_less": "Показать меньше",
"reviews": "Отзывы", "reviews": "Отзывы",
"review_played_for": "Играно",
"leave_a_review": "Оставить отзыв", "leave_a_review": "Оставить отзыв",
"write_review_placeholder": "Поделитесь своими мыслями об этой игре...", "write_review_placeholder": "Поделитесь своими мыслями об этой игре...",
"sort_newest": "Сначала новые", "sort_newest": "Сначала новые",

View File

@@ -1,3 +0,0 @@
import "./get-session-hash";
import "./open-auth-window";
import "./sign-out";

View File

@@ -1,2 +0,0 @@
import "./check-for-updates";
import "./restart-and-install-update";

View File

@@ -1,4 +0,0 @@
import "./get-game-assets";
import "./get-game-shop-details";
import "./get-game-stats";
import "./get-random-game";

View File

@@ -1,4 +0,0 @@
import "./download-game-artifact";
import "./get-game-backup-preview";
import "./select-game-backup-path";
import "./upload-save-game";

View File

@@ -1,6 +0,0 @@
import "./add-download-source";
import "./get-download-sources-check-baseline";
import "./get-download-sources-since-value";
import "./get-download-sources";
import "./remove-download-source";
import "./sync-download-sources";

View File

@@ -1,2 +0,0 @@
import "./check-folder-write-permission";
import "./get-disk-free-space";

View File

@@ -1,22 +1,107 @@
import { appVersion, defaultDownloadsPath, isStaging } from "@main/constants"; import { appVersion, defaultDownloadsPath, isStaging } from "@main/constants";
import { ipcMain } from "electron"; import { ipcMain } from "electron";
import "./auth"; import "./catalogue/get-game-shop-details";
import "./autoupdater"; import "./catalogue/get-random-game";
import "./catalogue"; import "./catalogue/get-game-stats";
import "./cloud-save"; import "./hardware/get-disk-free-space";
import "./download-sources"; import "./hardware/check-folder-write-permission";
import "./hardware"; import "./library/add-game-to-library";
import "./library"; import "./library/add-custom-game-to-library";
import "./leveldb"; import "./library/update-custom-game";
import "./misc"; import "./library/update-game-custom-assets";
import "./notifications"; import "./library/add-game-to-favorites";
import "./profile"; import "./library/remove-game-from-favorites";
import "./themes"; import "./library/toggle-game-pin";
import "./torrenting"; import "./library/create-game-shortcut";
import "./user"; import "./library/close-game";
import "./user-preferences"; import "./library/delete-game-folder";
import "./library/get-game-by-object-id";
import "./library/get-library";
import "./library/refresh-library-assets";
import "./library/extract-game-download";
import "./library/clear-new-download-options";
import "./library/open-game";
import "./library/open-game-executable-path";
import "./library/open-game-installer";
import "./library/open-game-installer-path";
import "./library/update-executable-path";
import "./library/update-launch-options";
import "./library/verify-executable-path";
import "./library/remove-game";
import "./library/remove-game-from-library";
import "./library/select-game-wine-prefix";
import "./library/reset-game-achievements";
import "./library/change-game-playtime";
import "./library/toggle-automatic-cloud-sync";
import "./library/get-default-wine-prefix-selection-path";
import "./library/cleanup-unused-assets";
import "./library/create-steam-shortcut";
import "./library/copy-custom-game-asset";
import "./misc/open-checkout";
import "./misc/open-external";
import "./misc/show-open-dialog";
import "./misc/show-item-in-folder";
import "./misc/install-common-redist";
import "./misc/can-install-common-redist";
import "./misc/save-temp-file";
import "./misc/delete-temp-file";
import "./misc/install-hydra-decky-plugin";
import "./misc/get-hydra-decky-plugin-info";
import "./misc/check-homebrew-folder-exists";
import "./misc/hydra-api-call";
import "./torrenting/cancel-game-download";
import "./torrenting/pause-game-download";
import "./torrenting/resume-game-download";
import "./torrenting/start-game-download";
import "./torrenting/pause-game-seed";
import "./torrenting/resume-game-seed";
import "./torrenting/check-debrid-availability";
import "./user-preferences/get-user-preferences";
import "./user-preferences/update-user-preferences";
import "./user-preferences/auto-launch";
import "./autoupdater/check-for-updates";
import "./autoupdater/restart-and-install-update";
import "./user-preferences/authenticate-real-debrid";
import "./user-preferences/authenticate-torbox";
import "./download-sources/add-download-source";
import "./download-sources/sync-download-sources";
import "./download-sources/get-download-sources-check-baseline";
import "./download-sources/get-download-sources-since-value";
import "./auth/sign-out";
import "./auth/open-auth-window";
import "./auth/get-session-hash";
import "./user/get-auth";
import "./user/get-unlocked-achievements";
import "./user/get-compared-unlocked-achievements";
import "./profile/get-me";
import "./profile/update-profile";
import "./profile/process-profile-image";
import "./profile/sync-friend-requests";
import "./cloud-save/download-game-artifact";
import "./cloud-save/get-game-backup-preview";
import "./cloud-save/upload-save-game";
import "./cloud-save/select-game-backup-path";
import "./notifications/publish-new-repacks-notification";
import "./notifications/update-achievement-notification-window";
import "./notifications/show-achievement-test-notification";
import "./themes/add-custom-theme";
import "./themes/delete-custom-theme";
import "./themes/get-all-custom-themes";
import "./themes/delete-all-custom-themes";
import "./themes/update-custom-theme";
import "./themes/open-editor-window";
import "./themes/get-custom-theme-by-id";
import "./themes/get-active-custom-theme";
import "./themes/close-editor-window";
import "./themes/toggle-custom-theme";
import "./themes/copy-theme-achievement-sound";
import "./themes/remove-theme-achievement-sound";
import "./themes/get-theme-sound-path";
import "./themes/get-theme-sound-data-url";
import "./themes/import-theme-sound-from-store";
import "./download-sources/remove-download-source";
import "./download-sources/get-download-sources";
import { isPortableVersion } from "@main/helpers"; import { isPortableVersion } from "@main/helpers";
ipcMain.handle("ping", () => "pong"); ipcMain.handle("ping", () => "pong");

View File

@@ -1,27 +0,0 @@
import { db } from "@main/level";
const sublevelCache = new Map<
string,
ReturnType<typeof db.sublevel<string, unknown>>
>();
/**
* Gets a sublevel by name, creating it if it doesn't exist.
* All sublevels use "json" encoding by default.
* @param sublevelName - The name of the sublevel to get or create
* @returns The sublevel instance
*/
export const getSublevelByName = (
sublevelName: string
): ReturnType<typeof db.sublevel<string, unknown>> => {
if (sublevelCache.has(sublevelName)) {
return sublevelCache.get(sublevelName)!;
}
// All sublevels use "json" encoding - this cannot be changed per sublevel
const sublevel = db.sublevel<string, unknown>(sublevelName, {
valueEncoding: "json",
});
sublevelCache.set(sublevelName, sublevel);
return sublevel;
};

View File

@@ -1,6 +0,0 @@
import "./leveldb-get";
import "./leveldb-put";
import "./leveldb-del";
import "./leveldb-clear";
import "./leveldb-values";
import "./leveldb-iterator";

View File

@@ -1,18 +0,0 @@
import { registerEvent } from "../register-event";
import { getSublevelByName } from "./helpers";
import { logger } from "@main/services";
const leveldbClear = async (
_event: Electron.IpcMainInvokeEvent,
sublevelName: string
) => {
try {
const sublevel = getSublevelByName(sublevelName);
await sublevel.clear();
} catch (error) {
logger.error("Error in leveldbClear", error);
throw error;
}
};
registerEvent("leveldbClear", leveldbClear);

View File

@@ -1,28 +0,0 @@
import { registerEvent } from "../register-event";
import { db } from "@main/level";
import { getSublevelByName } from "./helpers";
import { logger } from "@main/services";
const leveldbDel = async (
_event: Electron.IpcMainInvokeEvent,
key: string,
sublevelName?: string | null
) => {
try {
if (sublevelName) {
const sublevel = getSublevelByName(sublevelName);
await sublevel.del(key);
} else {
await db.del(key);
}
} catch (error) {
if (error instanceof Error && error.name === "NotFoundError") {
// NotFoundError on delete is not an error, just return
return;
}
logger.error("Error in leveldbDel", error);
throw error;
}
};
registerEvent("leveldbDel", leveldbDel);

View File

@@ -1,28 +0,0 @@
import { registerEvent } from "../register-event";
import { db } from "@main/level";
import { getSublevelByName } from "./helpers";
import { logger } from "@main/services";
const leveldbGet = async (
_event: Electron.IpcMainInvokeEvent,
key: string,
sublevelName?: string | null,
valueEncoding: "json" | "utf8" = "json"
) => {
try {
if (sublevelName) {
// Note: sublevels always use "json" encoding, valueEncoding parameter is ignored
const sublevel = getSublevelByName(sublevelName);
return sublevel.get(key);
}
return db.get<string, unknown>(key, { valueEncoding });
} catch (error) {
if (error instanceof Error && error.name === "NotFoundError") {
return null;
}
logger.error("Error in leveldbGet", error);
throw error;
}
};
registerEvent("leveldbGet", leveldbGet);

View File

@@ -1,18 +0,0 @@
import { registerEvent } from "../register-event";
import { getSublevelByName } from "./helpers";
import { logger } from "@main/services";
const leveldbIterator = async (
_event: Electron.IpcMainInvokeEvent,
sublevelName: string
) => {
try {
const sublevel = getSublevelByName(sublevelName);
return sublevel.iterator().all();
} catch (error) {
logger.error("Error in leveldbIterator", error);
throw error;
}
};
registerEvent("leveldbIterator", leveldbIterator);

View File

@@ -1,27 +0,0 @@
import { registerEvent } from "../register-event";
import { db } from "@main/level";
import { getSublevelByName } from "./helpers";
import { logger } from "@main/services";
const leveldbPut = async (
_event: Electron.IpcMainInvokeEvent,
key: string,
value: unknown,
sublevelName?: string | null,
valueEncoding: "json" | "utf8" = "json"
) => {
try {
if (sublevelName) {
// Note: sublevels always use "json" encoding, valueEncoding parameter is ignored
const sublevel = getSublevelByName(sublevelName);
await sublevel.put(key, value);
} else {
await db.put<string, unknown>(key, value, { valueEncoding });
}
} catch (error) {
logger.error("Error in leveldbPut", error);
throw error;
}
};
registerEvent("leveldbPut", leveldbPut);

View File

@@ -1,18 +0,0 @@
import { registerEvent } from "../register-event";
import { getSublevelByName } from "./helpers";
import { logger } from "@main/services";
const leveldbValues = async (
_event: Electron.IpcMainInvokeEvent,
sublevelName: string
) => {
try {
const sublevel = getSublevelByName(sublevelName);
return sublevel.values().all();
} catch (error) {
logger.error("Error in leveldbValues", error);
throw error;
}
};
registerEvent("leveldbValues", leveldbValues);

View File

@@ -1,23 +0,0 @@
import fs from "node:fs";
import { registerEvent } from "../register-event";
import { logger } from "@main/services";
const deleteArchive = async (
_event: Electron.IpcMainInvokeEvent,
filePath: string
) => {
try {
if (fs.existsSync(filePath)) {
await fs.promises.unlink(filePath);
logger.info(`Deleted archive: ${filePath}`);
return true;
}
return true;
} catch (err) {
logger.error(`Failed to delete archive: ${filePath}`, err);
return false;
}
};
registerEvent("deleteArchive", deleteArchive);

View File

@@ -22,7 +22,6 @@ const extractGameDownload = async (
await downloadsSublevel.put(gameKey, { await downloadsSublevel.put(gameKey, {
...download, ...download,
extracting: true, extracting: true,
extractionProgress: 0,
}); });
const gameFilesManager = new GameFilesManager(shop, objectId); const gameFilesManager = new GameFilesManager(shop, objectId);

View File

@@ -1,33 +0,0 @@
import "./add-custom-game-to-library";
import "./add-game-to-favorites";
import "./add-game-to-library";
import "./change-game-playtime";
import "./cleanup-unused-assets";
import "./clear-new-download-options";
import "./close-game";
import "./copy-custom-game-asset";
import "./create-game-shortcut";
import "./create-steam-shortcut";
import "./delete-archive";
import "./delete-game-folder";
import "./extract-game-download";
import "./get-default-wine-prefix-selection-path";
import "./get-game-by-object-id";
import "./get-library";
import "./open-game-executable-path";
import "./open-game-installer-path";
import "./open-game-installer";
import "./open-game";
import "./refresh-library-assets";
import "./remove-game-from-favorites";
import "./remove-game-from-library";
import "./remove-game";
import "./reset-game-achievements";
import "./select-game-wine-prefix";
import "./toggle-automatic-cloud-sync";
import "./toggle-game-pin";
import "./update-custom-game";
import "./update-executable-path";
import "./update-game-custom-assets";
import "./update-launch-options";
import "./verify-executable-path";

View File

@@ -1,12 +0,0 @@
import "./can-install-common-redist";
import "./check-homebrew-folder-exists";
import "./delete-temp-file";
import "./get-hydra-decky-plugin-info";
import "./hydra-api-call";
import "./install-common-redist";
import "./install-hydra-decky-plugin";
import "./open-checkout";
import "./open-external";
import "./save-temp-file";
import "./show-item-in-folder";
import "./show-open-dialog";

View File

@@ -1,8 +0,0 @@
import { registerEvent } from "../register-event";
import { LocalNotificationManager } from "@main/services";
const clearAllLocalNotifications = async () => {
await LocalNotificationManager.clearAll();
};
registerEvent("clearAllLocalNotifications", clearAllLocalNotifications);

View File

@@ -1,11 +0,0 @@
import { registerEvent } from "../register-event";
import { LocalNotificationManager } from "@main/services";
const deleteLocalNotification = async (
_event: Electron.IpcMainInvokeEvent,
id: string
) => {
await LocalNotificationManager.deleteNotification(id);
};
registerEvent("deleteLocalNotification", deleteLocalNotification);

View File

@@ -1,8 +0,0 @@
import { registerEvent } from "../register-event";
import { LocalNotificationManager } from "@main/services";
const getLocalNotificationsCount = async () => {
return LocalNotificationManager.getUnreadCount();
};
registerEvent("getLocalNotificationsCount", getLocalNotificationsCount);

View File

@@ -1,8 +0,0 @@
import { registerEvent } from "../register-event";
import { LocalNotificationManager } from "@main/services";
const getLocalNotifications = async () => {
return LocalNotificationManager.getNotifications();
};
registerEvent("getLocalNotifications", getLocalNotifications);

View File

@@ -1,9 +0,0 @@
import "./publish-new-repacks-notification";
import "./show-achievement-test-notification";
import "./update-achievement-notification-window";
import "./get-local-notifications";
import "./get-local-notifications-count";
import "./mark-local-notification-read";
import "./mark-all-local-notifications-read";
import "./delete-local-notification";
import "./clear-all-local-notifications";

View File

@@ -1,8 +0,0 @@
import { registerEvent } from "../register-event";
import { LocalNotificationManager } from "@main/services";
const markAllLocalNotificationsRead = async () => {
await LocalNotificationManager.markAllAsRead();
};
registerEvent("markAllLocalNotificationsRead", markAllLocalNotificationsRead);

View File

@@ -1,11 +0,0 @@
import { registerEvent } from "../register-event";
import { LocalNotificationManager } from "@main/services";
const markLocalNotificationRead = async (
_event: Electron.IpcMainInvokeEvent,
id: string
) => {
await LocalNotificationManager.markAsRead(id);
};
registerEvent("markLocalNotificationRead", markLocalNotificationRead);

View File

@@ -1,3 +0,0 @@
import "./get-me";
import "./process-profile-image";
import "./update-profile";

View File

@@ -0,0 +1,24 @@
import { registerEvent } from "../register-event";
import { HydraApi, WindowManager } from "@main/services";
import { UserNotLoggedInError } from "@shared";
import type { FriendRequestSync } from "@types";
export const syncFriendRequests = async () => {
return HydraApi.get<FriendRequestSync>(`/profile/friend-requests/sync`)
.then((res) => {
WindowManager.mainWindow?.webContents.send(
"on-sync-friend-requests",
res
);
return res;
})
.catch((err) => {
if (err instanceof UserNotLoggedInError) {
return { friendRequestCount: 0 } as FriendRequestSync;
}
throw err;
});
};
registerEvent("syncFriendRequests", syncFriendRequests);

View File

@@ -1,15 +0,0 @@
import "./add-custom-theme";
import "./close-editor-window";
import "./copy-theme-achievement-sound";
import "./delete-all-custom-themes";
import "./delete-custom-theme";
import "./get-active-custom-theme";
import "./get-all-custom-themes";
import "./get-custom-theme-by-id";
import "./get-theme-sound-data-url";
import "./get-theme-sound-path";
import "./import-theme-sound-from-store";
import "./open-editor-window";
import "./remove-theme-achievement-sound";
import "./toggle-custom-theme";
import "./update-custom-theme";

View File

@@ -1,7 +0,0 @@
import "./cancel-game-download";
import "./check-debrid-availability";
import "./pause-game-download";
import "./pause-game-seed";
import "./resume-game-download";
import "./resume-game-seed";
import "./start-game-download";

View File

@@ -13,11 +13,7 @@ const resumeGameDownload = async (
const download = await downloadsSublevel.get(gameKey); const download = await downloadsSublevel.get(gameKey);
if ( if (download?.status === "paused") {
download &&
(download.status === "paused" || download.status === "active") &&
download.progress !== 1
) {
await DownloadManager.pauseDownload(); await DownloadManager.pauseDownload();
for await (const [key, value] of downloadsSublevel.iterator()) { for await (const [key, value] of downloadsSublevel.iterator()) {

View File

@@ -41,6 +41,7 @@ const startGameDownload = async (
const game = await gamesSublevel.get(gameKey); const game = await gamesSublevel.get(gameKey);
const gameAssets = await gamesShopAssetsSublevel.get(gameKey); const gameAssets = await gamesShopAssetsSublevel.get(gameKey);
/* Delete any previous download */
await downloadsSublevel.del(gameKey); await downloadsSublevel.del(gameKey);
if (game) { if (game) {
@@ -81,7 +82,6 @@ const startGameDownload = async (
queued: true, queued: true,
extracting: false, extracting: false,
automaticallyExtract, automaticallyExtract,
extractionProgress: 0,
}; };
try { try {
@@ -123,42 +123,6 @@ const startGameDownload = async (
} }
if (err instanceof Error) { if (err instanceof Error) {
if (downloader === Downloader.Buzzheavier) {
if (err.message.includes("Rate limit")) {
return {
ok: false,
error: "Buzzheavier: Rate limit exceeded",
};
}
if (
err.message.includes("not found") ||
err.message.includes("deleted")
) {
return {
ok: false,
error: "Buzzheavier: File not found",
};
}
}
if (downloader === Downloader.FuckingFast) {
if (err.message.includes("Rate limit")) {
return {
ok: false,
error: "FuckingFast: Rate limit exceeded",
};
}
if (
err.message.includes("not found") ||
err.message.includes("deleted")
) {
return {
ok: false,
error: "FuckingFast: File not found",
};
}
}
return { ok: false, error: err.message }; return { ok: false, error: err.message };
} }

View File

@@ -1,5 +0,0 @@
import "./authenticate-real-debrid";
import "./authenticate-torbox";
import "./auto-launch";
import "./get-user-preferences";
import "./update-user-preferences";

View File

@@ -1,3 +0,0 @@
import "./get-auth";
import "./get-compared-unlocked-achievements";
import "./get-unlocked-achievements";

View File

@@ -1,4 +1,4 @@
// @generated by protobuf-ts 2.11.1 // @generated by protobuf-ts 2.10.0
// @generated from protobuf file "envelope.proto" (syntax proto3) // @generated from protobuf file "envelope.proto" (syntax proto3)
// tslint:disable // tslint:disable
import type { BinaryWriteOptions } from "@protobuf-ts/runtime"; import type { BinaryWriteOptions } from "@protobuf-ts/runtime";
@@ -15,11 +15,11 @@ import { MessageType } from "@protobuf-ts/runtime";
*/ */
export interface FriendRequest { export interface FriendRequest {
/** /**
* @generated from protobuf field: int32 friend_request_count = 1 * @generated from protobuf field: int32 friend_request_count = 1;
*/ */
friendRequestCount: number; friendRequestCount: number;
/** /**
* @generated from protobuf field: optional string sender_id = 2 * @generated from protobuf field: optional string sender_id = 2;
*/ */
senderId?: string; senderId?: string;
} }
@@ -28,27 +28,18 @@ export interface FriendRequest {
*/ */
export interface FriendGameSession { export interface FriendGameSession {
/** /**
* @generated from protobuf field: string object_id = 1 * @generated from protobuf field: string object_id = 1;
*/ */
objectId: string; objectId: string;
/** /**
* @generated from protobuf field: string shop = 2 * @generated from protobuf field: string shop = 2;
*/ */
shop: string; shop: string;
/** /**
* @generated from protobuf field: string friend_id = 3 * @generated from protobuf field: string friend_id = 3;
*/ */
friendId: string; friendId: string;
} }
/**
* @generated from protobuf message Notification
*/
export interface Notification {
/**
* @generated from protobuf field: int32 notification_count = 1
*/
notificationCount: number;
}
/** /**
* @generated from protobuf message Envelope * @generated from protobuf message Envelope
*/ */
@@ -60,24 +51,17 @@ export interface Envelope {
| { | {
oneofKind: "friendRequest"; oneofKind: "friendRequest";
/** /**
* @generated from protobuf field: FriendRequest friend_request = 1 * @generated from protobuf field: FriendRequest friend_request = 1;
*/ */
friendRequest: FriendRequest; friendRequest: FriendRequest;
} }
| { | {
oneofKind: "friendGameSession"; oneofKind: "friendGameSession";
/** /**
* @generated from protobuf field: FriendGameSession friend_game_session = 2 * @generated from protobuf field: FriendGameSession friend_game_session = 2;
*/ */
friendGameSession: FriendGameSession; friendGameSession: FriendGameSession;
} }
| {
oneofKind: "notification";
/**
* @generated from protobuf field: Notification notification = 3
*/
notification: Notification;
}
| { | {
oneofKind: undefined; oneofKind: undefined;
}; };
@@ -255,80 +239,6 @@ class FriendGameSession$Type extends MessageType<FriendGameSession> {
*/ */
export const FriendGameSession = new FriendGameSession$Type(); export const FriendGameSession = new FriendGameSession$Type();
// @generated message type with reflection information, may provide speed optimized methods // @generated message type with reflection information, may provide speed optimized methods
class Notification$Type extends MessageType<Notification> {
constructor() {
super("Notification", [
{
no: 1,
name: "notification_count",
kind: "scalar",
T: 5 /*ScalarType.INT32*/,
},
]);
}
create(value?: PartialMessage<Notification>): Notification {
const message = globalThis.Object.create(this.messagePrototype!);
message.notificationCount = 0;
if (value !== undefined)
reflectionMergePartial<Notification>(this, message, value);
return message;
}
internalBinaryRead(
reader: IBinaryReader,
length: number,
options: BinaryReadOptions,
target?: Notification
): Notification {
let message = target ?? this.create(),
end = reader.pos + length;
while (reader.pos < end) {
let [fieldNo, wireType] = reader.tag();
switch (fieldNo) {
case /* int32 notification_count */ 1:
message.notificationCount = reader.int32();
break;
default:
let u = options.readUnknownField;
if (u === "throw")
throw new globalThis.Error(
`Unknown field ${fieldNo} (wire type ${wireType}) for ${this.typeName}`
);
let d = reader.skip(wireType);
if (u !== false)
(u === true ? UnknownFieldHandler.onRead : u)(
this.typeName,
message,
fieldNo,
wireType,
d
);
}
}
return message;
}
internalBinaryWrite(
message: Notification,
writer: IBinaryWriter,
options: BinaryWriteOptions
): IBinaryWriter {
/* int32 notification_count = 1; */
if (message.notificationCount !== 0)
writer.tag(1, WireType.Varint).int32(message.notificationCount);
let u = options.writeUnknownFields;
if (u !== false)
(u == true ? UnknownFieldHandler.onWrite : u)(
this.typeName,
message,
writer
);
return writer;
}
}
/**
* @generated MessageType for protobuf message Notification
*/
export const Notification = new Notification$Type();
// @generated message type with reflection information, may provide speed optimized methods
class Envelope$Type extends MessageType<Envelope> { class Envelope$Type extends MessageType<Envelope> {
constructor() { constructor() {
super("Envelope", [ super("Envelope", [
@@ -346,13 +256,6 @@ class Envelope$Type extends MessageType<Envelope> {
oneof: "payload", oneof: "payload",
T: () => FriendGameSession, T: () => FriendGameSession,
}, },
{
no: 3,
name: "notification",
kind: "message",
oneof: "payload",
T: () => Notification,
},
]); ]);
} }
create(value?: PartialMessage<Envelope>): Envelope { create(value?: PartialMessage<Envelope>): Envelope {
@@ -395,17 +298,6 @@ class Envelope$Type extends MessageType<Envelope> {
), ),
}; };
break; break;
case /* Notification notification */ 3:
message.payload = {
oneofKind: "notification",
notification: Notification.internalBinaryRead(
reader,
reader.uint32(),
options,
(message.payload as any).notification
),
};
break;
default: default:
let u = options.readUnknownField; let u = options.readUnknownField;
if (u === "throw") if (u === "throw")
@@ -444,13 +336,6 @@ class Envelope$Type extends MessageType<Envelope> {
writer.tag(2, WireType.LengthDelimited).fork(), writer.tag(2, WireType.LengthDelimited).fork(),
options options
).join(); ).join();
/* Notification notification = 3; */
if (message.payload.oneofKind === "notification")
Notification.internalBinaryWrite(
message.payload.notification,
writer.tag(3, WireType.LengthDelimited).fork(),
options
).join();
let u = options.writeUnknownFields; let u = options.writeUnknownFields;
if (u !== false) if (u !== false)
(u == true ? UnknownFieldHandler.onWrite : u)( (u == true ? UnknownFieldHandler.onWrite : u)(

View File

@@ -7,9 +7,7 @@ export const getDownloadSourcesCheckBaseline = async (): Promise<
string | null string | null
> => { > => {
try { try {
const timestamp = await db.get(levelKeys.downloadSourcesCheckBaseline, { const timestamp = await db.get(levelKeys.downloadSourcesCheckBaseline);
valueEncoding: "utf8",
});
return timestamp; return timestamp;
} catch (error) { } catch (error) {
if (error instanceof Error && error.name === "NotFoundError") { if (error instanceof Error && error.name === "NotFoundError") {
@@ -29,9 +27,7 @@ export const updateDownloadSourcesCheckBaseline = async (
timestamp: string timestamp: string
): Promise<void> => { ): Promise<void> => {
const utcTimestamp = new Date(timestamp).toISOString(); const utcTimestamp = new Date(timestamp).toISOString();
await db.put(levelKeys.downloadSourcesCheckBaseline, utcTimestamp, { await db.put(levelKeys.downloadSourcesCheckBaseline, utcTimestamp);
valueEncoding: "utf8",
});
}; };
// Gets the 'since' value the API used in the last check (for modal comparison) // Gets the 'since' value the API used in the last check (for modal comparison)
@@ -39,9 +35,7 @@ export const getDownloadSourcesSinceValue = async (): Promise<
string | null string | null
> => { > => {
try { try {
const timestamp = await db.get(levelKeys.downloadSourcesSinceValue, { const timestamp = await db.get(levelKeys.downloadSourcesSinceValue);
valueEncoding: "utf8",
});
return timestamp; return timestamp;
} catch (error) { } catch (error) {
if (error instanceof Error && error.name === "NotFoundError") { if (error instanceof Error && error.name === "NotFoundError") {
@@ -61,7 +55,5 @@ export const updateDownloadSourcesSinceValue = async (
timestamp: string timestamp: string
): Promise<void> => { ): Promise<void> => {
const utcTimestamp = new Date(timestamp).toISOString(); const utcTimestamp = new Date(timestamp).toISOString();
await db.put(levelKeys.downloadSourcesSinceValue, utcTimestamp, { await db.put(levelKeys.downloadSourcesSinceValue, utcTimestamp);
valueEncoding: "utf8",
});
}; };

View File

@@ -8,4 +8,3 @@ export * from "./keys";
export * from "./themes"; export * from "./themes";
export * from "./download-sources"; export * from "./download-sources";
export * from "./downloadSourcesCheckTimestamp"; export * from "./downloadSourcesCheckTimestamp";
export * from "./local-notifications";

View File

@@ -20,5 +20,4 @@ export const levelKeys = {
downloadSources: "downloadSources", downloadSources: "downloadSources",
downloadSourcesCheckBaseline: "downloadSourcesCheckBaseline", // When we last started the app downloadSourcesCheckBaseline: "downloadSourcesCheckBaseline", // When we last started the app
downloadSourcesSinceValue: "downloadSourcesSinceValue", // The 'since' value API used (for modal comparison) downloadSourcesSinceValue: "downloadSourcesSinceValue", // The 'since' value API used (for modal comparison)
localNotifications: "localNotifications",
}; };

View File

@@ -1,11 +0,0 @@
import type { LocalNotification } from "@types";
import { db } from "../level";
import { levelKeys } from "./keys";
export const localNotificationsSublevel = db.sublevel<
string,
LocalNotification
>(levelKeys.localNotifications, {
valueEncoding: "json",
});

View File

@@ -1,5 +1,5 @@
import { downloadsSublevel } from "./level/sublevels/downloads"; import { downloadsSublevel } from "./level/sublevels/downloads";
import { orderBy } from "lodash-es"; import { sortBy } from "lodash-es";
import { Downloader } from "@shared"; import { Downloader } from "@shared";
import { levelKeys, db } from "./level"; import { levelKeys, db } from "./level";
import type { UserPreferences } from "@types"; import type { UserPreferences } from "@types";
@@ -33,7 +33,9 @@ export const loadState = async () => {
await import("./events"); await import("./events");
Aria2.spawn(); if (process.platform !== "darwin") {
Aria2.spawn();
}
if (userPreferences?.realDebridApiToken) { if (userPreferences?.realDebridApiToken) {
RealDebridClient.authorize(userPreferences.realDebridApiToken); RealDebridClient.authorize(userPreferences.realDebridApiToken);
@@ -57,10 +59,8 @@ export const loadState = async () => {
const { syncDownloadSourcesFromApi } = await import("./services/user"); const { syncDownloadSourcesFromApi } = await import("./services/user");
void syncDownloadSourcesFromApi(); void syncDownloadSourcesFromApi();
// Check for new download options on startup (if enabled) // Check for new download options on startup
(async () => { DownloadSourcesChecker.checkForChanges();
await DownloadSourcesChecker.checkForChanges();
})();
WSClient.connect(); WSClient.connect();
}); });
@@ -68,7 +68,7 @@ export const loadState = async () => {
.values() .values()
.all() .all()
.then((games) => { .then((games) => {
return orderBy(games, "timestamp", "desc"); return sortBy(games, "timestamp", "DESC");
}); });
downloads.forEach((download) => { downloads.forEach((download) => {

View File

@@ -1,5 +1,5 @@
import { app } from "electron"; import { app } from "electron";
import Seven, { CommandLineSwitches } from "node-7z"; import cp from "node:child_process";
import path from "node:path"; import path from "node:path";
import { logger } from "./logger"; import { logger } from "./logger";
@@ -9,17 +9,6 @@ export const binaryName = {
win32: "7z.exe", win32: "7z.exe",
}; };
export interface ExtractionProgress {
percent: number;
fileCount: number;
file: string;
}
export interface ExtractionResult {
success: boolean;
extractedFiles: string[];
}
export class SevenZip { export class SevenZip {
private static readonly binaryPath = app.isPackaged private static readonly binaryPath = app.isPackaged
? path.join(process.resourcesPath, binaryName[process.platform]) ? path.join(process.resourcesPath, binaryName[process.platform])
@@ -43,109 +32,43 @@ export class SevenZip {
cwd?: string; cwd?: string;
passwords?: string[]; passwords?: string[];
}, },
onProgress?: (progress: ExtractionProgress) => void successCb: () => void,
): Promise<ExtractionResult> { errorCb: () => void
return new Promise((resolve, reject) => { ) {
const tryPassword = (index = -1) => { const tryPassword = (index = -1) => {
const password = passwords[index] ?? ""; const password = passwords[index] ?? "";
logger.info( logger.info(`Trying password ${password} on ${filePath}`);
`Trying password "${password || "(empty)"}" on ${filePath}`
);
const extractedFiles: string[] = []; const args = ["x", filePath, "-y", "-p" + password];
let fileCount = 0;
const options: CommandLineSwitches = { if (outputPath) {
$bin: this.binaryPath, args.push("-o" + outputPath);
$progress: true, }
yes: true,
password: password || undefined,
};
if (outputPath) { const child = cp.execFile(this.binaryPath, args, {
options.outputDir = outputPath; cwd,
});
child.once("exit", (code) => {
if (code === 0) {
successCb();
return;
} }
const stream = Seven.extractFull(filePath, outputPath || cwd || ".", { if (index < passwords.length - 1) {
...options,
$spawnOptions: cwd ? { cwd } : undefined,
});
stream.on("progress", (progress) => {
if (onProgress) {
onProgress({
percent: progress.percent,
fileCount: fileCount,
file: progress.fileCount?.toString() || "",
});
}
});
stream.on("data", (data) => {
if (data.file) {
extractedFiles.push(data.file);
fileCount++;
}
});
stream.on("end", () => {
logger.info( logger.info(
`Successfully extracted ${filePath} (${extractedFiles.length} files)` `Failed to extract file: ${filePath} with password: ${password}. Trying next password...`
); );
resolve({
success: true,
extractedFiles,
});
});
stream.on("error", (err) => { tryPassword(index + 1);
logger.error(`Extraction error for ${filePath}:`, err); } else {
logger.info(`Failed to extract file: ${filePath}`);
if (index < passwords.length - 1) { errorCb();
logger.info(
`Failed to extract file: ${filePath} with password: "${password}". Trying next password...`
);
tryPassword(index + 1);
} else {
logger.error(
`Failed to extract file: ${filePath} after trying all passwords`
);
reject(new Error(`Failed to extract file: ${filePath}`));
}
});
};
tryPassword();
});
}
public static listFiles(
filePath: string,
password?: string
): Promise<string[]> {
return new Promise((resolve, reject) => {
const files: string[] = [];
const options: CommandLineSwitches = {
$bin: this.binaryPath,
password: password || undefined,
};
const stream = Seven.list(filePath, options);
stream.on("data", (data) => {
if (data.file) {
files.push(data.file);
} }
}); });
};
stream.on("end", () => { tryPassword();
resolve(files);
});
stream.on("error", (err) => {
reject(err);
});
});
} }
} }

View File

@@ -7,12 +7,9 @@ export class Aria2 {
private static process: cp.ChildProcess | null = null; private static process: cp.ChildProcess | null = null;
public static spawn() { public static spawn() {
const binaryPath = const binaryPath = app.isPackaged
process.platform === "darwin" ? path.join(process.resourcesPath, "aria2c")
? "aria2c" : path.join(__dirname, "..", "..", "binaries", "aria2c");
: app.isPackaged
? path.join(process.resourcesPath, "aria2c")
: path.join(__dirname, "..", "..", "binaries", "aria2c");
this.process = cp.spawn( this.process = cp.spawn(
binaryPath, binaryPath,

View File

@@ -74,16 +74,21 @@ export class DeckyPlugin {
await fs.promises.mkdir(extractPath, { recursive: true }); await fs.promises.mkdir(extractPath, { recursive: true });
try { return new Promise((resolve, reject) => {
await SevenZip.extractFile({ SevenZip.extractFile(
filePath: zipPath, {
outputPath: extractPath, filePath: zipPath,
}); outputPath: extractPath,
logger.log(`Plugin extracted to: ${extractPath}`); },
return extractPath; () => {
} catch { logger.log(`Plugin extracted to: ${extractPath}`);
throw new Error("Failed to extract plugin"); resolve(extractPath);
} },
() => {
reject(new Error("Failed to extract plugin"));
}
);
});
} }
private static needsSudo(): boolean { private static needsSudo(): boolean {

View File

@@ -5,12 +5,10 @@ import {
updateDownloadSourcesCheckBaseline, updateDownloadSourcesCheckBaseline,
updateDownloadSourcesSinceValue, updateDownloadSourcesSinceValue,
downloadSourcesSublevel, downloadSourcesSublevel,
db,
levelKeys,
} from "@main/level"; } from "@main/level";
import { logger } from "./logger"; import { logger } from "./logger";
import { WindowManager } from "./window-manager"; import { WindowManager } from "./window-manager";
import type { Game, UserPreferences } from "@types"; import type { Game } from "@types";
interface DownloadSourcesChangeResponse { interface DownloadSourcesChangeResponse {
shop: string; shop: string;
@@ -103,20 +101,6 @@ export class DownloadSourcesChecker {
logger.info("DownloadSourcesChecker.checkForChanges() called"); logger.info("DownloadSourcesChecker.checkForChanges() called");
try { try {
const userPreferences = await db.get<string, UserPreferences | null>(
levelKeys.userPreferences,
{
valueEncoding: "json",
}
);
if (userPreferences?.enableNewDownloadOptionsBadges === false) {
logger.info(
"New download options badges are disabled, skipping download sources check"
);
return;
}
// Get all installed games (excluding custom games) // Get all installed games (excluding custom games)
const installedGames = await gamesSublevel.values().all(); const installedGames = await gamesSublevel.values().all();
const nonCustomGames = installedGames.filter( const nonCustomGames = installedGames.filter(

View File

@@ -24,80 +24,10 @@ import { sortBy } from "lodash-es";
import { TorBoxClient } from "./torbox"; import { TorBoxClient } from "./torbox";
import { GameFilesManager } from "../game-files-manager"; import { GameFilesManager } from "../game-files-manager";
import { HydraDebridClient } from "./hydra-debrid"; import { HydraDebridClient } from "./hydra-debrid";
import { BuzzheavierApi, FuckingFastApi } from "@main/services/hosters";
export class DownloadManager { export class DownloadManager {
private static downloadingGameId: string | null = null; private static downloadingGameId: string | null = null;
private static extractFilename(
url: string,
originalUrl?: string
): string | undefined {
if (originalUrl?.includes("#")) {
const hashPart = originalUrl.split("#")[1];
if (hashPart && !hashPart.startsWith("http") && hashPart.includes(".")) {
return hashPart;
}
}
if (url.includes("#")) {
const hashPart = url.split("#")[1];
if (hashPart && !hashPart.startsWith("http") && hashPart.includes(".")) {
return hashPart;
}
}
try {
const urlObj = new URL(url);
const pathname = urlObj.pathname;
const pathParts = pathname.split("/");
const filename = pathParts[pathParts.length - 1];
if (filename?.includes(".") && filename.length > 0) {
return decodeURIComponent(filename);
}
} catch {
// Invalid URL
}
return undefined;
}
private static sanitizeFilename(filename: string): string {
return filename.replaceAll(/[<>:"/\\|?*]/g, "_");
}
private static createDownloadPayload(
directUrl: string,
originalUrl: string,
downloadId: string,
savePath: string
) {
const filename =
this.extractFilename(originalUrl, directUrl) ||
this.extractFilename(directUrl);
const sanitizedFilename = filename
? this.sanitizeFilename(filename)
: undefined;
if (sanitizedFilename) {
logger.log(`[DownloadManager] Using filename: ${sanitizedFilename}`);
} else {
logger.log(
`[DownloadManager] No filename extracted, aria2 will use default`
);
}
return {
action: "start" as const,
game_id: downloadId,
url: directUrl,
save_path: savePath,
out: sanitizedFilename,
allow_multiple_connections: true,
};
}
public static async startRPC( public static async startRPC(
download?: Download, download?: Download,
downloadsToSeed?: Download[] downloadsToSeed?: Download[]
@@ -191,14 +121,21 @@ export class DownloadManager {
const userPreferences = await db.get<string, UserPreferences | null>( const userPreferences = await db.get<string, UserPreferences | null>(
levelKeys.userPreferences, levelKeys.userPreferences,
{ valueEncoding: "json" } {
valueEncoding: "json",
}
); );
if (WindowManager.mainWindow && download) { if (WindowManager.mainWindow && download) {
WindowManager.mainWindow.setProgressBar(progress === 1 ? -1 : progress); WindowManager.mainWindow.setProgressBar(progress === 1 ? -1 : progress);
WindowManager.mainWindow.webContents.send( WindowManager.mainWindow.webContents.send(
"on-download-progress", "on-download-progress",
JSON.parse(JSON.stringify({ ...status, game })) JSON.parse(
JSON.stringify({
...status,
game,
})
)
); );
} }
@@ -242,25 +179,27 @@ export class DownloadManager {
) )
) { ) {
gameFilesManager.extractDownloadedFile(); gameFilesManager.extractDownloadedFile();
} else if (download.folderName) { } else {
gameFilesManager gameFilesManager
.extractFilesInDirectory( .extractFilesInDirectory(
path.join(download.downloadPath, download.folderName) path.join(download.downloadPath, download.folderName!)
) )
.then(() => gameFilesManager.setExtractionComplete()); .then(() => {
gameFilesManager.setExtractionComplete();
});
} }
} }
const downloads = await downloadsSublevel const downloads = await downloadsSublevel
.values() .values()
.all() .all()
.then((games) => .then((games) => {
sortBy( return sortBy(
games.filter((game) => game.status === "paused" && game.queued), games.filter((game) => game.status === "paused" && game.queued),
"timestamp", "timestamp",
"DESC" "DESC"
) );
); });
const [nextItemOnQueue] = downloads; const [nextItemOnQueue] = downloads;
@@ -328,8 +267,13 @@ export class DownloadManager {
static async cancelDownload(downloadKey = this.downloadingGameId) { static async cancelDownload(downloadKey = this.downloadingGameId) {
await PythonRPC.rpc await PythonRPC.rpc
.post("/action", { action: "cancel", game_id: downloadKey }) .post("/action", {
.catch((err) => logger.error("Failed to cancel game download", err)); action: "cancel",
game_id: downloadKey,
})
.catch((err) => {
logger.error("Failed to cancel game download", err);
});
if (downloadKey === this.downloadingGameId) { if (downloadKey === this.downloadingGameId) {
WindowManager.mainWindow?.setProgressBar(-1); WindowManager.mainWindow?.setProgressBar(-1);
@@ -362,6 +306,7 @@ export class DownloadManager {
const id = download.uri.split("/").pop(); const id = download.uri.split("/").pop();
const token = await GofileApi.authorize(); const token = await GofileApi.authorize();
const downloadLink = await GofileApi.getDownloadLink(id!); const downloadLink = await GofileApi.getDownloadLink(id!);
await GofileApi.checkDownloadUrl(downloadLink); await GofileApi.checkDownloadUrl(downloadLink);
return { return {
@@ -403,50 +348,9 @@ export class DownloadManager {
save_path: download.downloadPath, save_path: download.downloadPath,
}; };
} }
case Downloader.Buzzheavier: {
logger.log(
`[DownloadManager] Processing Buzzheavier download for URI: ${download.uri}`
);
try {
const directUrl = await BuzzheavierApi.getDirectLink(download.uri);
logger.log(`[DownloadManager] Buzzheavier direct URL obtained`);
return this.createDownloadPayload(
directUrl,
download.uri,
downloadId,
download.downloadPath
);
} catch (error) {
logger.error(
`[DownloadManager] Error processing Buzzheavier download:`,
error
);
throw error;
}
}
case Downloader.FuckingFast: {
logger.log(
`[DownloadManager] Processing FuckingFast download for URI: ${download.uri}`
);
try {
const directUrl = await FuckingFastApi.getDirectLink(download.uri);
logger.log(`[DownloadManager] FuckingFast direct URL obtained`);
return this.createDownloadPayload(
directUrl,
download.uri,
downloadId,
download.downloadPath
);
} catch (error) {
logger.error(
`[DownloadManager] Error processing FuckingFast download:`,
error
);
throw error;
}
}
case Downloader.Mediafire: { case Downloader.Mediafire: {
const downloadUrl = await MediafireApi.getDownloadUrl(download.uri); const downloadUrl = await MediafireApi.getDownloadUrl(download.uri);
return { return {
action: "start", action: "start",
game_id: downloadId, game_id: downloadId,
@@ -463,6 +367,7 @@ export class DownloadManager {
}; };
case Downloader.RealDebrid: { case Downloader.RealDebrid: {
const downloadUrl = await RealDebridClient.getDownloadUrl(download.uri); const downloadUrl = await RealDebridClient.getDownloadUrl(download.uri);
if (!downloadUrl) throw new Error(DownloadError.NotCachedOnRealDebrid); if (!downloadUrl) throw new Error(DownloadError.NotCachedOnRealDebrid);
return { return {
@@ -475,6 +380,7 @@ export class DownloadManager {
} }
case Downloader.TorBox: { case Downloader.TorBox: {
const { name, url } = await TorBoxClient.getDownloadInfo(download.uri); const { name, url } = await TorBoxClient.getDownloadInfo(download.uri);
if (!url) return; if (!url) return;
return { return {
action: "start", action: "start",
@@ -489,6 +395,7 @@ export class DownloadManager {
const downloadUrl = await HydraDebridClient.getDownloadUrl( const downloadUrl = await HydraDebridClient.getDownloadUrl(
download.uri download.uri
); );
if (!downloadUrl) throw new Error(DownloadError.NotCachedOnHydra); if (!downloadUrl) throw new Error(DownloadError.NotCachedOnHydra);
return { return {

View File

@@ -3,58 +3,24 @@ import fs from "node:fs";
import type { GameShop } from "@types"; import type { GameShop } from "@types";
import { downloadsSublevel, gamesSublevel, levelKeys } from "@main/level"; import { downloadsSublevel, gamesSublevel, levelKeys } from "@main/level";
import { FILE_EXTENSIONS_TO_EXTRACT } from "@shared"; import { FILE_EXTENSIONS_TO_EXTRACT } from "@shared";
import { SevenZip, ExtractionProgress } from "./7zip"; import { SevenZip } from "./7zip";
import { WindowManager } from "./window-manager"; import { WindowManager } from "./window-manager";
import { publishExtractionCompleteNotification } from "./notifications"; import { publishExtractionCompleteNotification } from "./notifications";
import { logger } from "./logger"; import { logger } from "./logger";
const PROGRESS_THROTTLE_MS = 1000;
export class GameFilesManager { export class GameFilesManager {
private lastProgressUpdate = 0;
constructor( constructor(
private readonly shop: GameShop, private readonly shop: GameShop,
private readonly objectId: string private readonly objectId: string
) {} ) {}
private get gameKey() {
return levelKeys.game(this.shop, this.objectId);
}
private async updateExtractionProgress(progress: number, force = false) {
const now = Date.now();
if (!force && now - this.lastProgressUpdate < PROGRESS_THROTTLE_MS) {
return;
}
this.lastProgressUpdate = now;
const download = await downloadsSublevel.get(this.gameKey);
if (!download) return;
await downloadsSublevel.put(this.gameKey, {
...download,
extractionProgress: progress,
});
WindowManager.mainWindow?.webContents.send(
"on-extraction-progress",
this.shop,
this.objectId,
progress
);
}
private async clearExtractionState() { private async clearExtractionState() {
const download = await downloadsSublevel.get(this.gameKey); const gameKey = levelKeys.game(this.shop, this.objectId);
if (!download) return; const download = await downloadsSublevel.get(gameKey);
await downloadsSublevel.put(this.gameKey, { await downloadsSublevel.put(gameKey, {
...download, ...download!,
extracting: false, extracting: false,
extractionProgress: 0,
}); });
WindowManager.mainWindow?.webContents.send( WindowManager.mainWindow?.webContents.send(
@@ -64,10 +30,6 @@ export class GameFilesManager {
); );
} }
private readonly handleProgress = (progress: ExtractionProgress) => {
this.updateExtractionProgress(progress.percent / 100);
};
async extractFilesInDirectory(directoryPath: string) { async extractFilesInDirectory(directoryPath: string) {
if (!fs.existsSync(directoryPath)) return; if (!fs.existsSync(directoryPath)) return;
const files = await fs.promises.readdir(directoryPath); const files = await fs.promises.readdir(directoryPath);
@@ -80,66 +42,53 @@ export class GameFilesManager {
(file) => /part1\.rar$/i.test(file) || !/part\d+\.rar$/i.test(file) (file) => /part1\.rar$/i.test(file) || !/part\d+\.rar$/i.test(file)
); );
if (filesToExtract.length === 0) return; await Promise.all(
filesToExtract.map((file) => {
await this.updateExtractionProgress(0, true); return new Promise((resolve, reject) => {
SevenZip.extractFile(
const totalFiles = filesToExtract.length; {
let completedFiles = 0; filePath: path.join(directoryPath, file),
cwd: directoryPath,
for (const file of filesToExtract) { passwords: ["online-fix.me", "steamrip.com"],
try { },
const result = await SevenZip.extractFile( () => {
{ resolve(true);
filePath: path.join(directoryPath, file), },
cwd: directoryPath, () => {
passwords: ["online-fix.me", "steamrip.com"], reject(new Error(`Failed to extract file: ${file}`));
}, this.clearExtractionState();
(progress) => { }
const overallProgress =
(completedFiles + progress.percent / 100) / totalFiles;
this.updateExtractionProgress(overallProgress);
}
);
if (result.success) {
completedFiles++;
await this.updateExtractionProgress(
completedFiles / totalFiles,
true
); );
} });
} catch (err) { })
logger.error(`Failed to extract file: ${file}`, err); );
await this.clearExtractionState();
return; compressedFiles.forEach((file) => {
const extractionPath = path.join(directoryPath, file);
if (fs.existsSync(extractionPath)) {
fs.unlink(extractionPath, (err) => {
if (err) {
logger.error(`Failed to delete file: ${file}`, err);
this.clearExtractionState();
}
});
} }
} });
const archivePaths = compressedFiles
.map((file) => path.join(directoryPath, file))
.filter((archivePath) => fs.existsSync(archivePath));
if (archivePaths.length > 0) {
WindowManager.mainWindow?.webContents.send(
"on-archive-deletion-prompt",
archivePaths
);
}
} }
async setExtractionComplete(publishNotification = true) { async setExtractionComplete(publishNotification = true) {
const gameKey = levelKeys.game(this.shop, this.objectId);
const [download, game] = await Promise.all([ const [download, game] = await Promise.all([
downloadsSublevel.get(this.gameKey), downloadsSublevel.get(gameKey),
gamesSublevel.get(this.gameKey), gamesSublevel.get(gameKey),
]); ]);
if (!download) return; await downloadsSublevel.put(gameKey, {
...download!,
await downloadsSublevel.put(this.gameKey, {
...download,
extracting: false, extracting: false,
extractionProgress: 0,
}); });
WindowManager.mainWindow?.webContents.send( WindowManager.mainWindow?.webContents.send(
@@ -148,15 +97,17 @@ export class GameFilesManager {
this.objectId this.objectId
); );
if (publishNotification && game) { if (publishNotification) {
publishExtractionCompleteNotification(game); publishExtractionCompleteNotification(game!);
} }
} }
async extractDownloadedFile() { async extractDownloadedFile() {
const gameKey = levelKeys.game(this.shop, this.objectId);
const [download, game] = await Promise.all([ const [download, game] = await Promise.all([
downloadsSublevel.get(this.gameKey), downloadsSublevel.get(gameKey),
gamesSublevel.get(this.gameKey), gamesSublevel.get(gameKey),
]); ]);
if (!download || !game) return false; if (!download || !game) return false;
@@ -168,39 +119,39 @@ export class GameFilesManager {
path.parse(download.folderName!).name path.parse(download.folderName!).name
); );
await this.updateExtractionProgress(0, true); SevenZip.extractFile(
{
try { filePath,
const result = await SevenZip.extractFile( outputPath: extractionPath,
{ passwords: ["online-fix.me", "steamrip.com"],
filePath, },
outputPath: extractionPath, async () => {
passwords: ["online-fix.me", "steamrip.com"],
},
this.handleProgress
);
if (result.success) {
await this.extractFilesInDirectory(extractionPath); await this.extractFilesInDirectory(extractionPath);
if (fs.existsSync(extractionPath) && fs.existsSync(filePath)) { if (fs.existsSync(extractionPath) && fs.existsSync(filePath)) {
WindowManager.mainWindow?.webContents.send( fs.unlink(filePath, (err) => {
"on-archive-deletion-prompt", if (err) {
[filePath] logger.error(
); `Failed to delete file: ${download.folderName}`,
err
);
this.clearExtractionState();
}
});
} }
await downloadsSublevel.put(this.gameKey, { await downloadsSublevel.put(gameKey, {
...download, ...download!,
folderName: path.parse(download.folderName!).name, folderName: path.parse(download.folderName!).name,
}); });
await this.setExtractionComplete(); this.setExtractionComplete();
},
() => {
this.clearExtractionState();
} }
} catch (err) { );
logger.error(`Failed to extract downloaded file: ${filePath}`, err);
await this.clearExtractionState();
}
return true; return true;
} }

View File

@@ -1,86 +0,0 @@
import axios from "axios";
import {
HOSTER_USER_AGENT,
extractHosterFilename,
handleHosterError,
} from "./fuckingfast";
import { logger } from "@main/services";
export class BuzzheavierApi {
private static readonly BUZZHEAVIER_DOMAINS = [
"buzzheavier.com",
"bzzhr.co",
"fuckingfast.net",
];
private static isSupportedDomain(url: string): boolean {
const lowerUrl = url.toLowerCase();
return this.BUZZHEAVIER_DOMAINS.some((domain) => lowerUrl.includes(domain));
}
private static async getBuzzheavierDirectLink(url: string): Promise<string> {
try {
const baseUrl = url.split("#")[0];
logger.log(
`[Buzzheavier] Starting download link extraction for: ${baseUrl}`
);
await axios.get(baseUrl, {
headers: { "User-Agent": HOSTER_USER_AGENT },
timeout: 30000,
});
const downloadUrl = `${baseUrl}/download`;
logger.log(`[Buzzheavier] Making HEAD request to: ${downloadUrl}`);
const headResponse = await axios.head(downloadUrl, {
headers: {
"hx-current-url": baseUrl,
"hx-request": "true",
referer: baseUrl,
"User-Agent": HOSTER_USER_AGENT,
},
maxRedirects: 0,
validateStatus: (status) =>
status === 200 || status === 204 || status === 301 || status === 302,
timeout: 30000,
});
const hxRedirect = headResponse.headers["hx-redirect"];
logger.log(`[Buzzheavier] Received hx-redirect header: ${hxRedirect}`);
if (!hxRedirect) {
logger.error(
`[Buzzheavier] No hx-redirect header found. Status: ${headResponse.status}`
);
throw new Error(
"Could not extract download link. File may be deleted or is a directory."
);
}
const domain = new URL(baseUrl).hostname;
const directLink = hxRedirect.startsWith("/dl/")
? `https://${domain}${hxRedirect}`
: hxRedirect;
logger.log(`[Buzzheavier] Extracted direct link`);
return directLink;
} catch (error) {
logger.error(`[Buzzheavier] Error in getBuzzheavierDirectLink:`, error);
handleHosterError(error);
}
}
public static async getDirectLink(url: string): Promise<string> {
if (!this.isSupportedDomain(url)) {
throw new Error(
`Unsupported domain. Supported domains: ${this.BUZZHEAVIER_DOMAINS.join(", ")}`
);
}
return this.getBuzzheavierDirectLink(url);
}
public static async getFilename(
url: string,
directUrl?: string
): Promise<string> {
return extractHosterFilename(url, directUrl);
}
}

View File

@@ -1,129 +0,0 @@
import axios from "axios";
import { logger } from "@main/services";
export const HOSTER_USER_AGENT =
"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:144.0) Gecko/20100101 Firefox/144.0";
export async function extractHosterFilename(
url: string,
directUrl?: string
): Promise<string> {
if (url.includes("#")) {
const fragment = url.split("#")[1];
if (fragment && !fragment.startsWith("http")) {
return fragment;
}
}
if (directUrl) {
try {
const response = await axios.head(directUrl, {
timeout: 10000,
headers: { "User-Agent": HOSTER_USER_AGENT },
});
const contentDisposition = response.headers["content-disposition"];
if (contentDisposition) {
const filenameMatch = /filename[^;=\n]*=((['"]).*?\2|[^;\n]*)/.exec(
contentDisposition
);
if (filenameMatch && filenameMatch[1]) {
return filenameMatch[1].replace(/['"]/g, "");
}
}
} catch {
// Ignore errors
}
const urlPath = new URL(directUrl).pathname;
const filename = urlPath.split("/").pop()?.split("?")[0];
if (filename) {
return filename;
}
}
return "downloaded_file";
}
export function handleHosterError(error: unknown): never {
if (axios.isAxiosError(error)) {
if (error.response?.status === 404) {
throw new Error("File not found");
}
if (error.response?.status === 429) {
throw new Error("Rate limit exceeded. Please try again later.");
}
if (error.response?.status === 403) {
throw new Error("Access denied. File may be private or deleted.");
}
throw new Error(`Network error: ${error.response?.status || "Unknown"}`);
}
throw error;
}
// ============================================
// FuckingFast API Class
// ============================================
export class FuckingFastApi {
private static readonly FUCKINGFAST_DOMAINS = ["fuckingfast.co"];
private static readonly FUCKINGFAST_REGEX =
/window\.open\("(https:\/\/fuckingfast\.co\/dl\/[^"]*)"\)/;
private static isSupportedDomain(url: string): boolean {
const lowerUrl = url.toLowerCase();
return this.FUCKINGFAST_DOMAINS.some((domain) => lowerUrl.includes(domain));
}
private static async getFuckingFastDirectLink(url: string): Promise<string> {
try {
logger.log(`[FuckingFast] Starting download link extraction for: ${url}`);
const response = await axios.get(url, {
headers: { "User-Agent": HOSTER_USER_AGENT },
timeout: 30000,
});
const html = response.data;
if (html.toLowerCase().includes("rate limit")) {
logger.error(`[FuckingFast] Rate limit detected`);
throw new Error(
"Rate limit exceeded. Please wait a few minutes and try again."
);
}
if (html.includes("File Not Found Or Deleted")) {
logger.error(`[FuckingFast] File not found or deleted`);
throw new Error("File not found or deleted");
}
const match = this.FUCKINGFAST_REGEX.exec(html);
if (!match || !match[1]) {
logger.error(`[FuckingFast] Could not extract download link`);
throw new Error("Could not extract download link from page");
}
logger.log(`[FuckingFast] Successfully extracted direct link`);
return match[1];
} catch (error) {
logger.error(`[FuckingFast] Error:`, error);
handleHosterError(error);
}
}
public static async getDirectLink(url: string): Promise<string> {
if (!this.isSupportedDomain(url)) {
throw new Error(
`Unsupported domain. Supported domains: ${this.FUCKINGFAST_DOMAINS.join(", ")}`
);
}
return this.getFuckingFastDirectLink(url);
}
public static async getFilename(
url: string,
directUrl?: string
): Promise<string> {
return extractHosterFilename(url, directUrl);
}
}

View File

@@ -36,13 +36,16 @@ export class GofileApi {
} }
public static async getDownloadLink(id: string) { public static async getDownloadLink(id: string) {
const searchParams = new URLSearchParams({
wt: WT,
});
const response = await axios.get<{ const response = await axios.get<{
status: string; status: string;
data: GofileContentsResponse; data: GofileContentsResponse;
}>(`https://api.gofile.io/contents/${id}`, { }>(`https://api.gofile.io/contents/${id}?${searchParams.toString()}`, {
headers: { headers: {
Authorization: `Bearer ${this.token}`, Authorization: `Bearer ${this.token}`,
"X-Website-Token": WT,
}, },
}); });

View File

@@ -3,5 +3,3 @@ export * from "./qiwi";
export * from "./datanodes"; export * from "./datanodes";
export * from "./mediafire"; export * from "./mediafire";
export * from "./pixeldrain"; export * from "./pixeldrain";
export * from "./buzzheavier";
export * from "./fuckingfast";

View File

@@ -30,7 +30,7 @@ export class HydraApi {
private static instance: AxiosInstance; private static instance: AxiosInstance;
private static readonly EXPIRATION_OFFSET_IN_MS = 1000 * 60 * 5; // 5 minutes private static readonly EXPIRATION_OFFSET_IN_MS = 1000 * 60 * 5; // 5 minutes
private static readonly ADD_LOG_INTERCEPTOR = true; private static readonly ADD_LOG_INTERCEPTOR = false;
private static secondsToMilliseconds(seconds: number) { private static secondsToMilliseconds(seconds: number) {
return seconds * 1000; return seconds * 1000;
@@ -58,13 +58,7 @@ export class HydraApi {
const decodedBase64 = atob(payload as string); const decodedBase64 = atob(payload as string);
const jsonData = JSON.parse(decodedBase64); const jsonData = JSON.parse(decodedBase64);
const { const { accessToken, expiresIn, refreshToken } = jsonData;
accessToken,
expiresIn,
refreshToken,
featurebaseJwt,
workwondersJwt,
} = jsonData;
const now = new Date(); const now = new Date();
@@ -91,8 +85,6 @@ export class HydraApi {
accessToken, accessToken,
refreshToken, refreshToken,
tokenExpirationTimestamp, tokenExpirationTimestamp,
featurebaseJwt,
workwondersJwt,
}, },
{ valueEncoding: "json" } { valueEncoding: "json" }
); );

View File

@@ -20,4 +20,3 @@ export * from "./lock";
export * from "./decky-plugin"; export * from "./decky-plugin";
export * from "./user"; export * from "./user";
export * from "./download-sources-checker"; export * from "./download-sources-checker";
export * from "./notifications/local-notifications";

View File

@@ -1,87 +0,0 @@
declare module "node-7z" {
import { ChildProcess } from "node:child_process";
import { EventEmitter } from "node:events";
export interface CommandLineSwitches {
$bin?: string;
$progress?: boolean;
$spawnOptions?: {
cwd?: string;
};
outputDir?: string;
yes?: boolean;
password?: string;
[key: string]: unknown;
}
export interface ProgressInfo {
percent: number;
fileCount?: number;
}
export interface FileInfo {
file?: string;
[key: string]: unknown;
}
export interface ZipStream extends EventEmitter {
on(event: "progress", listener: (progress: ProgressInfo) => void): this;
on(event: "data", listener: (data: FileInfo) => void): this;
on(event: "end", listener: () => void): this;
on(event: "error", listener: (err: Error) => void): this;
info: Map<string, unknown>;
_childProcess?: ChildProcess;
}
export function extractFull(
archive: string,
output: string,
options?: CommandLineSwitches
): ZipStream;
export function extract(
archive: string,
output: string,
options?: CommandLineSwitches
): ZipStream;
export function list(
archive: string,
options?: CommandLineSwitches
): ZipStream;
export function add(
archive: string,
files: string | string[],
options?: CommandLineSwitches
): ZipStream;
export function update(
archive: string,
files: string | string[],
options?: CommandLineSwitches
): ZipStream;
export function deleteFiles(
archive: string,
files: string | string[],
options?: CommandLineSwitches
): ZipStream;
export function test(
archive: string,
options?: CommandLineSwitches
): ZipStream;
const Seven: {
extractFull: typeof extractFull;
extract: typeof extract;
list: typeof list;
add: typeof add;
update: typeof update;
delete: typeof deleteFiles;
test: typeof test;
};
export default Seven;
}

View File

@@ -16,7 +16,6 @@ import { restartAndInstallUpdate } from "@main/events/autoupdater/restart-and-in
import { SystemPath } from "../system-path"; import { SystemPath } from "../system-path";
import { getThemeSoundPath } from "@main/helpers"; import { getThemeSoundPath } from "@main/helpers";
import { processProfileImage } from "@main/events/profile/process-profile-image"; import { processProfileImage } from "@main/events/profile/process-profile-image";
import { LocalNotificationManager } from "./local-notifications";
const getStaticImage = async (path: string) => { const getStaticImage = async (path: string) => {
return processProfileImage(path, "jpg") return processProfileImage(path, "jpg")
@@ -79,59 +78,37 @@ export const publishDownloadCompleteNotification = async (game: Game) => {
} }
); );
const title = t("download_complete", { ns: "notifications" });
const body = t("game_ready_to_install", {
ns: "notifications",
title: game.title,
});
if (userPreferences?.downloadNotificationsEnabled) { if (userPreferences?.downloadNotificationsEnabled) {
new Notification({ new Notification({
title, title: t("download_complete", {
body, ns: "notifications",
}),
body: t("game_ready_to_install", {
ns: "notifications",
title: game.title,
}),
icon: await downloadImage(game.iconUrl), icon: await downloadImage(game.iconUrl),
}).show(); }).show();
} }
// Create local notification
await LocalNotificationManager.createNotification(
"DOWNLOAD_COMPLETE",
title,
body,
{
pictureUrl: game.iconUrl,
url: `/game/${game.shop}/${game.objectId}`,
}
);
}; };
export const publishNotificationUpdateReadyToInstall = async ( export const publishNotificationUpdateReadyToInstall = async (
version: string version: string
) => { ) => {
const title = t("new_update_available", {
ns: "notifications",
version,
});
const body = t("restart_to_install_update", {
ns: "notifications",
});
new Notification({ new Notification({
title, title: t("new_update_available", {
body, ns: "notifications",
version,
}),
body: t("restart_to_install_update", {
ns: "notifications",
}),
icon: trayIcon, icon: trayIcon,
}) })
.on("click", () => { .on("click", () => {
restartAndInstallUpdate(); restartAndInstallUpdate();
}) })
.show(); .show();
// Create local notification
await LocalNotificationManager.createNotification(
"UPDATE_AVAILABLE",
title,
body
);
}; };
export const publishNewFriendRequestNotification = async ( export const publishNewFriendRequestNotification = async (
@@ -204,27 +181,14 @@ export const publishCombinedNewAchievementNotification = async (
}; };
export const publishExtractionCompleteNotification = async (game: Game) => { export const publishExtractionCompleteNotification = async (game: Game) => {
const title = t("extraction_complete", { ns: "notifications" });
const body = t("game_extracted", {
ns: "notifications",
title: game.title,
});
new Notification({ new Notification({
title, title: t("extraction_complete", { ns: "notifications" }),
body, body: t("game_extracted", {
ns: "notifications",
title: game.title,
}),
icon: trayIcon, icon: trayIcon,
}).show(); }).show();
// Create local notification
await LocalNotificationManager.createNotification(
"EXTRACTION_COMPLETE",
title,
body,
{
url: `/game/${game.shop}/${game.objectId}`,
}
);
}; };
export const publishNewAchievementNotification = async (info: { export const publishNewAchievementNotification = async (info: {

View File

@@ -1,99 +0,0 @@
import { localNotificationsSublevel } from "@main/level";
import { WindowManager } from "../window-manager";
import type { LocalNotification, LocalNotificationType } from "@types";
import crypto from "node:crypto";
export class LocalNotificationManager {
private static generateId(): string {
return crypto.randomBytes(8).toString("hex");
}
static async createNotification(
type: LocalNotificationType,
title: string,
description: string,
options?: {
pictureUrl?: string | null;
url?: string | null;
}
): Promise<LocalNotification> {
const id = this.generateId();
const notification: LocalNotification = {
id,
type,
title,
description,
pictureUrl: options?.pictureUrl ?? null,
url: options?.url ?? null,
isRead: false,
createdAt: new Date().toISOString(),
};
await localNotificationsSublevel.put(id, notification);
// Notify renderer about new notification
if (WindowManager.mainWindow) {
WindowManager.mainWindow.webContents.send(
"on-local-notification-created",
notification
);
}
return notification;
}
static async getNotifications(): Promise<LocalNotification[]> {
const notifications: LocalNotification[] = [];
for await (const [, value] of localNotificationsSublevel.iterator()) {
notifications.push(value);
}
// Sort by createdAt descending
return notifications.sort(
(a, b) =>
new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
);
}
static async getUnreadCount(): Promise<number> {
let count = 0;
for await (const [, value] of localNotificationsSublevel.iterator()) {
if (!value.isRead) {
count++;
}
}
return count;
}
static async markAsRead(id: string): Promise<void> {
const notification = await localNotificationsSublevel.get(id);
if (notification) {
notification.isRead = true;
await localNotificationsSublevel.put(id, notification);
}
}
static async markAllAsRead(): Promise<void> {
const batch = localNotificationsSublevel.batch();
for await (const [key, value] of localNotificationsSublevel.iterator()) {
if (!value.isRead) {
value.isRead = true;
batch.put(key, value);
}
}
await batch.write();
}
static async deleteNotification(id: string): Promise<void> {
await localNotificationsSublevel.del(id);
}
static async clearAll(): Promise<void> {
await localNotificationsSublevel.clear();
}
}

View File

@@ -13,9 +13,9 @@ export class SystemPath {
}; };
static checkIfPathsAreAvailable() { static checkIfPathsAreAvailable() {
const paths = Object.keys( const paths = Object.keys(SystemPath.paths) as Array<
SystemPath.paths keyof typeof SystemPath.paths
) as (keyof typeof SystemPath.paths)[]; >;
paths.forEach((pathName) => { paths.forEach((pathName) => {
try { try {

View File

@@ -36,9 +36,9 @@ export class WindowManager {
private static initialConfigInitializationMainWindow: Electron.BrowserWindowConstructorOptions = private static initialConfigInitializationMainWindow: Electron.BrowserWindowConstructorOptions =
{ {
width: 1200, width: 1200,
height: 860, height: 720,
minWidth: 1024, minWidth: 1024,
minHeight: 860, minHeight: 540,
backgroundColor: "#1c1c1c", backgroundColor: "#1c1c1c",
titleBarStyle: process.platform === "linux" ? "default" : "hidden", titleBarStyle: process.platform === "linux" ? "default" : "hidden",
icon, icon,
@@ -106,7 +106,7 @@ export class WindowManager {
valueEncoding: "json", valueEncoding: "json",
} }
); );
return data ?? { isMaximized: false, height: 860, width: 1200 }; return data ?? { isMaximized: false, height: 720, width: 1200 };
} }
private static updateInitialConfig( private static updateInitialConfig(
@@ -138,8 +138,7 @@ export class WindowManager {
(details, callback) => { (details, callback) => {
if ( if (
details.webContentsId !== this.mainWindow?.webContents.id || details.webContentsId !== this.mainWindow?.webContents.id ||
details.url.includes("chatwoot") || details.url.includes("chatwoot")
details.url.includes("workwonders")
) { ) {
return callback(details); return callback(details);
} }
@@ -160,8 +159,7 @@ export class WindowManager {
if ( if (
details.webContentsId !== this.mainWindow?.webContents.id || details.webContentsId !== this.mainWindow?.webContents.id ||
details.url.includes("featurebase") || details.url.includes("featurebase") ||
details.url.includes("chatwoot") || details.url.includes("chatwoot")
details.url.includes("workwonders")
) { ) {
return callback(details); return callback(details);
} }
@@ -224,7 +222,7 @@ export class WindowManager {
? { ? {
x: undefined, x: undefined,
y: undefined, y: undefined,
height: this.initialConfigInitializationMainWindow.height ?? 860, height: this.initialConfigInitializationMainWindow.height ?? 720,
width: this.initialConfigInitializationMainWindow.width ?? 1200, width: this.initialConfigInitializationMainWindow.width ?? 1200,
isMaximized: true, isMaximized: true,
} }

View File

@@ -1,8 +0,0 @@
import type { Notification } from "@main/generated/envelope";
import { WindowManager } from "@main/services/window-manager";
export const notificationEvent = (payload: Notification) => {
WindowManager.mainWindow?.webContents.send("on-sync-notification-count", {
notificationCount: payload.notificationCount,
});
};

View File

@@ -4,7 +4,6 @@ import { Envelope } from "@main/generated/envelope";
import { logger } from "../logger"; import { logger } from "../logger";
import { friendRequestEvent } from "./events/friend-request"; import { friendRequestEvent } from "./events/friend-request";
import { friendGameSessionEvent } from "./events/friend-game-session"; import { friendGameSessionEvent } from "./events/friend-game-session";
import { notificationEvent } from "./events/notification";
export class WSClient { export class WSClient {
private static ws: WebSocket | null = null; private static ws: WebSocket | null = null;
@@ -52,10 +51,6 @@ export class WSClient {
if (envelope.payload.oneofKind === "friendGameSession") { if (envelope.payload.oneofKind === "friendGameSession") {
friendGameSessionEvent(envelope.payload.friendGameSession); friendGameSessionEvent(envelope.payload.friendGameSession);
} }
if (envelope.payload.oneofKind === "notification") {
notificationEvent(envelope.payload.notification);
}
}); });
this.ws.on("close", () => this.handleDisconnect("close")); this.ws.on("close", () => this.handleDisconnect("close"));

View File

@@ -15,7 +15,6 @@ import type {
GameAchievement, GameAchievement,
Theme, Theme,
FriendRequestSync, FriendRequestSync,
NotificationSync,
ShortcutLocation, ShortcutLocation,
AchievementCustomNotificationPosition, AchievementCustomNotificationPosition,
AchievementNotificationInfo, AchievementNotificationInfo,
@@ -268,29 +267,6 @@ contextBridge.exposeInMainWorld("electron", {
ipcRenderer.on("on-extraction-complete", listener); ipcRenderer.on("on-extraction-complete", listener);
return () => ipcRenderer.removeListener("on-extraction-complete", listener); return () => ipcRenderer.removeListener("on-extraction-complete", listener);
}, },
onExtractionProgress: (
cb: (shop: GameShop, objectId: string, progress: number) => void
) => {
const listener = (
_event: Electron.IpcRendererEvent,
shop: GameShop,
objectId: string,
progress: number
) => cb(shop, objectId, progress);
ipcRenderer.on("on-extraction-progress", listener);
return () => ipcRenderer.removeListener("on-extraction-progress", listener);
},
onArchiveDeletionPrompt: (cb: (archivePaths: string[]) => void) => {
const listener = (
_event: Electron.IpcRendererEvent,
archivePaths: string[]
) => cb(archivePaths);
ipcRenderer.on("on-archive-deletion-prompt", listener);
return () =>
ipcRenderer.removeListener("on-archive-deletion-prompt", listener);
},
deleteArchive: (filePath: string) =>
ipcRenderer.invoke("deleteArchive", filePath),
/* Hardware */ /* Hardware */
getDiskFreeSpace: (path: string) => getDiskFreeSpace: (path: string) =>
@@ -498,6 +474,7 @@ contextBridge.exposeInMainWorld("electron", {
ipcRenderer.invoke("updateProfile", updateProfile), ipcRenderer.invoke("updateProfile", updateProfile),
processProfileImage: (imagePath: string) => processProfileImage: (imagePath: string) =>
ipcRenderer.invoke("processProfileImage", imagePath), ipcRenderer.invoke("processProfileImage", imagePath),
syncFriendRequests: () => ipcRenderer.invoke("syncFriendRequests"),
onSyncFriendRequests: (cb: (friendRequests: FriendRequestSync) => void) => { onSyncFriendRequests: (cb: (friendRequests: FriendRequestSync) => void) => {
const listener = ( const listener = (
_event: Electron.IpcRendererEvent, _event: Electron.IpcRendererEvent,
@@ -507,15 +484,6 @@ contextBridge.exposeInMainWorld("electron", {
return () => return () =>
ipcRenderer.removeListener("on-sync-friend-requests", listener); ipcRenderer.removeListener("on-sync-friend-requests", listener);
}, },
onSyncNotificationCount: (cb: (notification: NotificationSync) => void) => {
const listener = (
_event: Electron.IpcRendererEvent,
notification: NotificationSync
) => cb(notification);
ipcRenderer.on("on-sync-notification-count", listener);
return () =>
ipcRenderer.removeListener("on-sync-notification-count", listener);
},
updateFriendRequest: (userId: string, action: FriendRequestAction) => updateFriendRequest: (userId: string, action: FriendRequestAction) =>
ipcRenderer.invoke("updateFriendRequest", userId, action), ipcRenderer.invoke("updateFriendRequest", userId, action),
@@ -559,26 +527,6 @@ contextBridge.exposeInMainWorld("electron", {
/* Notifications */ /* Notifications */
publishNewRepacksNotification: (newRepacksCount: number) => publishNewRepacksNotification: (newRepacksCount: number) =>
ipcRenderer.invoke("publishNewRepacksNotification", newRepacksCount), ipcRenderer.invoke("publishNewRepacksNotification", newRepacksCount),
getLocalNotifications: () => ipcRenderer.invoke("getLocalNotifications"),
getLocalNotificationsCount: () =>
ipcRenderer.invoke("getLocalNotificationsCount"),
markLocalNotificationRead: (id: string) =>
ipcRenderer.invoke("markLocalNotificationRead", id),
markAllLocalNotificationsRead: () =>
ipcRenderer.invoke("markAllLocalNotificationsRead"),
deleteLocalNotification: (id: string) =>
ipcRenderer.invoke("deleteLocalNotification", id),
clearAllLocalNotifications: () =>
ipcRenderer.invoke("clearAllLocalNotifications"),
onLocalNotificationCreated: (cb: (notification: unknown) => void) => {
const listener = (
_event: Electron.IpcRendererEvent,
notification: unknown
) => cb(notification);
ipcRenderer.on("on-local-notification-created", listener);
return () =>
ipcRenderer.removeListener("on-local-notification-created", listener);
},
onAchievementUnlocked: ( onAchievementUnlocked: (
cb: ( cb: (
position?: AchievementCustomNotificationPosition, position?: AchievementCustomNotificationPosition,
@@ -671,28 +619,4 @@ contextBridge.exposeInMainWorld("electron", {
}, },
closeEditorWindow: (themeId?: string) => closeEditorWindow: (themeId?: string) =>
ipcRenderer.invoke("closeEditorWindow", themeId), ipcRenderer.invoke("closeEditorWindow", themeId),
/* LevelDB Generic CRUD */
leveldb: {
get: (
key: string,
sublevelName?: string | null,
valueEncoding?: "json" | "utf8"
) => ipcRenderer.invoke("leveldbGet", key, sublevelName, valueEncoding),
put: (
key: string,
value: unknown,
sublevelName?: string | null,
valueEncoding?: "json" | "utf8"
) =>
ipcRenderer.invoke("leveldbPut", key, value, sublevelName, valueEncoding),
del: (key: string, sublevelName?: string | null) =>
ipcRenderer.invoke("leveldbDel", key, sublevelName),
clear: (sublevelName: string) =>
ipcRenderer.invoke("leveldbClear", sublevelName),
values: (sublevelName: string) =>
ipcRenderer.invoke("leveldbValues", sublevelName),
iterator: (sublevelName: string) =>
ipcRenderer.invoke("leveldbIterator", sublevelName),
},
}); });

View File

@@ -6,7 +6,7 @@
<title>Hydra Launcher</title> <title>Hydra Launcher</title>
<meta <meta
http-equiv="Content-Security-Policy" http-equiv="Content-Security-Policy"
content="default-src 'self' 'unsafe-inline' * data: local:; media-src 'self' 'unsafe-inline' * data: local: blob:;" content="default-src 'self' 'unsafe-inline' * data: local:;"
/> />
</head> </head>
<body> <body>

View File

@@ -1,4 +1,4 @@
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useRef } from "react";
import { Sidebar, BottomPanel, Header, Toast } from "@renderer/components"; import { Sidebar, BottomPanel, Header, Toast } from "@renderer/components";
import { import {
@@ -19,13 +19,11 @@ import {
setUserDetails, setUserDetails,
setProfileBackground, setProfileBackground,
setGameRunning, setGameRunning,
setExtractionProgress,
clearExtraction,
} from "@renderer/features"; } from "@renderer/features";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { UserFriendModal } from "./pages/shared-modals/user-friend-modal";
import { useSubscription } from "./hooks/use-subscription"; import { useSubscription } from "./hooks/use-subscription";
import { HydraCloudModal } from "./pages/shared-modals/hydra-cloud/hydra-cloud-modal"; import { HydraCloudModal } from "./pages/shared-modals/hydra-cloud/hydra-cloud-modal";
import { ArchiveDeletionModal } from "./pages/downloads/archive-deletion-error-modal";
import { import {
injectCustomCss, injectCustomCss,
@@ -33,8 +31,6 @@ import {
getAchievementSoundUrl, getAchievementSoundUrl,
getAchievementSoundVolume, getAchievementSoundVolume,
} from "./helpers"; } from "./helpers";
import { levelDBService } from "./services/leveldb.service";
import type { UserPreferences } from "@types";
import "./app.scss"; import "./app.scss";
export interface AppProps { export interface AppProps {
@@ -53,7 +49,12 @@ export function App() {
const { clearDownload, setLastPacket } = useDownload(); const { clearDownload, setLastPacket } = useDownload();
const { const {
userDetails,
hasActiveSubscription, hasActiveSubscription,
isFriendsModalVisible,
friendRequetsModalTab,
friendModalUserId,
hideFriendsModal,
fetchUserDetails, fetchUserDetails,
updateUserDetails, updateUserDetails,
clearUserDetails, clearUserDetails,
@@ -75,17 +76,12 @@ export function App() {
const { showSuccessToast } = useToast(); const { showSuccessToast } = useToast();
const [showArchiveDeletionModal, setShowArchiveDeletionModal] =
useState(false);
const [archivePaths, setArchivePaths] = useState<string[]>([]);
useEffect(() => { useEffect(() => {
Promise.all([ Promise.all([window.electron.getUserPreferences(), updateLibrary()]).then(
levelDBService.get("userPreferences", null, "json"), ([preferences]) => {
updateLibrary(), dispatch(setUserPreferences(preferences));
]).then(([preferences]) => { }
dispatch(setUserPreferences(preferences as UserPreferences | null)); );
});
}, [navigate, location.pathname, dispatch, updateLibrary]); }, [navigate, location.pathname, dispatch, updateLibrary]);
useEffect(() => { useEffect(() => {
@@ -129,6 +125,7 @@ export function App() {
.then((response) => { .then((response) => {
if (response) { if (response) {
updateUserDetails(response); updateUserDetails(response);
window.electron.syncFriendRequests();
} }
}) })
.finally(() => { .finally(() => {
@@ -145,6 +142,7 @@ export function App() {
fetchUserDetails().then((response) => { fetchUserDetails().then((response) => {
if (response) { if (response) {
updateUserDetails(response); updateUserDetails(response);
window.electron.syncFriendRequests();
showSuccessToast(t("successfully_signed_in")); showSuccessToast(t("successfully_signed_in"));
} }
}); });
@@ -183,23 +181,12 @@ export function App() {
updateLibrary(); updateLibrary();
}), }),
window.electron.onSignOut(() => clearUserDetails()), window.electron.onSignOut(() => clearUserDetails()),
window.electron.onExtractionProgress((shop, objectId, progress) => {
dispatch(setExtractionProgress({ shop, objectId, progress }));
}),
window.electron.onExtractionComplete(() => {
dispatch(clearExtraction());
updateLibrary();
}),
window.electron.onArchiveDeletionPrompt((paths) => {
setArchivePaths(paths);
setShowArchiveDeletionModal(true);
}),
]; ];
return () => { return () => {
listeners.forEach((unsubscribe) => unsubscribe()); listeners.forEach((unsubscribe) => unsubscribe());
}; };
}, [onSignIn, updateLibrary, clearUserDetails, dispatch]); }, [onSignIn, updateLibrary, clearUserDetails]);
useEffect(() => { useEffect(() => {
if (contentRef.current) contentRef.current.scrollTop = 0; if (contentRef.current) contentRef.current.scrollTop = 0;
@@ -217,11 +204,7 @@ export function App() {
}, [dispatch, draggingDisabled]); }, [dispatch, draggingDisabled]);
const loadAndApplyTheme = useCallback(async () => { const loadAndApplyTheme = useCallback(async () => {
const allThemes = (await levelDBService.values("themes")) as { const activeTheme = await window.electron.getActiveCustomTheme();
isActive?: boolean;
code?: string;
}[];
const activeTheme = allThemes.find((theme) => theme.isActive);
if (activeTheme?.code) { if (activeTheme?.code) {
injectCustomCss(activeTheme.code); injectCustomCss(activeTheme.code);
} else { } else {
@@ -291,11 +274,14 @@ export function App() {
feature={hydraCloudFeature} feature={hydraCloudFeature}
/> />
<ArchiveDeletionModal {userDetails && (
visible={showArchiveDeletionModal} <UserFriendModal
archivePaths={archivePaths} visible={isFriendsModalVisible}
onClose={() => setShowArchiveDeletionModal(false)} initialTab={friendRequetsModalTab}
/> onClose={hideFriendsModal}
userId={friendModalUserId}
/>
)}
<main> <main>
<Sidebar /> <Sidebar />

View File

@@ -2,7 +2,6 @@ import { useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { import {
useAppSelector,
useDownload, useDownload,
useLibrary, useLibrary,
useToast, useToast,
@@ -27,8 +26,6 @@ export function BottomPanel() {
const { lastPacket, progress, downloadSpeed, eta } = useDownload(); const { lastPacket, progress, downloadSpeed, eta } = useDownload();
const extraction = useAppSelector((state) => state.download.extraction);
const [version, setVersion] = useState(""); const [version, setVersion] = useState("");
const [sessionHash, setSessionHash] = useState<null | string>(""); const [sessionHash, setSessionHash] = useState<null | string>("");
const [commonRedistStatus, setCommonRedistStatus] = useState<string | null>( const [commonRedistStatus, setCommonRedistStatus] = useState<string | null>(
@@ -71,20 +68,6 @@ export function BottomPanel() {
return t("installing_common_redist", { log: commonRedistStatus }); return t("installing_common_redist", { log: commonRedistStatus });
} }
if (extraction) {
const extractingGame = library.find(
(game) => game.id === extraction.visibleId
);
if (extractingGame) {
const extractionPercentage = Math.round(extraction.progress * 100);
return t("extracting", {
title: extractingGame.title,
percentage: `${extractionPercentage}%`,
});
}
}
const game = lastPacket const game = lastPacket
? library.find((game) => game.id === lastPacket?.gameId) ? library.find((game) => game.id === lastPacket?.gameId)
: undefined; : undefined;
@@ -126,7 +109,6 @@ export function BottomPanel() {
eta, eta,
downloadSpeed, downloadSpeed,
commonRedistStatus, commonRedistStatus,
extraction,
]); ]);
return ( return (
@@ -140,10 +122,10 @@ export function BottomPanel() {
</button> </button>
<button <button
data-open-workwonders-changelog-mini data-featurebase-changelog
className="bottom-panel__version-button" className="bottom-panel__version-button"
> >
<small> <small data-featurebase-changelog>
{sessionHash ? `${sessionHash} -` : ""} v{version} &quot; {sessionHash ? `${sessionHash} -` : ""} v{version} &quot;
{VERSION_CODENAME}&quot; {VERSION_CODENAME}&quot;
</small> </small>

View File

@@ -18,7 +18,6 @@ interface DropdownMenuProps {
side?: "top" | "bottom" | "left" | "right"; side?: "top" | "bottom" | "left" | "right";
align?: "start" | "center" | "end"; align?: "start" | "center" | "end";
alignOffset?: number; alignOffset?: number;
collisionPadding?: number;
} }
export function DropdownMenu({ export function DropdownMenu({
@@ -30,7 +29,6 @@ export function DropdownMenu({
loop = true, loop = true,
align = "center", align = "center",
alignOffset = 0, alignOffset = 0,
collisionPadding = 16,
}: Readonly<DropdownMenuProps>) { }: Readonly<DropdownMenuProps>) {
return ( return (
<DropdownMenuPrimitive.Root> <DropdownMenuPrimitive.Root>
@@ -45,7 +43,6 @@ export function DropdownMenu({
loop={loop} loop={loop}
align={align} align={align}
alignOffset={alignOffset} alignOffset={alignOffset}
collisionPadding={collisionPadding}
className="dropdown-menu__content" className="dropdown-menu__content"
> >
{title && ( {title && (

View File

@@ -1,82 +0,0 @@
@use "../../scss/globals.scss";
.fullscreen-media-modal__overlay {
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
background-color: rgba(0, 0, 0, 0.8);
backdrop-filter: blur(2px);
z-index: globals.$backdrop-z-index;
display: flex;
justify-content: center;
align-items: center;
padding: 0;
margin: 0;
}
.fullscreen-media-modal {
display: flex;
justify-content: center;
align-items: center;
width: 100%;
height: 100%;
position: relative;
margin: 0;
padding: 0;
border: none;
background: transparent;
max-width: none;
max-height: none;
&__close-button {
position: absolute;
top: calc(globals.$spacing-unit * 5);
right: calc(globals.$spacing-unit * 4);
cursor: pointer;
color: globals.$body-color;
background-color: rgba(0, 0, 0, 0.5);
border-radius: 50%;
border: 1px solid globals.$border-color;
padding: globals.$spacing-unit;
display: flex;
align-items: center;
justify-content: center;
transition: all ease 0.2s;
z-index: 10;
&:hover {
background-color: rgba(0, 0, 0, 0.8);
transform: scale(1.1);
}
}
&__image-container {
max-width: 90%;
max-height: 90%;
display: flex;
justify-content: center;
align-items: center;
}
&__image {
max-width: 100%;
max-height: 60vh;
object-fit: contain;
border-radius: 8px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5);
animation: image-appear 0.3s cubic-bezier(0.34, 1.56, 0.64, 1) forwards;
}
}
@keyframes image-appear {
0% {
opacity: 0;
transform: scale(0.85);
}
100% {
opacity: 1;
transform: scale(1);
}
}

View File

@@ -1,87 +0,0 @@
import { useEffect, useRef } from "react";
import { createPortal } from "react-dom";
import { XIcon } from "@primer/octicons-react";
import { useTranslation } from "react-i18next";
import "./fullscreen-media-modal.scss";
export interface FullscreenMediaModalProps {
visible: boolean;
onClose: () => void;
src: string | null | undefined;
alt?: string;
}
export function FullscreenMediaModal({
visible,
onClose,
src,
alt,
}: FullscreenMediaModalProps) {
const containerRef = useRef<HTMLDivElement | null>(null);
const { t } = useTranslation("modal");
useEffect(() => {
if (visible) {
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") {
onClose();
}
};
window.addEventListener("keydown", onKeyDown);
return () => {
window.removeEventListener("keydown", onKeyDown);
};
}
return () => {};
}, [onClose, visible]);
useEffect(() => {
const onMouseDown = (e: MouseEvent) => {
if (containerRef.current) {
const clickedOnImage = containerRef.current.contains(e.target as Node);
if (!clickedOnImage) {
onClose();
}
}
};
if (visible) {
window.addEventListener("mousedown", onMouseDown);
}
return () => {
window.removeEventListener("mousedown", onMouseDown);
};
}, [onClose, visible]);
if (!visible || !src) return null;
return createPortal(
<div className="fullscreen-media-modal__overlay">
<dialog className="fullscreen-media-modal" open aria-label={alt}>
<button
type="button"
onClick={onClose}
className="fullscreen-media-modal__close-button"
aria-label={t("close")}
>
<XIcon size={24} />
</button>
<div
ref={containerRef}
className="fullscreen-media-modal__image-container"
>
<img src={src} alt={alt} className="fullscreen-media-modal__image" />
</div>
</dialog>
</div>,
document.body
);
}

View File

@@ -15,8 +15,6 @@ import { AutoUpdateSubHeader } from "./auto-update-sub-header";
import { setFilters, setLibrarySearchQuery } from "@renderer/features"; import { setFilters, setLibrarySearchQuery } from "@renderer/features";
import cn from "classnames"; import cn from "classnames";
import { SearchDropdown } from "@renderer/components"; import { SearchDropdown } from "@renderer/components";
import { buildGameDetailsPath } from "@renderer/helpers";
import type { GameShop } from "@types";
const pathTitle: Record<string, string> = { const pathTitle: Record<string, string> = {
"/": "home", "/": "home",
@@ -82,7 +80,6 @@ export function Header() {
if (location.pathname.startsWith("/game")) return headerTitle; if (location.pathname.startsWith("/game")) return headerTitle;
if (location.pathname.startsWith("/achievements")) return headerTitle; if (location.pathname.startsWith("/achievements")) return headerTitle;
if (location.pathname.startsWith("/profile")) return headerTitle; if (location.pathname.startsWith("/profile")) return headerTitle;
if (location.pathname.startsWith("/notifications")) return headerTitle;
if (location.pathname.startsWith("/library")) if (location.pathname.startsWith("/library"))
return headerTitle || t("library"); return headerTitle || t("library");
if (location.pathname.startsWith("/search")) return t("search_results"); if (location.pathname.startsWith("/search")) return t("search_results");
@@ -164,11 +161,11 @@ export function Header() {
const handleSelectSuggestion = (suggestion: { const handleSelectSuggestion = (suggestion: {
title: string; title: string;
objectId: string; objectId: string;
shop: GameShop; shop: string;
}) => { }) => {
setIsDropdownVisible(false); setIsDropdownVisible(false);
inputRef.current?.blur(); inputRef.current?.blur();
navigate(buildGameDetailsPath(suggestion)); navigate(`/game/${suggestion.shop}/${suggestion.objectId}`);
}; };
const handleClearSearch = () => { const handleClearSearch = () => {
@@ -324,8 +321,7 @@ export function Header() {
<SearchDropdown <SearchDropdown
visible={ visible={
isDropdownVisible && isDropdownVisible &&
(searchValue.trim().length > 0 || (historyItems.length > 0 ||
historyItems.length > 0 ||
suggestions.length > 0 || suggestions.length > 0 ||
isLoadingSuggestions) isLoadingSuggestions)
} }

View File

@@ -20,4 +20,3 @@ export * from "./game-context-menu/game-context-menu";
export * from "./game-context-menu/use-game-actions"; export * from "./game-context-menu/use-game-actions";
export * from "./star-rating/star-rating"; export * from "./star-rating/star-rating";
export * from "./search-dropdown/search-dropdown"; export * from "./search-dropdown/search-dropdown";
export * from "./fullscreen-media-modal/fullscreen-media-modal";

View File

@@ -10,7 +10,7 @@ import cn from "classnames";
export interface ModalProps { export interface ModalProps {
visible: boolean; visible: boolean;
title: React.ReactNode; title: string;
description?: string; description?: string;
onClose: () => void; onClose: () => void;
large?: boolean; large?: boolean;
@@ -115,6 +115,7 @@ export function Modal({
"modal--large": large, "modal--large": large,
})} })}
role="dialog" role="dialog"
aria-labelledby={title}
aria-describedby={description} aria-describedby={description}
ref={modalContentRef} ref={modalContentRef}
data-hydra-dialog data-hydra-dialog

View File

@@ -1,11 +1,11 @@
import React from "react"; import React from "react";
interface HighlightTextProps { interface HighlightTextProps {
readonly text: string; text: string;
readonly query: string; query: string;
} }
export function HighlightText({ text, query }: Readonly<HighlightTextProps>) { export function HighlightText({ text, query }: HighlightTextProps) {
if (!query.trim()) { if (!query.trim()) {
return <>{text}</>; return <>{text}</>;
} }
@@ -19,25 +19,24 @@ export function HighlightText({ text, query }: Readonly<HighlightTextProps>) {
return <>{text}</>; return <>{text}</>;
} }
const matches: { start: number; end: number }[] = []; const textWords = text.split(/\b/);
const textLower = text.toLowerCase(); const matches: Array<{ start: number; end: number; text: string }> = [];
queryWords.forEach((queryWord) => { let currentIndex = 0;
const escapedQuery = queryWord.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); textWords.forEach((word) => {
const regex = new RegExp( const wordLower = word.toLowerCase();
`(?:^|[\\s])${escapedQuery}(?=[\\s]|$)|^${escapedQuery}$`,
"gi"
);
let match; queryWords.forEach((queryWord) => {
while ((match = regex.exec(textLower)) !== null) { if (wordLower === queryWord) {
const matchedText = match[0]; matches.push({
const leadingSpace = matchedText.startsWith(" ") ? 1 : 0; start: currentIndex,
const start = match.index + leadingSpace; end: currentIndex + word.length,
const end = start + queryWord.length; text: word,
});
}
});
matches.push({ start, end }); currentIndex += word.length;
}
}); });
if (matches.length === 0) { if (matches.length === 0) {
@@ -46,15 +45,17 @@ export function HighlightText({ text, query }: Readonly<HighlightTextProps>) {
matches.sort((a, b) => a.start - b.start); matches.sort((a, b) => a.start - b.start);
const mergedMatches: { start: number; end: number }[] = []; const mergedMatches: Array<{ start: number; end: number }> = [];
if (matches.length === 0) {
return <>{text}</>;
}
let current = matches[0]; let current = matches[0];
for (let i = 1; i < matches.length; i++) { for (let i = 1; i < matches.length; i++) {
if (matches[i].start <= current.end) { if (matches[i].start <= current.end) {
current = { current.end = Math.max(current.end, matches[i].end);
start: current.start,
end: Math.max(current.end, matches[i].end),
};
} else { } else {
mergedMatches.push(current); mergedMatches.push(current);
current = matches[i]; current = matches[i];
@@ -62,7 +63,7 @@ export function HighlightText({ text, query }: Readonly<HighlightTextProps>) {
} }
mergedMatches.push(current); mergedMatches.push(current);
const parts: { text: string; highlight: boolean; key: string }[] = []; const parts: Array<{ text: string; highlight: boolean }> = [];
let lastIndex = 0; let lastIndex = 0;
mergedMatches.forEach((match) => { mergedMatches.forEach((match) => {
@@ -70,14 +71,12 @@ export function HighlightText({ text, query }: Readonly<HighlightTextProps>) {
parts.push({ parts.push({
text: text.slice(lastIndex, match.start), text: text.slice(lastIndex, match.start),
highlight: false, highlight: false,
key: `${lastIndex}-${match.start}`,
}); });
} }
parts.push({ parts.push({
text: text.slice(match.start, match.end), text: text.slice(match.start, match.end),
highlight: true, highlight: true,
key: `${match.start}-${match.end}`,
}); });
lastIndex = match.end; lastIndex = match.end;
@@ -87,19 +86,18 @@ export function HighlightText({ text, query }: Readonly<HighlightTextProps>) {
parts.push({ parts.push({
text: text.slice(lastIndex), text: text.slice(lastIndex),
highlight: false, highlight: false,
key: `${lastIndex}-${text.length}`,
}); });
} }
return ( return (
<> <>
{parts.map((part) => {parts.map((part, index) =>
part.highlight ? ( part.highlight ? (
<mark key={part.key} className="search-dropdown__highlight"> <mark key={index} className="search-dropdown__highlight">
{part.text} {part.text}
</mark> </mark>
) : ( ) : (
<React.Fragment key={part.key}>{part.text}</React.Fragment> <React.Fragment key={index}>{part.text}</React.Fragment>
) )
)} )}
</> </>

View File

@@ -5,7 +5,7 @@
background-color: globals.$dark-background-color; background-color: globals.$dark-background-color;
border: 1px solid globals.$border-color; border: 1px solid globals.$border-color;
border-radius: 8px; border-radius: 8px;
max-height: 350px; max-height: 300px;
overflow-y: auto; overflow-y: auto;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3); box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
z-index: 1000; z-index: 1000;
@@ -24,8 +24,7 @@
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
padding: 8px 12px 8px; padding: 8px 12px 4px;
margin-bottom: 4px;
} }
&__section-title { &__section-title {
@@ -36,19 +35,19 @@
letter-spacing: 0.5px; letter-spacing: 0.5px;
} }
&__clear-text-button { &__clear-button {
color: globals.$muted-color; color: globals.$muted-color;
cursor: pointer; cursor: pointer;
padding: 0; padding: 4px;
font-size: 11px; border-radius: 4px;
font-weight: bold; transition: all ease 0.2s;
text-transform: uppercase; display: flex;
transition: color ease 0.2s; align-items: center;
background: transparent; justify-content: center;
border: none;
&:hover { &:hover {
color: #ffffff; color: #dadbe1;
background-color: rgba(255, 255, 255, 0.1);
} }
} }
@@ -75,16 +74,17 @@
transform: translateY(-50%); transform: translateY(-50%);
color: globals.$muted-color; color: globals.$muted-color;
padding: 4px; padding: 4px;
border-radius: 4px;
opacity: 0; opacity: 0;
transition: opacity ease 0.15s; transition: all ease 0.15s;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
background-color: transparent; background-color: transparent;
&:hover { &:hover {
color: #ff3333; color: #ff5555;
background-color: rgba(255, 85, 85, 0.2); background-color: rgba(255, 85, 85, 0.1);
} }
} }
@@ -144,8 +144,8 @@
} }
&__highlight { &__highlight {
background-color: rgba(255, 193, 7, 0.4); background-color: rgba(255, 193, 7, 0.3);
color: #ffa000; color: #ffc107;
font-weight: 600; font-weight: 600;
padding: 0 2px; padding: 0 2px;
border-radius: 2px; border-radius: 2px;

View File

@@ -1,6 +1,11 @@
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useCallback, useState } from "react";
import { createPortal } from "react-dom"; import { createPortal } from "react-dom";
import { ClockIcon, SearchIcon, XIcon } from "@primer/octicons-react"; import {
ClockIcon,
SearchIcon,
TrashIcon,
XIcon,
} from "@primer/octicons-react";
import cn from "classnames"; import cn from "classnames";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import type { SearchHistoryEntry } from "@renderer/hooks/use-search-history"; import type { SearchHistoryEntry } from "@renderer/hooks/use-search-history";
@@ -92,8 +97,23 @@ export function SearchDropdown({
return () => document.removeEventListener("mousedown", handleClickOutside); return () => document.removeEventListener("mousedown", handleClickOutside);
}, [visible, onClose, searchContainerRef]); }, [visible, onClose, searchContainerRef]);
const handleItemClick = useCallback(
(
type: "history" | "suggestion",
item: SearchHistoryEntry | SearchSuggestion
) => {
if (type === "history") {
onSelectHistory((item as SearchHistoryEntry).query);
} else {
onSelectSuggestion(item as SearchSuggestion);
}
},
[onSelectHistory, onSelectSuggestion]
);
if (!visible) return null; if (!visible) return null;
const totalItems = historyItems.length + suggestions.length;
const hasHistory = historyItems.length > 0; const hasHistory = historyItems.length > 0;
const hasSuggestions = suggestions.length > 0; const hasSuggestions = suggestions.length > 0;
@@ -124,10 +144,11 @@ export function SearchDropdown({
</span> </span>
<button <button
type="button" type="button"
className="search-dropdown__clear-text-button" className="search-dropdown__clear-button"
onClick={onClearHistory} onClick={onClearHistory}
title={t("clear_history")}
> >
{t("clear_history")} <TrashIcon size={14} />
</button> </button>
</div> </div>
<ul className="search-dropdown__list"> <ul className="search-dropdown__list">
@@ -143,7 +164,7 @@ export function SearchDropdown({
activeIndex === getItemIndex("history", index), activeIndex === getItemIndex("history", index),
})} })}
onMouseDown={(e) => e.preventDefault()} onMouseDown={(e) => e.preventDefault()}
onClick={() => onSelectHistory(item.query)} onClick={() => handleItemClick("history", item)}
> >
<ClockIcon size={16} className="search-dropdown__item-icon" /> <ClockIcon size={16} className="search-dropdown__item-icon" />
<span className="search-dropdown__item-text"> <span className="search-dropdown__item-text">
@@ -185,7 +206,7 @@ export function SearchDropdown({
activeIndex === getItemIndex("suggestion", index), activeIndex === getItemIndex("suggestion", index),
})} })}
onMouseDown={(e) => e.preventDefault()} onMouseDown={(e) => e.preventDefault()}
onClick={() => onSelectSuggestion(item)} onClick={() => handleItemClick("suggestion", item)}
> >
{item.iconUrl ? ( {item.iconUrl ? (
<img <img
@@ -212,6 +233,13 @@ export function SearchDropdown({
{isLoadingSuggestions && !hasSuggestions && !hasHistory && ( {isLoadingSuggestions && !hasSuggestions && !hasHistory && (
<div className="search-dropdown__loading">{t("loading")}</div> <div className="search-dropdown__loading">{t("loading")}</div>
)} )}
{!isLoadingSuggestions &&
!hasHistory &&
!hasSuggestions &&
totalItems === 0 && (
<div className="search-dropdown__empty">{t("no_results")}</div>
)}
</div> </div>
); );

View File

@@ -5,7 +5,6 @@ import cn from "classnames";
import { useLocation } from "react-router-dom"; import { useLocation } from "react-router-dom";
import { useState } from "react"; import { useState } from "react";
import { GameContextMenu } from ".."; import { GameContextMenu } from "..";
import { useAppSelector } from "@renderer/hooks";
interface SidebarGameItemProps { interface SidebarGameItemProps {
game: LibraryGame; game: LibraryGame;
@@ -19,9 +18,6 @@ export function SidebarGameItem({
getGameTitle, getGameTitle,
}: Readonly<SidebarGameItemProps>) { }: Readonly<SidebarGameItemProps>) {
const location = useLocation(); const location = useLocation();
const userPreferences = useAppSelector(
(state) => state.userPreferences.value
);
const [contextMenu, setContextMenu] = useState<{ const [contextMenu, setContextMenu] = useState<{
visible: boolean; visible: boolean;
position: { x: number; y: number }; position: { x: number; y: number };
@@ -85,12 +81,11 @@ export function SidebarGameItem({
{getGameTitle(game)} {getGameTitle(game)}
</span> </span>
{userPreferences?.enableNewDownloadOptionsBadges !== false && {(game.newDownloadOptionsCount ?? 0) > 0 && (
(game.newDownloadOptionsCount ?? 0) > 0 && ( <span className="sidebar__game-badge">
<span className="sidebar__game-badge"> +{game.newDownloadOptionsCount}
+{game.newDownloadOptionsCount} </span>
</span> )}
)}
</button> </button>
</li> </li>

View File

@@ -46,7 +46,7 @@
white-space: nowrap; white-space: nowrap;
} }
&__notification-button { &__friends-button {
color: globals.$muted-color; color: globals.$muted-color;
cursor: pointer; cursor: pointer;
border-radius: 50%; border-radius: 50%;
@@ -62,7 +62,7 @@
} }
} }
&__notification-button-badge { &__friends-button-badge {
background-color: globals.$success-color; background-color: globals.$success-color;
display: flex; display: flex;
justify-content: center; justify-content: center;
@@ -73,8 +73,6 @@
position: absolute; position: absolute;
top: -5px; top: -5px;
right: -5px; right: -5px;
font-size: 10px;
font-weight: bold;
} }
&__game-running-icon { &__game-running-icon {

View File

@@ -1,13 +1,12 @@
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { BellIcon } from "@primer/octicons-react"; import { PeopleIcon } from "@primer/octicons-react";
import { useAppSelector, useUserDetails } from "@renderer/hooks"; import { useAppSelector, useUserDetails } from "@renderer/hooks";
import { useCallback, useEffect, useMemo, useState } from "react"; import { useMemo } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { UserFriendModalTab } from "@renderer/pages/shared-modals/user-friend-modal";
import SteamLogo from "@renderer/assets/steam-logo.svg?react"; import SteamLogo from "@renderer/assets/steam-logo.svg?react";
import { Avatar } from "../avatar/avatar"; import { Avatar } from "../avatar/avatar";
import { AuthPage } from "@shared"; import { AuthPage } from "@shared";
import { logger } from "@renderer/logger";
import type { NotificationCountResponse } from "@types";
import "./sidebar-profile.scss"; import "./sidebar-profile.scss";
export function SidebarProfile() { export function SidebarProfile() {
@@ -15,75 +14,11 @@ export function SidebarProfile() {
const { t } = useTranslation("sidebar"); const { t } = useTranslation("sidebar");
const { userDetails } = useUserDetails(); const { userDetails, friendRequestCount, showFriendsModal } =
useUserDetails();
const { gameRunning } = useAppSelector((state) => state.gameRunning); const { gameRunning } = useAppSelector((state) => state.gameRunning);
const [notificationCount, setNotificationCount] = useState(0);
const fetchNotificationCount = useCallback(async () => {
try {
// Always fetch local notification count
const localCount = await window.electron.getLocalNotificationsCount();
// Fetch API notification count only if logged in
let apiCount = 0;
if (userDetails) {
try {
const response =
await window.electron.hydraApi.get<NotificationCountResponse>(
"/profile/notifications/count",
{ needsAuth: true }
);
apiCount = response.count;
} catch {
// Ignore API errors
}
}
setNotificationCount(localCount + apiCount);
} catch (error) {
logger.error("Failed to fetch notification count", error);
}
}, [userDetails]);
useEffect(() => {
fetchNotificationCount();
const interval = setInterval(fetchNotificationCount, 60000);
return () => clearInterval(interval);
}, [fetchNotificationCount]);
useEffect(() => {
const unsubscribe = window.electron.onLocalNotificationCreated(() => {
fetchNotificationCount();
});
return () => unsubscribe();
}, [fetchNotificationCount]);
useEffect(() => {
const handleNotificationsChange = () => {
fetchNotificationCount();
};
window.addEventListener("notificationsChanged", handleNotificationsChange);
return () => {
window.removeEventListener(
"notificationsChanged",
handleNotificationsChange
);
};
}, [fetchNotificationCount]);
useEffect(() => {
const unsubscribe = window.electron.onSyncNotificationCount(() => {
fetchNotificationCount();
});
return () => unsubscribe();
}, [fetchNotificationCount]);
const handleProfileClick = () => { const handleProfileClick = () => {
if (userDetails === null) { if (userDetails === null) {
window.electron.openAuthWindow(AuthPage.SignIn); window.electron.openAuthWindow(AuthPage.SignIn);
@@ -93,24 +28,28 @@ export function SidebarProfile() {
navigate(`/profile/${userDetails.id}`); navigate(`/profile/${userDetails.id}`);
}; };
const notificationsButton = useMemo(() => { const friendsButton = useMemo(() => {
if (!userDetails) return null;
return ( return (
<button <button
type="button" type="button"
className="sidebar-profile__notification-button" className="sidebar-profile__friends-button"
onClick={() => navigate("/notifications")} onClick={() =>
title={t("notifications")} showFriendsModal(UserFriendModalTab.AddFriend, userDetails.id)
}
title={t("friends")}
> >
{notificationCount > 0 && ( {friendRequestCount > 0 && (
<small className="sidebar-profile__notification-button-badge"> <small className="sidebar-profile__friends-button-badge">
{notificationCount > 99 ? "99+" : notificationCount} {friendRequestCount > 99 ? "99+" : friendRequestCount}
</small> </small>
)} )}
<BellIcon size={16} /> <PeopleIcon size={16} />
</button> </button>
); );
}, [t, notificationCount, navigate]); }, [userDetails, t, friendRequestCount, showFriendsModal]);
const gameRunningDetails = () => { const gameRunningDetails = () => {
if (!userDetails || !gameRunning) return null; if (!userDetails || !gameRunning) return null;
@@ -159,7 +98,7 @@ export function SidebarProfile() {
</div> </div>
</button> </button>
{notificationsButton} {friendsButton}
</div> </div>
); );
} }

View File

@@ -10,8 +10,6 @@ export const DOWNLOADER_NAME = {
[Downloader.Qiwi]: "Qiwi", [Downloader.Qiwi]: "Qiwi",
[Downloader.Datanodes]: "Datanodes", [Downloader.Datanodes]: "Datanodes",
[Downloader.Mediafire]: "Mediafire", [Downloader.Mediafire]: "Mediafire",
[Downloader.Buzzheavier]: "Buzzheavier",
[Downloader.FuckingFast]: "FuckingFast",
[Downloader.TorBox]: "TorBox", [Downloader.TorBox]: "TorBox",
[Downloader.Hydra]: "Nimbus", [Downloader.Hydra]: "Nimbus",
}; };

View File

@@ -1,8 +1,6 @@
import { createContext, useCallback, useEffect, useRef, useState } from "react"; import { createContext, useCallback, useEffect, useRef, useState } from "react";
import { setHeaderTitle } from "@renderer/features"; import { setHeaderTitle } from "@renderer/features";
import { levelDBService } from "@renderer/services/leveldb.service";
import { orderBy } from "lodash-es";
import { getSteamLanguage } from "@renderer/helpers"; import { getSteamLanguage } from "@renderer/helpers";
import { import {
useAppDispatch, useAppDispatch,
@@ -12,7 +10,6 @@ import {
} from "@renderer/hooks"; } from "@renderer/hooks";
import type { import type {
DownloadSource,
GameRepack, GameRepack,
GameShop, GameShop,
GameStats, GameStats,
@@ -300,10 +297,7 @@ export function GameDetailsContextProvider({
const fetchDownloadSources = async () => { const fetchDownloadSources = async () => {
try { try {
const sourcesRaw = (await levelDBService.values( const sources = await window.electron.getDownloadSources();
"downloadSources"
)) as DownloadSource[];
const sources = orderBy(sourcesRaw, "createdAt", "desc");
const params = { const params = {
take: 100, take: 100,

View File

@@ -2,7 +2,6 @@ import { createContext, useCallback, useEffect, useState } from "react";
import { setUserPreferences } from "@renderer/features"; import { setUserPreferences } from "@renderer/features";
import { useAppDispatch } from "@renderer/hooks"; import { useAppDispatch } from "@renderer/hooks";
import { levelDBService } from "@renderer/services/leveldb.service";
import type { UserBlocks, UserPreferences } from "@types"; import type { UserBlocks, UserPreferences } from "@types";
import { useSearchParams } from "react-router-dom"; import { useSearchParams } from "react-router-dom";
@@ -135,11 +134,9 @@ export function SettingsContextProvider({
const updateUserPreferences = async (values: Partial<UserPreferences>) => { const updateUserPreferences = async (values: Partial<UserPreferences>) => {
await window.electron.updateUserPreferences(values); await window.electron.updateUserPreferences(values);
levelDBService window.electron.getUserPreferences().then((userPreferences) => {
.get("userPreferences", null, "json") dispatch(setUserPreferences(userPreferences));
.then((userPreferences) => { });
dispatch(setUserPreferences(userPreferences as UserPreferences | null));
});
}; };
return ( return (

View File

@@ -14,7 +14,6 @@ import type {
GameStats, GameStats,
UserDetails, UserDetails,
FriendRequestSync, FriendRequestSync,
NotificationSync,
GameArtifact, GameArtifact,
LudusaviBackup, LudusaviBackup,
UserAchievement, UserAchievement,
@@ -32,7 +31,6 @@ import type {
Game, Game,
DiskUsage, DiskUsage,
DownloadSource, DownloadSource,
LocalNotification,
} from "@types"; } from "@types";
import type { AxiosProgressEvent } from "axios"; import type { AxiosProgressEvent } from "axios";
@@ -210,13 +208,6 @@ declare global {
onExtractionComplete: ( onExtractionComplete: (
cb: (shop: GameShop, objectId: string) => void cb: (shop: GameShop, objectId: string) => void
) => () => Electron.IpcRenderer; ) => () => Electron.IpcRenderer;
onExtractionProgress: (
cb: (shop: GameShop, objectId: string, progress: number) => void
) => () => Electron.IpcRenderer;
onArchiveDeletionPrompt: (
cb: (archivePaths: string[]) => void
) => () => Electron.IpcRenderer;
deleteArchive: (filePath: string) => Promise<boolean>;
getDefaultWinePrefixSelectionPath: () => Promise<string | null>; getDefaultWinePrefixSelectionPath: () => Promise<string | null>;
createSteamShortcut: (shop: GameShop, objectId: string) => Promise<void>; createSteamShortcut: (shop: GameShop, objectId: string) => Promise<void>;
@@ -389,12 +380,10 @@ declare global {
processProfileImage: ( processProfileImage: (
path: string path: string
) => Promise<{ imagePath: string; mimeType: string }>; ) => Promise<{ imagePath: string; mimeType: string }>;
syncFriendRequests: () => Promise<void>;
onSyncFriendRequests: ( onSyncFriendRequests: (
cb: (friendRequests: FriendRequestSync) => void cb: (friendRequests: FriendRequestSync) => void
) => () => Electron.IpcRenderer; ) => () => Electron.IpcRenderer;
onSyncNotificationCount: (
cb: (notification: NotificationSync) => void
) => () => Electron.IpcRenderer;
updateFriendRequest: ( updateFriendRequest: (
userId: string, userId: string,
action: FriendRequestAction action: FriendRequestAction
@@ -402,15 +391,6 @@ declare global {
/* Notifications */ /* Notifications */
publishNewRepacksNotification: (newRepacksCount: number) => Promise<void>; publishNewRepacksNotification: (newRepacksCount: number) => Promise<void>;
getLocalNotifications: () => Promise<LocalNotification[]>;
getLocalNotificationsCount: () => Promise<number>;
markLocalNotificationRead: (id: string) => Promise<void>;
markAllLocalNotificationsRead: () => Promise<void>;
deleteLocalNotification: (id: string) => Promise<void>;
clearAllLocalNotifications: () => Promise<void>;
onLocalNotificationCreated: (
cb: (notification: LocalNotification) => void
) => () => Electron.IpcRenderer;
onAchievementUnlocked: ( onAchievementUnlocked: (
cb: ( cb: (
position?: AchievementCustomNotificationPosition, position?: AchievementCustomNotificationPosition,
@@ -458,25 +438,6 @@ declare global {
onNewDownloadOptions: ( onNewDownloadOptions: (
cb: (gamesWithNewOptions: { gameId: string; count: number }[]) => void cb: (gamesWithNewOptions: { gameId: string; count: number }[]) => void
) => () => Electron.IpcRenderer; ) => () => Electron.IpcRenderer;
/* LevelDB Generic CRUD */
leveldb: {
get: (
key: string,
sublevelName?: string | null,
valueEncoding?: "json" | "utf8"
) => Promise<unknown>;
put: (
key: string,
value: unknown,
sublevelName?: string | null,
valueEncoding?: "json" | "utf8"
) => Promise<void>;
del: (key: string, sublevelName?: string | null) => Promise<void>;
clear: (sublevelName: string) => Promise<void>;
values: (sublevelName: string) => Promise<unknown[]>;
iterator: (sublevelName: string) => Promise<[string, unknown][]>;
};
} }
interface Window { interface Window {

View File

@@ -1,28 +1,17 @@
import { createSlice } from "@reduxjs/toolkit"; import { createSlice } from "@reduxjs/toolkit";
import type { PayloadAction } from "@reduxjs/toolkit"; import type { PayloadAction } from "@reduxjs/toolkit";
import type { DownloadProgress, GameShop } from "@types"; import type { DownloadProgress } from "@types";
export interface ExtractionInfo {
visibleId: string;
progress: number;
}
export interface DownloadState { export interface DownloadState {
lastPacket: DownloadProgress | null; lastPacket: DownloadProgress | null;
gameId: string | null; gameId: string | null;
gamesWithDeletionInProgress: string[]; gamesWithDeletionInProgress: string[];
extraction: ExtractionInfo | null;
peakSpeeds: Record<string, number>;
speedHistory: Record<string, number[]>;
} }
const initialState: DownloadState = { const initialState: DownloadState = {
lastPacket: null, lastPacket: null,
gameId: null, gameId: null,
gamesWithDeletionInProgress: [], gamesWithDeletionInProgress: [],
extraction: null,
peakSpeeds: {},
speedHistory: {},
}; };
export const downloadSlice = createSlice({ export const downloadSlice = createSlice({
@@ -32,27 +21,6 @@ export const downloadSlice = createSlice({
setLastPacket: (state, action: PayloadAction<DownloadProgress | null>) => { setLastPacket: (state, action: PayloadAction<DownloadProgress | null>) => {
state.lastPacket = action.payload; state.lastPacket = action.payload;
if (!state.gameId && action.payload) state.gameId = action.payload.gameId; if (!state.gameId && action.payload) state.gameId = action.payload.gameId;
// Track peak speed and speed history atomically when packet arrives
if (action.payload?.gameId && action.payload.downloadSpeed != null) {
const { gameId, downloadSpeed } = action.payload;
// Update peak speed if this is higher
const currentPeak = state.peakSpeeds[gameId] || 0;
if (downloadSpeed > currentPeak) {
state.peakSpeeds[gameId] = downloadSpeed;
}
// Update speed history for chart
if (!state.speedHistory[gameId]) {
state.speedHistory[gameId] = [];
}
state.speedHistory[gameId].push(downloadSpeed);
// Keep only last 120 entries
if (state.speedHistory[gameId].length > 120) {
state.speedHistory[gameId].shift();
}
}
}, },
clearDownload: (state) => { clearDownload: (state) => {
state.lastPacket = null; state.lastPacket = null;
@@ -70,37 +38,6 @@ export const downloadSlice = createSlice({
const index = state.gamesWithDeletionInProgress.indexOf(action.payload); const index = state.gamesWithDeletionInProgress.indexOf(action.payload);
if (index >= 0) state.gamesWithDeletionInProgress.splice(index, 1); if (index >= 0) state.gamesWithDeletionInProgress.splice(index, 1);
}, },
setExtractionProgress: (
state,
action: PayloadAction<{
shop: GameShop;
objectId: string;
progress: number;
}>
) => {
const { shop, objectId, progress } = action.payload;
state.extraction = {
visibleId: `${shop}:${objectId}`,
progress,
};
},
clearExtraction: (state) => {
state.extraction = null;
},
updatePeakSpeed: (
state,
action: PayloadAction<{ gameId: string; speed: number }>
) => {
const { gameId, speed } = action.payload;
const currentPeak = state.peakSpeeds[gameId] || 0;
if (speed > currentPeak) {
state.peakSpeeds[gameId] = speed;
}
},
clearPeakSpeed: (state, action: PayloadAction<string>) => {
state.peakSpeeds[action.payload] = 0;
state.speedHistory[action.payload] = [];
},
}, },
}); });
@@ -109,8 +46,4 @@ export const {
clearDownload, clearDownload,
setGameDeleting, setGameDeleting,
removeGameFromDeleting, removeGameFromDeleting,
setExtractionProgress,
clearExtraction,
updatePeakSpeed,
clearPeakSpeed,
} = downloadSlice.actions; } = downloadSlice.actions;

View File

@@ -1,4 +1,5 @@
import { PayloadAction, createSlice } from "@reduxjs/toolkit"; import { PayloadAction, createSlice } from "@reduxjs/toolkit";
import { UserFriendModalTab } from "@renderer/pages/shared-modals/user-friend-modal";
import type { FriendRequest, UserDetails } from "@types"; import type { FriendRequest, UserDetails } from "@types";
export interface UserDetailsState { export interface UserDetailsState {
@@ -6,6 +7,9 @@ export interface UserDetailsState {
profileBackground: null | string; profileBackground: null | string;
friendRequests: FriendRequest[]; friendRequests: FriendRequest[];
friendRequestCount: number; friendRequestCount: number;
isFriendsModalVisible: boolean;
friendRequetsModalTab: UserFriendModalTab | null;
friendModalUserId: string;
} }
const initialState: UserDetailsState = { const initialState: UserDetailsState = {
@@ -13,6 +17,9 @@ const initialState: UserDetailsState = {
profileBackground: null, profileBackground: null,
friendRequests: [], friendRequests: [],
friendRequestCount: 0, friendRequestCount: 0,
isFriendsModalVisible: false,
friendRequetsModalTab: null,
friendModalUserId: "",
}; };
export const userDetailsSlice = createSlice({ export const userDetailsSlice = createSlice({
@@ -31,6 +38,18 @@ export const userDetailsSlice = createSlice({
setFriendRequestCount: (state, action: PayloadAction<number>) => { setFriendRequestCount: (state, action: PayloadAction<number>) => {
state.friendRequestCount = action.payload; state.friendRequestCount = action.payload;
}, },
setFriendsModalVisible: (
state,
action: PayloadAction<{ initialTab: UserFriendModalTab; userId: string }>
) => {
state.isFriendsModalVisible = true;
state.friendRequetsModalTab = action.payload.initialTab;
state.friendModalUserId = action.payload.userId;
},
setFriendsModalHidden: (state) => {
state.isFriendsModalVisible = false;
state.friendRequetsModalTab = null;
},
}, },
}); });
@@ -39,4 +58,6 @@ export const {
setProfileBackground, setProfileBackground,
setFriendRequests, setFriendRequests,
setFriendRequestCount, setFriendRequestCount,
setFriendsModalVisible,
setFriendsModalHidden,
} = userDetailsSlice.actions; } = userDetailsSlice.actions;

View File

@@ -3,7 +3,6 @@ import type { GameShop } from "@types";
import Color from "color"; import Color from "color";
import { v4 as uuidv4 } from "uuid"; import { v4 as uuidv4 } from "uuid";
import { THEME_WEB_STORE_URL } from "./constants"; import { THEME_WEB_STORE_URL } from "./constants";
import { levelDBService } from "./services/leveldb.service";
export const formatDownloadProgress = ( export const formatDownloadProgress = (
progress?: number, progress?: number,
@@ -128,12 +127,7 @@ export const getAchievementSoundUrl = async (): Promise<string> => {
.default; .default;
try { try {
const allThemes = (await levelDBService.values("themes")) as { const activeTheme = await window.electron.getActiveCustomTheme();
id: string;
isActive?: boolean;
hasCustomSound?: boolean;
}[];
const activeTheme = allThemes.find((theme) => theme.isActive);
if (activeTheme?.hasCustomSound) { if (activeTheme?.hasCustomSound) {
const soundDataUrl = await window.electron.getThemeSoundDataUrl( const soundDataUrl = await window.electron.getThemeSoundDataUrl(
@@ -152,18 +146,10 @@ export const getAchievementSoundUrl = async (): Promise<string> => {
export const getAchievementSoundVolume = async (): Promise<number> => { export const getAchievementSoundVolume = async (): Promise<number> => {
try { try {
const prefs = (await levelDBService.get( const prefs = await window.electron.getUserPreferences();
"userPreferences",
null,
"json"
)) as { achievementSoundVolume?: number } | null;
return prefs?.achievementSoundVolume ?? 0.15; return prefs?.achievementSoundVolume ?? 0.15;
} catch (error) { } catch (error) {
console.error("Failed to get sound volume", error); console.error("Failed to get sound volume", error);
return 0.15; return 0.15;
} }
}; };
export const getGameKey = (shop: GameShop, objectId: string): string => {
return `${shop}:${objectId}`;
};

View File

@@ -10,4 +10,3 @@ export * from "./use-download-options-listener";
export * from "./use-game-card"; export * from "./use-game-card";
export * from "./use-search-history"; export * from "./use-search-history";
export * from "./use-search-suggestions"; export * from "./use-search-suggestions";
export * from "./use-hls-video";

View File

@@ -1,9 +1,8 @@
import axios from "axios"; import axios from "axios";
import { useCallback, useEffect, useState } from "react"; import { useCallback, useEffect, useState } from "react";
import { levelDBService } from "@renderer/services/leveldb.service";
import type { DownloadSource } from "@types";
import { useAppDispatch } from "./redux"; import { useAppDispatch } from "./redux";
import { setGenres, setTags } from "@renderer/features"; import { setGenres, setTags } from "@renderer/features";
import type { DownloadSource } from "@types";
export const externalResourcesInstance = axios.create({ export const externalResourcesInstance = axios.create({
baseURL: import.meta.env.RENDERER_VITE_EXTERNAL_RESOURCES_URL, baseURL: import.meta.env.RENDERER_VITE_EXTERNAL_RESOURCES_URL,
@@ -41,9 +40,8 @@ export function useCatalogue() {
}, []); }, []);
const getDownloadSources = useCallback(() => { const getDownloadSources = useCallback(() => {
levelDBService.values("downloadSources").then((results) => { window.electron.getDownloadSources().then((results) => {
const sources = results as DownloadSource[]; setDownloadSources(results.filter((source) => !!source.fingerprint));
setDownloadSources(sources.filter((source) => !!source.fingerprint));
}); });
}, []); }, []);

View File

@@ -1,102 +0,0 @@
import { useEffect, useRef } from "react";
import Hls from "hls.js";
import { logger } from "@renderer/logger";
interface UseHlsVideoOptions {
videoSrc: string | undefined;
videoType: string | undefined;
autoplay?: boolean;
muted?: boolean;
loop?: boolean;
}
export function useHlsVideo(
videoRef: React.RefObject<HTMLVideoElement>,
{ videoSrc, videoType, autoplay, muted, loop }: UseHlsVideoOptions
) {
const hlsRef = useRef<Hls | null>(null);
useEffect(() => {
const video = videoRef.current;
if (!video || !videoSrc) return;
const isHls = videoType === "application/x-mpegURL";
if (!isHls) {
return undefined;
}
if (Hls.isSupported()) {
const hls = new Hls({
enableWorker: true,
lowLatencyMode: false,
});
hlsRef.current = hls;
hls.loadSource(videoSrc);
hls.attachMedia(video);
hls.on(Hls.Events.MANIFEST_PARSED, () => {
if (autoplay) {
video.play().catch((err) => {
logger.warn("Failed to autoplay HLS video:", err);
});
}
});
hls.on(Hls.Events.ERROR, (_event, data) => {
if (data.fatal) {
switch (data.type) {
case Hls.ErrorTypes.NETWORK_ERROR:
logger.error("HLS network error, trying to recover");
hls.startLoad();
break;
case Hls.ErrorTypes.MEDIA_ERROR:
logger.error("HLS media error, trying to recover");
hls.recoverMediaError();
break;
default:
logger.error("HLS fatal error, destroying instance");
hls.destroy();
break;
}
}
});
return () => {
hls.destroy();
hlsRef.current = null;
};
} else if (video.canPlayType("application/vnd.apple.mpegurl")) {
video.src = videoSrc;
video.load();
if (autoplay) {
video.play().catch((err) => {
logger.warn("Failed to autoplay HLS video:", err);
});
}
return () => {
video.src = "";
};
} else {
logger.warn("HLS playback is not supported in this browser");
return undefined;
}
}, [videoRef, videoSrc, videoType, autoplay, muted, loop]);
useEffect(() => {
const video = videoRef.current;
if (!video) return;
if (muted !== undefined) {
video.muted = muted;
}
if (loop !== undefined) {
video.loop = loop;
}
}, [videoRef, muted, loop]);
return hlsRef.current;
}

View File

@@ -1,5 +1,4 @@
import { useState, useCallback, useEffect, useRef } from "react"; import { useState, useCallback, useEffect } from "react";
import { levelDBService } from "@renderer/services/leveldb.service";
export interface SearchHistoryEntry { export interface SearchHistoryEntry {
query: string; query: string;
@@ -7,32 +6,22 @@ export interface SearchHistoryEntry {
context: "library" | "catalogue"; context: "library" | "catalogue";
} }
const LEVELDB_KEY = "searchHistory"; const STORAGE_KEY = "search-history";
const MAX_HISTORY_ENTRIES = 15; const MAX_HISTORY_ENTRIES = 15;
export function useSearchHistory() { export function useSearchHistory() {
const [history, setHistory] = useState<SearchHistoryEntry[]>([]); const [history, setHistory] = useState<SearchHistoryEntry[]>([]);
const isInitialized = useRef(false);
useEffect(() => { useEffect(() => {
const loadHistory = async () => { const stored = localStorage.getItem(STORAGE_KEY);
if (isInitialized.current) return; if (stored) {
isInitialized.current = true;
try { try {
const data = (await levelDBService.get(LEVELDB_KEY, null, "json")) as const parsed = JSON.parse(stored) as SearchHistoryEntry[];
| SearchHistoryEntry[] setHistory(parsed);
| null;
if (data) {
setHistory(data);
}
} catch { } catch {
setHistory([]); localStorage.removeItem(STORAGE_KEY);
} }
}; }
loadHistory();
}, []); }, []);
const addToHistory = useCallback( const addToHistory = useCallback(
@@ -50,7 +39,7 @@ export function useSearchHistory() {
(entry) => entry.query.toLowerCase() !== query.toLowerCase().trim() (entry) => entry.query.toLowerCase() !== query.toLowerCase().trim()
); );
const updated = [newEntry, ...filtered].slice(0, MAX_HISTORY_ENTRIES); const updated = [newEntry, ...filtered].slice(0, MAX_HISTORY_ENTRIES);
levelDBService.put(LEVELDB_KEY, updated, null, "json"); localStorage.setItem(STORAGE_KEY, JSON.stringify(updated));
return updated; return updated;
}); });
}, },
@@ -60,14 +49,14 @@ export function useSearchHistory() {
const removeFromHistory = useCallback((query: string) => { const removeFromHistory = useCallback((query: string) => {
setHistory((prev) => { setHistory((prev) => {
const updated = prev.filter((entry) => entry.query !== query); const updated = prev.filter((entry) => entry.query !== query);
levelDBService.put(LEVELDB_KEY, updated, null, "json"); localStorage.setItem(STORAGE_KEY, JSON.stringify(updated));
return updated; return updated;
}); });
}, []); }, []);
const clearHistory = useCallback(() => { const clearHistory = useCallback(() => {
setHistory([]); setHistory([]);
levelDBService.del(LEVELDB_KEY, null); localStorage.removeItem(STORAGE_KEY);
}, []); }, []);
const getRecentHistory = useCallback( const getRecentHistory = useCallback(

View File

@@ -1,13 +1,11 @@
import { useState, useEffect, useCallback, useRef } from "react"; import { useState, useEffect, useCallback, useRef } from "react";
import { useAppSelector } from "./redux"; import { useAppSelector } from "./redux";
import { debounce } from "lodash-es"; import { debounce } from "lodash-es";
import { logger } from "@renderer/logger";
import type { GameShop } from "@types";
export interface SearchSuggestion { export interface SearchSuggestion {
title: string; title: string;
objectId: string; objectId: string;
shop: GameShop; shop: string;
iconUrl: string | null; iconUrl: string | null;
source: "library" | "catalogue"; source: "library" | "catalogue";
} }
@@ -21,7 +19,6 @@ export function useSearchSuggestions(
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const library = useAppSelector((state) => state.library.value); const library = useAppSelector((state) => state.library.value);
const abortControllerRef = useRef<AbortController | null>(null); const abortControllerRef = useRef<AbortController | null>(null);
const cacheRef = useRef<Map<string, SearchSuggestion[]>>(new Map());
const getLibrarySuggestions = useCallback( const getLibrarySuggestions = useCallback(
(searchQuery: string, limit: number = 3): SearchSuggestion[] => { (searchQuery: string, limit: number = 3): SearchSuggestion[] => {
@@ -70,15 +67,6 @@ export function useSearchSuggestions(
return; return;
} }
const cacheKey = `${searchQuery.toLowerCase()}_${limit}`;
const cachedResults = cacheRef.current.get(cacheKey);
if (cachedResults) {
setSuggestions(cachedResults);
setIsLoading(false);
return;
}
abortControllerRef.current?.abort(); abortControllerRef.current?.abort();
const abortController = new AbortController(); const abortController = new AbortController();
abortControllerRef.current = abortController; abortControllerRef.current = abortController;
@@ -87,12 +75,12 @@ export function useSearchSuggestions(
try { try {
const response = await window.electron.hydraApi.get< const response = await window.electron.hydraApi.get<
{ Array<{
title: string; title: string;
objectId: string; objectId: string;
shop: GameShop; shop: string;
iconUrl: string | null; iconUrl: string | null;
}[] }>
>("/catalogue/search/suggestions", { >("/catalogue/search/suggestions", {
params: { params: {
query: searchQuery, query: searchQuery,
@@ -110,12 +98,10 @@ export function useSearchSuggestions(
}) })
); );
cacheRef.current.set(cacheKey, catalogueSuggestions);
setSuggestions(catalogueSuggestions); setSuggestions(catalogueSuggestions);
} catch (error) { } catch (error) {
if (!abortController.signal.aborted) { if (!abortController.signal.aborted) {
setSuggestions([]); setSuggestions([]);
logger.error("Failed to fetch catalogue suggestions", error);
} }
} finally { } finally {
if (!abortController.signal.aborted) { if (!abortController.signal.aborted) {

View File

@@ -4,6 +4,8 @@ import {
setProfileBackground, setProfileBackground,
setUserDetails, setUserDetails,
setFriendRequests, setFriendRequests,
setFriendsModalVisible,
setFriendsModalHidden,
} from "@renderer/features"; } from "@renderer/features";
import type { import type {
FriendRequestAction, FriendRequestAction,
@@ -11,12 +13,20 @@ import type {
UserDetails, UserDetails,
FriendRequest, FriendRequest,
} from "@types"; } from "@types";
import { UserFriendModalTab } from "@renderer/pages/shared-modals/user-friend-modal";
export function useUserDetails() { export function useUserDetails() {
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const { userDetails, profileBackground, friendRequests, friendRequestCount } = const {
useAppSelector((state) => state.userDetails); userDetails,
profileBackground,
friendRequests,
friendRequestCount,
isFriendsModalVisible,
friendModalUserId,
friendRequetsModalTab,
} = useAppSelector((state) => state.userDetails);
const clearUserDetails = useCallback(async () => { const clearUserDetails = useCallback(async () => {
dispatch(setUserDetails(null)); dispatch(setUserDetails(null));
@@ -75,11 +85,24 @@ export function useUserDetails() {
return window.electron.hydraApi return window.electron.hydraApi
.get<FriendRequest[]>("/profile/friend-requests") .get<FriendRequest[]>("/profile/friend-requests")
.then((friendRequests) => { .then((friendRequests) => {
window.electron.syncFriendRequests();
dispatch(setFriendRequests(friendRequests)); dispatch(setFriendRequests(friendRequests));
}) })
.catch(() => {}); .catch(() => {});
}, [dispatch]); }, [dispatch]);
const showFriendsModal = useCallback(
(initialTab: UserFriendModalTab, userId: string) => {
dispatch(setFriendsModalVisible({ initialTab, userId }));
fetchFriendRequests();
},
[dispatch, fetchFriendRequests]
);
const hideFriendsModal = useCallback(() => {
dispatch(setFriendsModalHidden());
}, [dispatch]);
const sendFriendRequest = useCallback( const sendFriendRequest = useCallback(
async (userId: string) => { async (userId: string) => {
return window.electron.hydraApi return window.electron.hydraApi
@@ -129,7 +152,12 @@ export function useUserDetails() {
profileBackground, profileBackground,
friendRequests, friendRequests,
friendRequestCount, friendRequestCount,
friendRequetsModalTab,
isFriendsModalVisible,
friendModalUserId,
hasActiveSubscription, hasActiveSubscription,
showFriendsModal,
hideFriendsModal,
fetchUserDetails, fetchUserDetails,
signOut, signOut,
clearUserDetails, clearUserDetails,

View File

@@ -21,7 +21,6 @@ import resources from "@locales";
import { logger } from "./logger"; import { logger } from "./logger";
import { addCookieInterceptor } from "./cookies"; import { addCookieInterceptor } from "./cookies";
import { levelDBService } from "./services/leveldb.service";
import Catalogue from "./pages/catalogue/catalogue"; import Catalogue from "./pages/catalogue/catalogue";
import Home from "./pages/home/home"; import Home from "./pages/home/home";
import Downloads from "./pages/downloads/downloads"; import Downloads from "./pages/downloads/downloads";
@@ -31,7 +30,6 @@ import Profile from "./pages/profile/profile";
import Achievements from "./pages/achievements/achievements"; import Achievements from "./pages/achievements/achievements";
import ThemeEditor from "./pages/theme-editor/theme-editor"; import ThemeEditor from "./pages/theme-editor/theme-editor";
import Library from "./pages/library/library"; import Library from "./pages/library/library";
import Notifications from "./pages/notifications/notifications";
import { AchievementNotification } from "./pages/achievements/notification/achievement-notification"; import { AchievementNotification } from "./pages/achievements/notification/achievement-notification";
console.log = logger.log; console.log = logger.log;
@@ -50,11 +48,7 @@ i18n
}, },
}) })
.then(async () => { .then(async () => {
const userPreferences = (await levelDBService.get( const userPreferences = await window.electron.getUserPreferences();
"userPreferences",
null,
"json"
)) as { language?: string } | null;
if (userPreferences?.language) { if (userPreferences?.language) {
i18n.changeLanguage(userPreferences.language); i18n.changeLanguage(userPreferences.language);
@@ -77,7 +71,6 @@ ReactDOM.createRoot(document.getElementById("root")!).render(
<Route path="/settings" element={<Settings />} /> <Route path="/settings" element={<Settings />} />
<Route path="/profile/:userId" element={<Profile />} /> <Route path="/profile/:userId" element={<Profile />} />
<Route path="/achievements" element={<Achievements />} /> <Route path="/achievements" element={<Achievements />} />
<Route path="/notifications" element={<Notifications />} />
</Route> </Route>
<Route path="/theme-editor" element={<ThemeEditor />} /> <Route path="/theme-editor" element={<ThemeEditor />} />

Some files were not shown because too many files have changed in this diff Show More