Compare commits

..

8 Commits

Author SHA1 Message Date
Chubby Granny Chaser
47bfc1648f Merge branch 'main' into fix/fixing-level-events 2025-12-10 17:34:01 +00:00
Chubby Granny Chaser
b5445b3dfa chore: updating lock 2025-12-01 06:51:08 +00:00
Chubby Granny Chaser
1ccf70af12 chore: updating lock 2025-11-30 23:57:13 +00:00
Chubby Granny Chaser
bb45b95820 chore: updating lock 2025-11-30 23:39:47 +00:00
Chubby Granny Chaser
361c158a44 fix: fixing level events 2025-11-30 23:17:56 +00:00
Chubby Granny Chaser
1f5e84b32c fix: fixing level events 2025-11-30 23:17:09 +00:00
Chubby Granny Chaser
e49d885b30 chore: update package.json to use yarn commands for type checking and building
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-30 15:21:50 +00:00
Chubby Granny Chaser
cb01301a0d feat: add new translation keys for network statistics in multiple languages 2025-11-30 15:07:32 +00:00
142 changed files with 1733 additions and 5308 deletions

View File

@@ -10,7 +10,6 @@
[![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)
[![chocolatey](https://img.shields.io/chocolatey/v/hydralauncher.svg)](https://community.chocolatey.org/packages/hydralauncher)
![Hydra Launcher Home Page](./docs/screenshot.png)

View File

@@ -19,12 +19,12 @@
"lint": "eslint . --ext .js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix",
"typecheck:node": "tsc --noEmit -p tsconfig.node.json --composite false",
"typecheck:web": "tsc --noEmit -p tsconfig.web.json --composite false",
"typecheck": "npm run typecheck:node && npm run typecheck:web",
"typecheck": "yarn run typecheck:node && yarn run typecheck:web",
"start": "electron-vite preview",
"dev": "electron-vite dev",
"build": "npm run typecheck && electron-vite build",
"build": "yarn run typecheck && electron-vite build",
"postinstall": "electron-builder install-app-deps && node ./scripts/postinstall.cjs",
"build:unpack": "npm run build && electron-builder --dir",
"build:unpack": "yarn run build && electron-builder --dir",
"build:win": "electron-vite build && electron-builder --win",
"build:mac": "electron-vite build && electron-builder --mac",
"build:linux": "electron-vite build && electron-builder --linux",
@@ -70,7 +70,6 @@
"jsonwebtoken": "^9.0.2",
"lodash-es": "^4.17.21",
"lucide-react": "^0.544.0",
"node-7z": "^3.0.0",
"parse-torrent": "^11.0.18",
"rc-virtual-list": "^3.18.3",
"react-dnd": "^16.0.1",

2
proto

Submodule proto updated: 6f11c99c57...7a23620f93

View File

@@ -26,7 +26,6 @@
"game_has_no_executable": "Game has no executable selected",
"sign_in": "Sign in",
"friends": "Friends",
"notifications": "Notifications",
"need_help": "Need help?",
"favorites": "Favorites",
"playable_button_title": "Show only games you can play now",
@@ -116,7 +115,6 @@
"downloading": "Downloading {{title}}… ({{percentage}} complete) - Completion {{eta}} - {{speed}}",
"calculating_eta": "Downloading {{title}}… ({{percentage}} complete) - Calculating remaining time…",
"checking_files": "Checking {{title}} files… ({{percentage}} complete)",
"extracting": "Extracting {{title}}… ({{percentage}} complete)",
"installing_common_redist": "{{log}}…",
"installation_complete": "Installation complete",
"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",
"download_in_progress": "Download in progress",
"download_paused": "Download paused",
"extracting": "Extracting",
"last_downloaded_option": "Last downloaded option",
"new_download_option": "New",
"create_steam_shortcut": "Create Steam shortcut",
@@ -418,12 +415,10 @@
"options": "Manage",
"extract": "Extract 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"
"network": "Network",
"peak": "Peak",
"seeds": "Seeds",
"peers": "Peers"
},
"settings": {
"downloads_path": "Downloads path",
@@ -559,7 +554,6 @@
"show_download_speed_in_megabytes": "Show download speed in megabytes per second",
"extract_files_by_default": "Extract files by default after download",
"enable_steam_achievements": "Enable search for Steam achievements",
"enable_new_download_options_badges": "Show new download options badges",
"achievement_custom_notification_position": "Achievement custom notification position",
"top-left": "Top left",
"top-center": "Top center",
@@ -664,7 +658,6 @@
"sending": "Sending",
"friend_request_sent": "Friend request sent",
"friends": "Friends",
"badges": "Badges",
"friends_list": "Friends list",
"user_not_found": "User not found",
"block_user": "Block user",
@@ -675,16 +668,12 @@
"ignore_request": "Ignore request",
"cancel_request": "Cancel request",
"undo_friendship": "Undo friendship",
"friendship_removed": "Friend removed",
"request_accepted": "Request accepted",
"user_blocked_successfully": "User blocked successfully",
"user_block_modal_text": "This will block {{displayName}}",
"blocked_users": "Blocked users",
"unblock": "Unblock",
"no_friends_added": "You have no added friends",
"view_all": "View all",
"load_more": "Load more",
"loading": "Loading",
"pending": "Pending",
"no_pending_invites": "You have no pending invites",
"no_blocked_users": "You have no blocked users",
@@ -708,7 +697,6 @@
"report_reason_other": "Other",
"profile_reported": "Profile reported",
"your_friend_code": "Your friend code:",
"copy_friend_code": "Copy friend code",
"upload_banner": "Upload banner",
"uploading_banner": "Uploading banner…",
"background_image_updated": "Background image updated",
@@ -731,10 +719,7 @@
"karma_description": "Earned from positive likes on reviews",
"user_reviews": "Reviews",
"delete_review": "Delete Review",
"loading_reviews": "Loading reviews...",
"wrapped_2025": "Wrapped 2025",
"view_my_wrapped_button": "View My Wrapped 2025",
"view_wrapped_button": "View {{displayName}}'s Wrapped 2025"
"loading_reviews": "Loading reviews..."
},
"library": {
"library": "Library",
@@ -785,40 +770,5 @@
"hydra_cloud_feature_found": "You've just discovered a Hydra Cloud feature!",
"learn_more": "Learn More",
"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

@@ -414,7 +414,11 @@
"resume_seeding": "Continuar sembrando",
"options": "Administrar",
"extract": "Extraer archivos",
"extracting": "Extrayendo archivos…"
"extracting": "Extrayendo archivos…",
"network": "Red",
"peak": "Pico",
"seeds": "Seeds",
"peers": "Peers"
},
"settings": {
"downloads_path": "Ruta de descarga",

View File

@@ -27,69 +27,7 @@
"friends": "Amis",
"need_help": "Besoin d'aide ?",
"favorites": "Favoris",
"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"
"playable_button_title": "Afficher uniquement les jeux que vous pouvez jouer maintenant"
},
"header": {
"search": "Rechercher",
@@ -99,15 +37,7 @@
"search_results": "Résultats de la recherche",
"settings": "Paramètres",
"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.",
"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"
"version_available_download": "Version {{version}} disponible. Cliquez ici pour télécharger."
},
"bottom_panel": {
"no_downloads_in_progress": "Aucun téléchargement en cours",
@@ -117,8 +47,7 @@
"checking_files": "Vérification des fichiers de {{title}}… ({{percentage}} terminé)",
"installing_common_redist": "{{log}}…",
"installation_complete": "Installation terminée",
"installation_complete_message": "Redistribuables communs installés avec succès",
"extracting": "Extraction de {{title}}… ({{percentage}} terminé)"
"installation_complete_message": "Redistribuables communs installés avec succès"
},
"catalogue": {
"search": "Filtrer…",
@@ -269,113 +198,7 @@
"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_added_to_favorites": "Jeu ajouté aux favoris",
"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"
"automatically_extract_downloaded_files": "Extraire automatiquement les fichiers téléchargés"
},
"activation": {
"title": "Activer Hydra",
@@ -414,11 +237,7 @@
"resume_seeding": "Reprendre le partage",
"options": "Gérer",
"extract": "Extraire les 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"
"extracting": "Extraction des fichiers…"
},
"settings": {
"downloads_path": "Chemin des téléchargements",
@@ -547,40 +366,7 @@
"bottom-left": "En bas à gauche",
"bottom-center": "En bas au centre",
"bottom-right": "En bas à droite",
"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"
"enable_friend_start_game_notifications": "Quand un ami commence à jouer à un jeu"
},
"notifications": {
"download_complete": "Téléchargement terminé",

View File

@@ -22,7 +22,7 @@
"downloading": "{{title}} ({{percentage}} - Letöltés…)",
"filter": "Könyvtár szűrése",
"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",
"sign_in": "Bejelentkezés",
"friends": "Barátok",
@@ -94,12 +94,6 @@
"header": {
"search": "Keresés",
"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",
"catalogue": "Katalógus",
"library": "Könyvtár",
@@ -115,7 +109,6 @@
"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ő…",
"checking_files": "A(z) {{title}} fájljaiból… ({{percentage}} kész)",
"extracting": "{{title}} kicsomagolása… ({{percentage}} kicsomagolva)",
"installing_common_redist": "{{log}}…",
"installation_complete": "Telepítés befejezve",
"installation_complete_message": "A(z) Alapvető segédprogramok sikeresen telepítve"
@@ -172,7 +165,7 @@
"playing_now": "Játékban: ",
"change": "Változtatás",
"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",
"no_shop_details": "A bolt adatai nem érhetőek el.",
"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",
"download_in_progress": "Letöltés folyamatban",
"download_paused": "Letöltés szüneteltetve",
"extracting": "Kicsomagolás",
"last_downloaded_option": "Utoljára letöltött",
"new_download_option": "Új",
"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",
"install": "Telepít",
"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",
"queued": "Várakozásban",
"no_downloads_title": "Oly üres..",
@@ -416,11 +408,7 @@
"resume_seeding": "Seedelés folytatása",
"options": "Kezelés",
"extract": "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"
"extracting": "Fájlok kibontása…"
},
"settings": {
"downloads_path": "Letöltési útvonalak",
@@ -681,7 +669,7 @@
"no_blocked_users": "Nincs letiltott felhasználó",
"friend_code_copied": "Barát kód kimásolva",
"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",
"image_process_failure": "Hiba a kép feldolgozása közben",
"required_field": "Ez a mező kötelező",

View File

@@ -115,7 +115,6 @@
"downloading": "Baixando {{title}}… ({{percentage}} concluído) - Conclusão {{eta}} - {{speed}}",
"calculating_eta": "Baixando {{title}}… ({{percentage}} concluído) - Calculando tempo restante…",
"checking_files": "Verificando arquivos de {{title}}…",
"extracting": "Extraindo {{title}}… ({{percentage}} concluído)",
"installing_common_redist": "{{log}}…",
"installation_complete": "Instalação concluída",
"installation_complete_message": "Componentes recomendados instalados com sucesso"
@@ -191,7 +190,6 @@
"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_paused": "Download pausado",
"extracting": "Extraindo",
"last_downloaded_option": "Última opção baixada",
"new_download_option": "Novo",
"create_steam_shortcut": "Criar atalho na Steam",
@@ -405,12 +403,10 @@
"options": "Gerenciar",
"extract": "Extrair 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"
"network": "Rede",
"peak": "Pico",
"seeds": "Seeds",
"peers": "Peers"
},
"settings": {
"downloads_path": "Diretório dos downloads",

View File

@@ -229,7 +229,13 @@
"seeding": "A semear",
"stop_seeding": "Parar de semear",
"resume_seeding": "Semear",
"options": "Opções"
"options": "Opções",
"extract": "Extrair ficheiros",
"extracting": "A extrair ficheiros…",
"network": "Rede",
"peak": "Pico",
"seeds": "Seeds",
"peers": "Peers"
},
"settings": {
"downloads_path": "Local das transferências",

View File

@@ -414,7 +414,11 @@
"resume_seeding": "Продолжить раздачу",
"options": "Управлять",
"extract": "Распаковать файлы",
"extracting": "Распаковка файлов…"
"extracting": "Распаковка файлов…",
"network": "Сеть",
"peak": "Пик",
"seeds": "Seeds",
"peers": "Peers"
},
"settings": {
"downloads_path": "Путь загрузок",

View File

@@ -1,20 +0,0 @@
import jwt from "jsonwebtoken";
import { registerEvent } from "../register-event";
import { db, levelKeys } from "@main/level";
import type { Auth } from "@types";
const getSessionHash = async (_event: Electron.IpcMainInvokeEvent) => {
const auth = await db.get<string, Auth>(levelKeys.auth, {
valueEncoding: "json",
});
if (!auth) return null;
const payload = jwt.decode(auth.accessToken) as jwt.JwtPayload;
if (!payload) return null;
return payload.sessionId;
};
registerEvent("getSessionHash", getSessionHash);

View File

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

View File

@@ -1,13 +0,0 @@
import { getDownloadSourcesCheckBaseline } from "@main/level";
import { registerEvent } from "../register-event";
const getDownloadSourcesCheckBaselineHandler = async (
_event: Electron.IpcMainInvokeEvent
) => {
return await getDownloadSourcesCheckBaseline();
};
registerEvent(
"getDownloadSourcesCheckBaseline",
getDownloadSourcesCheckBaselineHandler
);

View File

@@ -1,13 +0,0 @@
import { getDownloadSourcesSinceValue } from "@main/level";
import { registerEvent } from "../register-event";
const getDownloadSourcesSinceValueHandler = async (
_event: Electron.IpcMainInvokeEvent
) => {
return await getDownloadSourcesSinceValue();
};
registerEvent(
"getDownloadSourcesSinceValue",
getDownloadSourcesSinceValueHandler
);

View File

@@ -1,10 +0,0 @@
import { downloadSourcesSublevel } from "@main/level";
import { registerEvent } from "../register-event";
import { orderBy } from "lodash-es";
const getDownloadSources = async (_event: Electron.IpcMainInvokeEvent) => {
const allSources = await downloadSourcesSublevel.values().all();
return orderBy(allSources, "createdAt", "desc");
};
registerEvent("getDownloadSources", getDownloadSources);

View File

@@ -1,6 +1,3 @@
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,27 +0,0 @@
import { registerEvent } from "../register-event";
import { gamesSublevel, levelKeys } from "@main/level";
import { logger } from "@main/services";
import type { GameShop } from "@types";
const clearNewDownloadOptions = async (
_event: Electron.IpcMainInvokeEvent,
shop: GameShop,
objectId: string
) => {
const gameKey = levelKeys.game(shop, objectId);
const game = await gamesSublevel.get(gameKey);
if (!game) return;
try {
await gamesSublevel.put(gameKey, {
...game,
newDownloadOptionsCount: undefined,
});
logger.info(`Cleared newDownloadOptionsCount for game ${gameKey}`);
} catch (error) {
logger.error(`Failed to clear newDownloadOptionsCount: ${error}`);
}
};
registerEvent("clearNewDownloadOptions", clearNewDownloadOptions);

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, {
...download,
extracting: true,
extractionProgress: 0,
});
const gameFilesManager = new GameFilesManager(shop, objectId);

View File

@@ -1,21 +0,0 @@
import { registerEvent } from "../register-event";
import { gamesSublevel, downloadsSublevel, levelKeys } from "@main/level";
import type { GameShop } from "@types";
const getGameByObjectId = async (
_event: Electron.IpcMainInvokeEvent,
shop: GameShop,
objectId: string
) => {
const gameKey = levelKeys.game(shop, objectId);
const [game, download] = await Promise.all([
gamesSublevel.get(gameKey),
downloadsSublevel.get(gameKey),
]);
if (!game || game.isDeleted) return null;
return { id: gameKey, ...game, download };
};
registerEvent("getGameByObjectId", getGameByObjectId);

View File

@@ -1,49 +0,0 @@
import type { LibraryGame } from "@types";
import { registerEvent } from "../register-event";
import {
downloadsSublevel,
gameAchievementsSublevel,
gamesShopAssetsSublevel,
gamesSublevel,
} from "@main/level";
const getLibrary = async (): Promise<LibraryGame[]> => {
return gamesSublevel
.iterator()
.all()
.then((results) => {
return Promise.all(
results
.filter(([_key, game]) => game.isDeleted === false)
.map(async ([key, game]) => {
const download = await downloadsSublevel.get(key);
const gameAssets = await gamesShopAssetsSublevel.get(key);
let unlockedAchievementCount = game.unlockedAchievementCount ?? 0;
if (!game.unlockedAchievementCount) {
const achievements = await gameAchievementsSublevel.get(key);
unlockedAchievementCount =
achievements?.unlockedAchievements.length ?? 0;
}
return {
id: key,
...game,
download: download ?? null,
unlockedAchievementCount,
achievementCount: game.achievementCount ?? 0,
// Spread gameAssets last to ensure all image URLs are properly set
...gameAssets,
// Preserve custom image URLs from game if they exist
customIconUrl: game.customIconUrl,
customLogoImageUrl: game.customLogoImageUrl,
customHeroImageUrl: game.customHeroImageUrl,
};
})
);
});
};
registerEvent("getLibrary", getLibrary);

View File

@@ -3,17 +3,13 @@ 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";

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 +1,3 @@
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 +1,4 @@
import "./get-me";
import "./process-profile-image";
import "./sync-friend-requests";
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,12 +0,0 @@
import { Theme } from "@types";
import { registerEvent } from "../register-event";
import { themesSublevel } from "@main/level";
const addCustomTheme = async (
_event: Electron.IpcMainInvokeEvent,
theme: Theme
) => {
await themesSublevel.put(theme.id, theme);
};
registerEvent("addCustomTheme", addCustomTheme);

View File

@@ -1,8 +0,0 @@
import { themesSublevel } from "@main/level";
import { registerEvent } from "../register-event";
const deleteAllCustomThemes = async (_event: Electron.IpcMainInvokeEvent) => {
await themesSublevel.clear();
};
registerEvent("deleteAllCustomThemes", deleteAllCustomThemes);

View File

@@ -1,11 +0,0 @@
import { themesSublevel } from "@main/level";
import { registerEvent } from "../register-event";
const deleteCustomTheme = async (
_event: Electron.IpcMainInvokeEvent,
themeId: string
) => {
await themesSublevel.del(themeId);
};
registerEvent("deleteCustomTheme", deleteCustomTheme);

View File

@@ -1,9 +0,0 @@
import { themesSublevel } from "@main/level";
import { registerEvent } from "../register-event";
const getActiveCustomTheme = async () => {
const allThemes = await themesSublevel.values().all();
return allThemes.find((theme) => theme.isActive);
};
registerEvent("getActiveCustomTheme", getActiveCustomTheme);

View File

@@ -1,8 +0,0 @@
import { themesSublevel } from "@main/level";
import { registerEvent } from "../register-event";
const getAllCustomThemes = async (_event: Electron.IpcMainInvokeEvent) => {
return themesSublevel.values().all();
};
registerEvent("getAllCustomThemes", getAllCustomThemes);

View File

@@ -1,11 +0,0 @@
import { themesSublevel } from "@main/level";
import { registerEvent } from "../register-event";
const getCustomThemeById = async (
_event: Electron.IpcMainInvokeEvent,
themeId: string
) => {
return themesSublevel.get(themeId);
};
registerEvent("getCustomThemeById", getCustomThemeById);

View File

@@ -1,11 +1,5 @@
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";

View File

@@ -41,6 +41,7 @@ const startGameDownload = async (
const game = await gamesSublevel.get(gameKey);
const gameAssets = await gamesShopAssetsSublevel.get(gameKey);
/* Delete any previous download */
await downloadsSublevel.del(gameKey);
if (game) {
@@ -81,7 +82,6 @@ const startGameDownload = async (
queued: true,
extracting: false,
automaticallyExtract,
extractionProgress: 0,
};
try {
@@ -123,42 +123,6 @@ const startGameDownload = async (
}
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 };
}

View File

@@ -1,10 +0,0 @@
import { registerEvent } from "../register-event";
import { db, levelKeys } from "@main/level";
import type { UserPreferences } from "@types";
const getUserPreferences = async () =>
db.get<string, UserPreferences | null>(levelKeys.userPreferences, {
valueEncoding: "json",
});
registerEvent("getUserPreferences", getUserPreferences);

View File

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

View File

@@ -1,11 +0,0 @@
import { db, levelKeys } from "@main/level";
import type { Auth } from "@types";
import { registerEvent } from "../register-event";
const getAuth = async (_event: Electron.IpcMainInvokeEvent) =>
db.get<string, Auth>(levelKeys.auth, {
valueEncoding: "json",
});
registerEvent("getAuth", getAuth);

View File

@@ -1,3 +1,2 @@
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)
// tslint:disable
import type { BinaryWriteOptions } from "@protobuf-ts/runtime";
@@ -15,11 +15,11 @@ import { MessageType } from "@protobuf-ts/runtime";
*/
export interface FriendRequest {
/**
* @generated from protobuf field: int32 friend_request_count = 1
* @generated from protobuf field: int32 friend_request_count = 1;
*/
friendRequestCount: number;
/**
* @generated from protobuf field: optional string sender_id = 2
* @generated from protobuf field: optional string sender_id = 2;
*/
senderId?: string;
}
@@ -28,27 +28,18 @@ export interface FriendRequest {
*/
export interface FriendGameSession {
/**
* @generated from protobuf field: string object_id = 1
* @generated from protobuf field: string object_id = 1;
*/
objectId: string;
/**
* @generated from protobuf field: string shop = 2
* @generated from protobuf field: string shop = 2;
*/
shop: string;
/**
* @generated from protobuf field: string friend_id = 3
* @generated from protobuf field: string friend_id = 3;
*/
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
*/
@@ -60,24 +51,17 @@ export interface Envelope {
| {
oneofKind: "friendRequest";
/**
* @generated from protobuf field: FriendRequest friend_request = 1
* @generated from protobuf field: FriendRequest friend_request = 1;
*/
friendRequest: FriendRequest;
}
| {
oneofKind: "friendGameSession";
/**
* @generated from protobuf field: FriendGameSession friend_game_session = 2
* @generated from protobuf field: FriendGameSession friend_game_session = 2;
*/
friendGameSession: FriendGameSession;
}
| {
oneofKind: "notification";
/**
* @generated from protobuf field: Notification notification = 3
*/
notification: Notification;
}
| {
oneofKind: undefined;
};
@@ -255,80 +239,6 @@ class FriendGameSession$Type extends MessageType<FriendGameSession> {
*/
export const FriendGameSession = new FriendGameSession$Type();
// @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> {
constructor() {
super("Envelope", [
@@ -346,13 +256,6 @@ class Envelope$Type extends MessageType<Envelope> {
oneof: "payload",
T: () => FriendGameSession,
},
{
no: 3,
name: "notification",
kind: "message",
oneof: "payload",
T: () => Notification,
},
]);
}
create(value?: PartialMessage<Envelope>): Envelope {
@@ -395,17 +298,6 @@ class Envelope$Type extends MessageType<Envelope> {
),
};
break;
case /* Notification notification */ 3:
message.payload = {
oneofKind: "notification",
notification: Notification.internalBinaryRead(
reader,
reader.uint32(),
options,
(message.payload as any).notification
),
};
break;
default:
let u = options.readUnknownField;
if (u === "throw")
@@ -444,13 +336,6 @@ class Envelope$Type extends MessageType<Envelope> {
writer.tag(2, WireType.LengthDelimited).fork(),
options
).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;
if (u !== false)
(u == true ? UnknownFieldHandler.onWrite : u)(

View File

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

View File

@@ -20,5 +20,4 @@ export const levelKeys = {
downloadSources: "downloadSources",
downloadSourcesCheckBaseline: "downloadSourcesCheckBaseline", // When we last started the app
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

@@ -33,7 +33,9 @@ export const loadState = async () => {
await import("./events");
Aria2.spawn();
if (process.platform !== "darwin") {
Aria2.spawn();
}
if (userPreferences?.realDebridApiToken) {
RealDebridClient.authorize(userPreferences.realDebridApiToken);
@@ -57,10 +59,8 @@ export const loadState = async () => {
const { syncDownloadSourcesFromApi } = await import("./services/user");
void syncDownloadSourcesFromApi();
// Check for new download options on startup (if enabled)
(async () => {
await DownloadSourcesChecker.checkForChanges();
})();
// Check for new download options on startup
DownloadSourcesChecker.checkForChanges();
WSClient.connect();
});

View File

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

View File

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

View File

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

View File

@@ -5,12 +5,10 @@ import {
updateDownloadSourcesCheckBaseline,
updateDownloadSourcesSinceValue,
downloadSourcesSublevel,
db,
levelKeys,
} from "@main/level";
import { logger } from "./logger";
import { WindowManager } from "./window-manager";
import type { Game, UserPreferences } from "@types";
import type { Game } from "@types";
interface DownloadSourcesChangeResponse {
shop: string;
@@ -103,20 +101,6 @@ export class DownloadSourcesChecker {
logger.info("DownloadSourcesChecker.checkForChanges() called");
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)
const installedGames = await gamesSublevel.values().all();
const nonCustomGames = installedGames.filter(

View File

@@ -20,84 +20,14 @@ import { RealDebridClient } from "./real-debrid";
import path from "path";
import { logger } from "../logger";
import { db, downloadsSublevel, gamesSublevel, levelKeys } from "@main/level";
import { sortBy } from "lodash-es";
import { orderBy } from "lodash-es";
import { TorBoxClient } from "./torbox";
import { GameFilesManager } from "../game-files-manager";
import { HydraDebridClient } from "./hydra-debrid";
import { BuzzheavierApi, FuckingFastApi } from "@main/services/hosters";
export class DownloadManager {
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(
download?: Download,
downloadsToSeed?: Download[]
@@ -191,14 +121,21 @@ export class DownloadManager {
const userPreferences = await db.get<string, UserPreferences | null>(
levelKeys.userPreferences,
{ valueEncoding: "json" }
{
valueEncoding: "json",
}
);
if (WindowManager.mainWindow && download) {
WindowManager.mainWindow.setProgressBar(progress === 1 ? -1 : progress);
WindowManager.mainWindow.webContents.send(
"on-download-progress",
JSON.parse(JSON.stringify({ ...status, game }))
JSON.parse(
JSON.stringify({
...status,
game,
})
)
);
}
@@ -242,25 +179,27 @@ export class DownloadManager {
)
) {
gameFilesManager.extractDownloadedFile();
} else if (download.folderName) {
} else {
gameFilesManager
.extractFilesInDirectory(
path.join(download.downloadPath, download.folderName)
path.join(download.downloadPath, download.folderName!)
)
.then(() => gameFilesManager.setExtractionComplete());
.then(() => {
gameFilesManager.setExtractionComplete();
});
}
}
const downloads = await downloadsSublevel
.values()
.all()
.then((games) =>
sortBy(
.then((games) => {
return orderBy(
games.filter((game) => game.status === "paused" && game.queued),
"timestamp",
"DESC"
)
);
"desc"
);
});
const [nextItemOnQueue] = downloads;
@@ -328,8 +267,13 @@ export class DownloadManager {
static async cancelDownload(downloadKey = this.downloadingGameId) {
await PythonRPC.rpc
.post("/action", { action: "cancel", game_id: downloadKey })
.catch((err) => logger.error("Failed to cancel game download", err));
.post("/action", {
action: "cancel",
game_id: downloadKey,
})
.catch((err) => {
logger.error("Failed to cancel game download", err);
});
if (downloadKey === this.downloadingGameId) {
WindowManager.mainWindow?.setProgressBar(-1);
@@ -362,6 +306,7 @@ export class DownloadManager {
const id = download.uri.split("/").pop();
const token = await GofileApi.authorize();
const downloadLink = await GofileApi.getDownloadLink(id!);
await GofileApi.checkDownloadUrl(downloadLink);
return {
@@ -403,50 +348,9 @@ export class DownloadManager {
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: {
const downloadUrl = await MediafireApi.getDownloadUrl(download.uri);
return {
action: "start",
game_id: downloadId,
@@ -463,6 +367,7 @@ export class DownloadManager {
};
case Downloader.RealDebrid: {
const downloadUrl = await RealDebridClient.getDownloadUrl(download.uri);
if (!downloadUrl) throw new Error(DownloadError.NotCachedOnRealDebrid);
return {
@@ -475,6 +380,7 @@ export class DownloadManager {
}
case Downloader.TorBox: {
const { name, url } = await TorBoxClient.getDownloadInfo(download.uri);
if (!url) return;
return {
action: "start",
@@ -489,6 +395,7 @@ export class DownloadManager {
const downloadUrl = await HydraDebridClient.getDownloadUrl(
download.uri
);
if (!downloadUrl) throw new Error(DownloadError.NotCachedOnHydra);
return {

View File

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

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) {
const searchParams = new URLSearchParams({
wt: WT,
});
const response = await axios.get<{
status: string;
data: GofileContentsResponse;
}>(`https://api.gofile.io/contents/${id}`, {
}>(`https://api.gofile.io/contents/${id}?${searchParams.toString()}`, {
headers: {
Authorization: `Bearer ${this.token}`,
"X-Website-Token": WT,
},
});

View File

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

View File

@@ -30,7 +30,7 @@ export class HydraApi {
private static instance: AxiosInstance;
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) {
return seconds * 1000;

View File

@@ -20,4 +20,3 @@ export * from "./lock";
export * from "./decky-plugin";
export * from "./user";
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 { getThemeSoundPath } from "@main/helpers";
import { processProfileImage } from "@main/events/profile/process-profile-image";
import { LocalNotificationManager } from "./local-notifications";
const getStaticImage = async (path: string) => {
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) {
new Notification({
title,
body,
title: t("download_complete", {
ns: "notifications",
}),
body: t("game_ready_to_install", {
ns: "notifications",
title: game.title,
}),
icon: await downloadImage(game.iconUrl),
}).show();
}
// Create local notification
await LocalNotificationManager.createNotification(
"DOWNLOAD_COMPLETE",
title,
body,
{
pictureUrl: game.iconUrl,
url: `/game/${game.shop}/${game.objectId}`,
}
);
};
export const publishNotificationUpdateReadyToInstall = async (
version: string
) => {
const title = t("new_update_available", {
ns: "notifications",
version,
});
const body = t("restart_to_install_update", {
ns: "notifications",
});
new Notification({
title,
body,
title: t("new_update_available", {
ns: "notifications",
version,
}),
body: t("restart_to_install_update", {
ns: "notifications",
}),
icon: trayIcon,
})
.on("click", () => {
restartAndInstallUpdate();
})
.show();
// Create local notification
await LocalNotificationManager.createNotification(
"UPDATE_AVAILABLE",
title,
body
);
};
export const publishNewFriendRequestNotification = async (
@@ -204,27 +181,14 @@ export const publishCombinedNewAchievementNotification = async (
};
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({
title,
body,
title: t("extraction_complete", { ns: "notifications" }),
body: t("game_extracted", {
ns: "notifications",
title: game.title,
}),
icon: trayIcon,
}).show();
// Create local notification
await LocalNotificationManager.createNotification(
"EXTRACTION_COMPLETE",
title,
body,
{
url: `/game/${game.shop}/${game.objectId}`,
}
);
};
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

@@ -36,9 +36,9 @@ export class WindowManager {
private static initialConfigInitializationMainWindow: Electron.BrowserWindowConstructorOptions =
{
width: 1200,
height: 860,
height: 720,
minWidth: 1024,
minHeight: 860,
minHeight: 540,
backgroundColor: "#1c1c1c",
titleBarStyle: process.platform === "linux" ? "default" : "hidden",
icon,
@@ -106,7 +106,7 @@ export class WindowManager {
valueEncoding: "json",
}
);
return data ?? { isMaximized: false, height: 860, width: 1200 };
return data ?? { isMaximized: false, height: 720, width: 1200 };
}
private static updateInitialConfig(
@@ -224,7 +224,7 @@ export class WindowManager {
? {
x: undefined,
y: undefined,
height: this.initialConfigInitializationMainWindow.height ?? 860,
height: this.initialConfigInitializationMainWindow.height ?? 720,
width: this.initialConfigInitializationMainWindow.width ?? 1200,
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 { friendRequestEvent } from "./events/friend-request";
import { friendGameSessionEvent } from "./events/friend-game-session";
import { notificationEvent } from "./events/notification";
export class WSClient {
private static ws: WebSocket | null = null;
@@ -52,10 +51,6 @@ export class WSClient {
if (envelope.payload.oneofKind === "friendGameSession") {
friendGameSessionEvent(envelope.payload.friendGameSession);
}
if (envelope.payload.oneofKind === "notification") {
notificationEvent(envelope.payload.notification);
}
});
this.ws.on("close", () => this.handleDisconnect("close"));

View File

@@ -13,9 +13,7 @@ import type {
UpdateProfileRequest,
SeedingStatus,
GameAchievement,
Theme,
FriendRequestSync,
NotificationSync,
ShortcutLocation,
AchievementCustomNotificationPosition,
AchievementNotificationInfo,
@@ -87,7 +85,8 @@ contextBridge.exposeInMainWorld("electron", {
},
/* User preferences */
getUserPreferences: () => ipcRenderer.invoke("getUserPreferences"),
getUserPreferences: () =>
ipcRenderer.invoke("leveldbGet", "userPreferences", null, "json"),
updateUserPreferences: (preferences: UserPreferences) =>
ipcRenderer.invoke("updateUserPreferences", preferences),
autoLaunch: (autoLaunchProps: { enabled: boolean; minimized: boolean }) =>
@@ -102,12 +101,7 @@ contextBridge.exposeInMainWorld("electron", {
ipcRenderer.invoke("addDownloadSource", url),
removeDownloadSource: (url: string, removeAll?: boolean) =>
ipcRenderer.invoke("removeDownloadSource", url, removeAll),
getDownloadSources: () => ipcRenderer.invoke("getDownloadSources"),
syncDownloadSources: () => ipcRenderer.invoke("syncDownloadSources"),
getDownloadSourcesCheckBaseline: () =>
ipcRenderer.invoke("getDownloadSourcesCheckBaseline"),
getDownloadSourcesSinceValue: () =>
ipcRenderer.invoke("getDownloadSourcesSinceValue"),
/* Library */
toggleAutomaticCloudSync: (
@@ -184,8 +178,6 @@ contextBridge.exposeInMainWorld("electron", {
ipcRenderer.invoke("addGameToFavorites", shop, objectId),
removeGameFromFavorites: (shop: GameShop, objectId: string) =>
ipcRenderer.invoke("removeGameFromFavorites", shop, objectId),
clearNewDownloadOptions: (shop: GameShop, objectId: string) =>
ipcRenderer.invoke("clearNewDownloadOptions", shop, objectId),
toggleGamePin: (shop: GameShop, objectId: string, pinned: boolean) =>
ipcRenderer.invoke("toggleGamePin", shop, objectId, pinned),
updateLaunchOptions: (
@@ -202,7 +194,6 @@ contextBridge.exposeInMainWorld("electron", {
ipcRenderer.invoke("selectGameWinePrefix", shop, objectId, winePrefixPath),
verifyExecutablePathInUse: (executablePath: string) =>
ipcRenderer.invoke("verifyExecutablePathInUse", executablePath),
getLibrary: () => ipcRenderer.invoke("getLibrary"),
refreshLibraryAssets: () => ipcRenderer.invoke("refreshLibraryAssets"),
openGameInstaller: (shop: GameShop, objectId: string) =>
ipcRenderer.invoke("openGameInstaller", shop, objectId),
@@ -231,8 +222,6 @@ contextBridge.exposeInMainWorld("electron", {
ipcRenderer.invoke("removeGame", shop, objectId),
deleteGameFolder: (shop: GameShop, objectId: string) =>
ipcRenderer.invoke("deleteGameFolder", shop, objectId),
getGameByObjectId: (shop: GameShop, objectId: string) =>
ipcRenderer.invoke("getGameByObjectId", shop, objectId),
resetGameAchievements: (shop: GameShop, objectId: string) =>
ipcRenderer.invoke("resetGameAchievements", shop, objectId),
changeGamePlayTime: (shop: GameShop, objectId: string, playtime: number) =>
@@ -268,29 +257,6 @@ contextBridge.exposeInMainWorld("electron", {
ipcRenderer.on("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 */
getDiskFreeSpace: (path: string) =>
@@ -311,8 +277,6 @@ contextBridge.exposeInMainWorld("electron", {
gameArtifactId: string
) =>
ipcRenderer.invoke("downloadGameArtifact", objectId, shop, gameArtifactId),
getGameArtifacts: (objectId: string, shop: GameShop) =>
ipcRenderer.invoke("getGameArtifacts", objectId, shop),
getGameBackupPreview: (objectId: string, shop: GameShop) =>
ipcRenderer.invoke("getGameBackupPreview", objectId, shop),
selectGameBackupPath: (
@@ -498,6 +462,7 @@ contextBridge.exposeInMainWorld("electron", {
ipcRenderer.invoke("updateProfile", updateProfile),
processProfileImage: (imagePath: string) =>
ipcRenderer.invoke("processProfileImage", imagePath),
syncFriendRequests: () => ipcRenderer.invoke("syncFriendRequests"),
onSyncFriendRequests: (cb: (friendRequests: FriendRequestSync) => void) => {
const listener = (
_event: Electron.IpcRendererEvent,
@@ -507,15 +472,6 @@ contextBridge.exposeInMainWorld("electron", {
return () =>
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) =>
ipcRenderer.invoke("updateFriendRequest", userId, action),
@@ -535,11 +491,10 @@ contextBridge.exposeInMainWorld("electron", {
ipcRenderer.invoke("getUnlockedAchievements", objectId, shop),
/* Auth */
getAuth: () => ipcRenderer.invoke("getAuth"),
getAuth: () => ipcRenderer.invoke("leveldbGet", "auth", null, "json"),
signOut: () => ipcRenderer.invoke("signOut"),
openAuthWindow: (page: AuthPage) =>
ipcRenderer.invoke("openAuthWindow", page),
getSessionHash: () => ipcRenderer.invoke("getSessionHash"),
onSignIn: (cb: () => void) => {
const listener = (_event: Electron.IpcRendererEvent) => cb();
ipcRenderer.on("on-signin", listener);
@@ -559,26 +514,6 @@ contextBridge.exposeInMainWorld("electron", {
/* Notifications */
publishNewRepacksNotification: (newRepacksCount: number) =>
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: (
cb: (
position?: AchievementCustomNotificationPosition,
@@ -617,16 +552,8 @@ contextBridge.exposeInMainWorld("electron", {
ipcRenderer.invoke("showAchievementTestNotification"),
/* Themes */
addCustomTheme: (theme: Theme) => ipcRenderer.invoke("addCustomTheme", theme),
getAllCustomThemes: () => ipcRenderer.invoke("getAllCustomThemes"),
deleteAllCustomThemes: () => ipcRenderer.invoke("deleteAllCustomThemes"),
deleteCustomTheme: (themeId: string) =>
ipcRenderer.invoke("deleteCustomTheme", themeId),
updateCustomTheme: (themeId: string, code: string) =>
ipcRenderer.invoke("updateCustomTheme", themeId, code),
getCustomThemeById: (themeId: string) =>
ipcRenderer.invoke("getCustomThemeById", themeId),
getActiveCustomTheme: () => ipcRenderer.invoke("getActiveCustomTheme"),
toggleCustomTheme: (themeId: string, isActive: boolean) =>
ipcRenderer.invoke("toggleCustomTheme", themeId, isActive),
copyThemeAchievementSound: (themeId: string, sourcePath: string) =>

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 {
@@ -19,13 +19,11 @@ import {
setUserDetails,
setProfileBackground,
setGameRunning,
setExtractionProgress,
clearExtraction,
} from "@renderer/features";
import { useTranslation } from "react-i18next";
import { UserFriendModal } from "./pages/shared-modals/user-friend-modal";
import { useSubscription } from "./hooks/use-subscription";
import { HydraCloudModal } from "./pages/shared-modals/hydra-cloud/hydra-cloud-modal";
import { ArchiveDeletionModal } from "./pages/downloads/archive-deletion-error-modal";
import {
injectCustomCss,
@@ -53,7 +51,12 @@ export function App() {
const { clearDownload, setLastPacket } = useDownload();
const {
userDetails,
hasActiveSubscription,
isFriendsModalVisible,
friendRequetsModalTab,
friendModalUserId,
hideFriendsModal,
fetchUserDetails,
updateUserDetails,
clearUserDetails,
@@ -75,10 +78,6 @@ export function App() {
const { showSuccessToast } = useToast();
const [showArchiveDeletionModal, setShowArchiveDeletionModal] =
useState(false);
const [archivePaths, setArchivePaths] = useState<string[]>([]);
useEffect(() => {
Promise.all([
levelDBService.get("userPreferences", null, "json"),
@@ -129,6 +128,7 @@ export function App() {
.then((response) => {
if (response) {
updateUserDetails(response);
window.electron.syncFriendRequests();
}
})
.finally(() => {
@@ -145,6 +145,7 @@ export function App() {
fetchUserDetails().then((response) => {
if (response) {
updateUserDetails(response);
window.electron.syncFriendRequests();
showSuccessToast(t("successfully_signed_in"));
}
});
@@ -183,23 +184,12 @@ export function App() {
updateLibrary();
}),
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 () => {
listeners.forEach((unsubscribe) => unsubscribe());
};
}, [onSignIn, updateLibrary, clearUserDetails, dispatch]);
}, [onSignIn, updateLibrary, clearUserDetails]);
useEffect(() => {
if (contentRef.current) contentRef.current.scrollTop = 0;
@@ -291,11 +281,14 @@ export function App() {
feature={hydraCloudFeature}
/>
<ArchiveDeletionModal
visible={showArchiveDeletionModal}
archivePaths={archivePaths}
onClose={() => setShowArchiveDeletionModal(false)}
/>
{userDetails && (
<UserFriendModal
visible={isFriendsModalVisible}
initialTab={friendRequetsModalTab}
onClose={hideFriendsModal}
userId={friendModalUserId}
/>
)}
<main>
<Sidebar />

View File

@@ -2,17 +2,18 @@ import { useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import {
useAppSelector,
useDownload,
useLibrary,
useToast,
useUserDetails,
} from "@renderer/hooks";
import { levelDBService } from "@renderer/services/leveldb.service";
import "./bottom-panel.scss";
import { useNavigate } from "react-router-dom";
import { VERSION_CODENAME } from "@renderer/constants";
import type jwt from "jsonwebtoken";
export function BottomPanel() {
const { t } = useTranslation("bottom_panel");
@@ -27,8 +28,6 @@ export function BottomPanel() {
const { lastPacket, progress, downloadSpeed, eta } = useDownload();
const extraction = useAppSelector((state) => state.download.extraction);
const [version, setVersion] = useState("");
const [sessionHash, setSessionHash] = useState<null | string>("");
const [commonRedistStatus, setCommonRedistStatus] = useState<string | null>(
@@ -63,7 +62,28 @@ export function BottomPanel() {
}, [t, showSuccessToast]);
useEffect(() => {
window.electron.getSessionHash().then((result) => setSessionHash(result));
const getSessionHash = async () => {
const auth = (await levelDBService.get("auth", null, "json")) as {
accessToken?: string;
} | null;
if (!auth?.accessToken) {
setSessionHash(null);
return;
}
try {
const jwtModule = await import("jsonwebtoken");
const payload = jwtModule.decode(
auth.accessToken
) as jwt.JwtPayload | null;
setSessionHash(payload?.sessionId ?? null);
} catch {
setSessionHash(null);
}
};
getSessionHash();
}, [userDetails?.id]);
const status = useMemo(() => {
@@ -71,20 +91,6 @@ export function BottomPanel() {
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
? library.find((game) => game.id === lastPacket?.gameId)
: undefined;
@@ -126,7 +132,6 @@ export function BottomPanel() {
eta,
downloadSpeed,
commonRedistStatus,
extraction,
]);
return (

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

@@ -82,7 +82,6 @@ export function Header() {
if (location.pathname.startsWith("/game")) return headerTitle;
if (location.pathname.startsWith("/achievements")) return headerTitle;
if (location.pathname.startsWith("/profile")) return headerTitle;
if (location.pathname.startsWith("/notifications")) return headerTitle;
if (location.pathname.startsWith("/library"))
return headerTitle || t("library");
if (location.pathname.startsWith("/search")) return t("search_results");
@@ -324,8 +323,7 @@ export function Header() {
<SearchDropdown
visible={
isDropdownVisible &&
(searchValue.trim().length > 0 ||
historyItems.length > 0 ||
(historyItems.length > 0 ||
suggestions.length > 0 ||
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 "./star-rating/star-rating";
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 {
visible: boolean;
title: React.ReactNode;
title: string;
description?: string;
onClose: () => void;
large?: boolean;
@@ -115,6 +115,7 @@ export function Modal({
"modal--large": large,
})}
role="dialog"
aria-labelledby={title}
aria-describedby={description}
ref={modalContentRef}
data-hydra-dialog

View File

@@ -5,7 +5,7 @@
background-color: globals.$dark-background-color;
border: 1px solid globals.$border-color;
border-radius: 8px;
max-height: 350px;
max-height: 300px;
overflow-y: auto;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
z-index: 1000;

View File

@@ -1,4 +1,4 @@
import { useEffect, useRef, useState } from "react";
import { useEffect, useRef, useCallback, useState } from "react";
import { createPortal } from "react-dom";
import { ClockIcon, SearchIcon, XIcon } from "@primer/octicons-react";
import cn from "classnames";
@@ -92,8 +92,23 @@ export function SearchDropdown({
return () => document.removeEventListener("mousedown", handleClickOutside);
}, [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;
const totalItems = historyItems.length + suggestions.length;
const hasHistory = historyItems.length > 0;
const hasSuggestions = suggestions.length > 0;
@@ -143,7 +158,7 @@ export function SearchDropdown({
activeIndex === getItemIndex("history", index),
})}
onMouseDown={(e) => e.preventDefault()}
onClick={() => onSelectHistory(item.query)}
onClick={() => handleItemClick("history", item)}
>
<ClockIcon size={16} className="search-dropdown__item-icon" />
<span className="search-dropdown__item-text">
@@ -185,7 +200,7 @@ export function SearchDropdown({
activeIndex === getItemIndex("suggestion", index),
})}
onMouseDown={(e) => e.preventDefault()}
onClick={() => onSelectSuggestion(item)}
onClick={() => handleItemClick("suggestion", item)}
>
{item.iconUrl ? (
<img
@@ -212,6 +227,13 @@ export function SearchDropdown({
{isLoadingSuggestions && !hasSuggestions && !hasHistory && (
<div className="search-dropdown__loading">{t("loading")}</div>
)}
{!isLoadingSuggestions &&
!hasHistory &&
!hasSuggestions &&
totalItems === 0 && (
<div className="search-dropdown__empty">{t("no_results")}</div>
)}
</div>
);

View File

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

View File

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

View File

@@ -1,13 +1,12 @@
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 { useCallback, useEffect, useMemo, useState } from "react";
import { useMemo } from "react";
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 { Avatar } from "../avatar/avatar";
import { AuthPage } from "@shared";
import { logger } from "@renderer/logger";
import type { NotificationCountResponse } from "@types";
import "./sidebar-profile.scss";
export function SidebarProfile() {
@@ -15,75 +14,11 @@ export function SidebarProfile() {
const { t } = useTranslation("sidebar");
const { userDetails } = useUserDetails();
const { userDetails, friendRequestCount, showFriendsModal } =
useUserDetails();
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 = () => {
if (userDetails === null) {
window.electron.openAuthWindow(AuthPage.SignIn);
@@ -93,24 +28,28 @@ export function SidebarProfile() {
navigate(`/profile/${userDetails.id}`);
};
const notificationsButton = useMemo(() => {
const friendsButton = useMemo(() => {
if (!userDetails) return null;
return (
<button
type="button"
className="sidebar-profile__notification-button"
onClick={() => navigate("/notifications")}
title={t("notifications")}
className="sidebar-profile__friends-button"
onClick={() =>
showFriendsModal(UserFriendModalTab.AddFriend, userDetails.id)
}
title={t("friends")}
>
{notificationCount > 0 && (
<small className="sidebar-profile__notification-button-badge">
{notificationCount > 99 ? "99+" : notificationCount}
{friendRequestCount > 0 && (
<small className="sidebar-profile__friends-button-badge">
{friendRequestCount > 99 ? "99+" : friendRequestCount}
</small>
)}
<BellIcon size={16} />
<PeopleIcon size={16} />
</button>
);
}, [t, notificationCount, navigate]);
}, [userDetails, t, friendRequestCount, showFriendsModal]);
const gameRunningDetails = () => {
if (!userDetails || !gameRunning) return null;
@@ -159,7 +98,7 @@ export function SidebarProfile() {
</div>
</button>
{notificationsButton}
{friendsButton}
</div>
);
}

View File

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

View File

@@ -12,6 +12,7 @@ import {
} from "@renderer/hooks";
import type {
Download,
DownloadSource,
GameRepack,
GameShop,
@@ -95,9 +96,19 @@ export function GameDetailsContextProvider({
);
const updateGame = useCallback(async () => {
return window.electron
.getGameByObjectId(shop, objectId)
.then((result) => setGame(result));
const gameKey = `${shop}:${objectId}`;
const [game, download] = await Promise.all([
levelDBService.get(gameKey, "games") as Promise<LibraryGame | null>,
levelDBService.get(gameKey, "downloads") as Promise<Download | null>,
]);
if (!game || game.isDeleted) {
setGame(null);
return;
}
const { id: _id, ...gameWithoutId } = game;
setGame({ id: gameKey, ...gameWithoutId, download: download ?? null });
}, [shop, objectId]);
const isGameDownloading =

View File

@@ -14,15 +14,11 @@ import type {
GameStats,
UserDetails,
FriendRequestSync,
NotificationSync,
GameArtifact,
LudusaviBackup,
UserAchievement,
ComparedAchievements,
LibraryGame,
GameRunning,
TorBoxUser,
Theme,
Auth,
ShortcutLocation,
ShopAssets,
@@ -32,7 +28,6 @@ import type {
Game,
DiskUsage,
DownloadSource,
LocalNotification,
} from "@types";
import type { AxiosProgressEvent } from "axios";
@@ -144,10 +139,6 @@ declare global {
shop: GameShop,
objectId: string
) => Promise<void>;
clearNewDownloadOptions: (
shop: GameShop,
objectId: string
) => Promise<void>;
toggleGamePin: (
shop: GameShop,
objectId: string,
@@ -164,7 +155,6 @@ declare global {
winePrefixPath: string | null
) => Promise<void>;
verifyExecutablePathInUse: (executablePath: string) => Promise<Game>;
getLibrary: () => Promise<LibraryGame[]>;
refreshLibraryAssets: () => Promise<void>;
openGameInstaller: (shop: GameShop, objectId: string) => Promise<boolean>;
openGameInstallerPath: (shop: GameShop, objectId: string) => Promise<void>;
@@ -179,10 +169,6 @@ declare global {
removeGameFromLibrary: (shop: GameShop, objectId: string) => Promise<void>;
removeGame: (shop: GameShop, objectId: string) => Promise<void>;
deleteGameFolder: (shop: GameShop, objectId: string) => Promise<unknown>;
getGameByObjectId: (
shop: GameShop,
objectId: string
) => Promise<LibraryGame | null>;
onGamesRunning: (
cb: (
gamesRunning: Pick<GameRunning, "id" | "sessionDurationInMillis">[]
@@ -196,9 +182,9 @@ declare global {
playtimeInSeconds: number
) => Promise<void>;
/* User preferences */
getUserPreferences: () => Promise<UserPreferences | null>;
authenticateRealDebrid: (apiToken: string) => Promise<RealDebridUser>;
authenticateTorBox: (apiToken: string) => Promise<TorBoxUser>;
getUserPreferences: () => Promise<UserPreferences | null>;
updateUserPreferences: (
preferences: Partial<UserPreferences>
) => Promise<void>;
@@ -210,13 +196,6 @@ declare global {
onExtractionComplete: (
cb: (shop: GameShop, objectId: string) => void
) => () => 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>;
createSteamShortcut: (shop: GameShop, objectId: string) => Promise<void>;
@@ -226,10 +205,7 @@ declare global {
removeAll = false,
downloadSourceId?: string
) => Promise<void>;
getDownloadSources: () => Promise<DownloadSource[]>;
syncDownloadSources: () => Promise<void>;
getDownloadSourcesCheckBaseline: () => Promise<string | null>;
getDownloadSourcesSinceValue: () => Promise<string | null>;
/* Hardware */
getDiskFreeSpace: (path: string) => Promise<DiskUsage>;
@@ -246,10 +222,6 @@ declare global {
shop: GameShop,
gameArtifactId: string
) => Promise<void>;
getGameArtifacts: (
objectId: string,
shop: GameShop
) => Promise<GameArtifact[]>;
getGameBackupPreview: (
objectId: string,
shop: GameShop
@@ -364,7 +336,6 @@ declare global {
getAuth: () => Promise<Auth | null>;
signOut: () => Promise<void>;
openAuthWindow: (page: AuthPage) => Promise<void>;
getSessionHash: () => Promise<string | null>;
onSignIn: (cb: () => void) => () => Electron.IpcRenderer;
onAccountUpdated: (cb: () => void) => () => Electron.IpcRenderer;
onSignOut: (cb: () => void) => () => Electron.IpcRenderer;
@@ -389,12 +360,10 @@ declare global {
processProfileImage: (
path: string
) => Promise<{ imagePath: string; mimeType: string }>;
syncFriendRequests: () => Promise<void>;
onSyncFriendRequests: (
cb: (friendRequests: FriendRequestSync) => void
) => () => Electron.IpcRenderer;
onSyncNotificationCount: (
cb: (notification: NotificationSync) => void
) => () => Electron.IpcRenderer;
updateFriendRequest: (
userId: string,
action: FriendRequestAction
@@ -402,15 +371,6 @@ declare global {
/* Notifications */
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: (
cb: (
position?: AchievementCustomNotificationPosition,
@@ -428,13 +388,7 @@ declare global {
showAchievementTestNotification: () => Promise<void>;
/* Themes */
addCustomTheme: (theme: Theme) => Promise<void>;
getAllCustomThemes: () => Promise<Theme[]>;
deleteAllCustomThemes: () => Promise<void>;
deleteCustomTheme: (themeId: string) => Promise<void>;
updateCustomTheme: (themeId: string, code: string) => Promise<void>;
getCustomThemeById: (themeId: string) => Promise<Theme | null>;
getActiveCustomTheme: () => Promise<Theme | null>;
toggleCustomTheme: (themeId: string, isActive: boolean) => Promise<void>;
copyThemeAchievementSound: (
themeId: string,

View File

@@ -1,28 +1,17 @@
import { createSlice } from "@reduxjs/toolkit";
import type { PayloadAction } from "@reduxjs/toolkit";
import type { DownloadProgress, GameShop } from "@types";
export interface ExtractionInfo {
visibleId: string;
progress: number;
}
import type { DownloadProgress } from "@types";
export interface DownloadState {
lastPacket: DownloadProgress | null;
gameId: string | null;
gamesWithDeletionInProgress: string[];
extraction: ExtractionInfo | null;
peakSpeeds: Record<string, number>;
speedHistory: Record<string, number[]>;
}
const initialState: DownloadState = {
lastPacket: null,
gameId: null,
gamesWithDeletionInProgress: [],
extraction: null,
peakSpeeds: {},
speedHistory: {},
};
export const downloadSlice = createSlice({
@@ -32,27 +21,6 @@ export const downloadSlice = createSlice({
setLastPacket: (state, action: PayloadAction<DownloadProgress | null>) => {
state.lastPacket = action.payload;
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) => {
state.lastPacket = null;
@@ -70,37 +38,6 @@ export const downloadSlice = createSlice({
const index = state.gamesWithDeletionInProgress.indexOf(action.payload);
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,
setGameDeleting,
removeGameFromDeleting,
setExtractionProgress,
clearExtraction,
updatePeakSpeed,
clearPeakSpeed,
} = downloadSlice.actions;

View File

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

View File

@@ -1,15 +1,65 @@
import { useCallback } from "react";
import { useAppDispatch, useAppSelector } from "./redux";
import { setLibrary } from "@renderer/features";
import { levelDBService } from "@renderer/services/leveldb.service";
import type {
LibraryGame,
Game,
Download,
ShopAssets,
GameAchievement,
} from "@types";
export function useLibrary() {
const dispatch = useAppDispatch();
const library = useAppSelector((state) => state.library.value);
const updateLibrary = useCallback(async () => {
return window.electron
.getLibrary()
.then((updatedLibrary) => dispatch(setLibrary(updatedLibrary)));
const results = await levelDBService.iterator("games");
const libraryGames = await Promise.all(
results
.filter(([_key, game]) => (game as Game).isDeleted === false)
.map(async ([key, game]) => {
const gameData = game as Game;
const download = (await levelDBService.get(
key,
"downloads"
)) as Download | null;
const gameAssets = (await levelDBService.get(
key,
"gameShopAssets"
)) as (ShopAssets & { updatedAt: number }) | null;
let unlockedAchievementCount = gameData.unlockedAchievementCount ?? 0;
if (!gameData.unlockedAchievementCount) {
const achievements = (await levelDBService.get(
key,
"gameAchievements"
)) as GameAchievement | null;
unlockedAchievementCount =
achievements?.unlockedAchievements.length ?? 0;
}
return {
id: key,
...gameData,
download: download ?? null,
unlockedAchievementCount,
achievementCount: gameData.achievementCount ?? 0,
// Spread gameAssets last to ensure all image URLs are properly set
...gameAssets,
// Preserve custom image URLs from game if they exist
customIconUrl: gameData.customIconUrl,
customLogoImageUrl: gameData.customLogoImageUrl,
customHeroImageUrl: gameData.customHeroImageUrl,
} as LibraryGame;
})
);
dispatch(setLibrary(libraryGames));
}, [dispatch]);
return { library, updateLibrary };

View File

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

View File

@@ -31,7 +31,6 @@ import Profile from "./pages/profile/profile";
import Achievements from "./pages/achievements/achievements";
import ThemeEditor from "./pages/theme-editor/theme-editor";
import Library from "./pages/library/library";
import Notifications from "./pages/notifications/notifications";
import { AchievementNotification } from "./pages/achievements/notification/achievement-notification";
console.log = logger.log;
@@ -77,7 +76,6 @@ ReactDOM.createRoot(document.getElementById("root")!).render(
<Route path="/settings" element={<Settings />} />
<Route path="/profile/:userId" element={<Profile />} />
<Route path="/achievements" element={<Achievements />} />
<Route path="/notifications" element={<Notifications />} />
</Route>
<Route path="/theme-editor" element={<ThemeEditor />} />

View File

@@ -1,44 +0,0 @@
import { useTranslation } from "react-i18next";
import { ConfirmationModal } from "@renderer/components";
interface ArchiveDeletionModalProps {
visible: boolean;
archivePaths: string[];
onClose: () => void;
}
export function ArchiveDeletionModal({
visible,
archivePaths,
onClose,
}: Readonly<ArchiveDeletionModalProps>) {
const { t } = useTranslation("downloads");
const fullFileName =
archivePaths.length > 0 ? (archivePaths[0].split(/[/\\]/).pop() ?? "") : "";
const maxLength = 40;
const fileName =
fullFileName.length > maxLength
? `${fullFileName.slice(0, maxLength)}`
: fullFileName;
const handleConfirm = async () => {
for (const archivePath of archivePaths) {
await window.electron.deleteArchive(archivePath);
}
onClose();
};
return (
<ConfirmationModal
visible={visible}
title={t("delete_archive_title", { fileName })}
descriptionText={t("delete_archive_description")}
confirmButtonLabel={t("yes")}
cancelButtonLabel={t("no")}
onConfirm={handleConfirm}
onClose={onClose}
/>
);
}

View File

@@ -108,11 +108,16 @@
cursor: pointer;
display: flex;
align-items: center;
transition: scale 0.2s ease;
transition: opacity 0.2s ease;
outline: none;
&:hover {
scale: 1.05;
opacity: 0.8;
}
&:focus,
&:focus-visible {
outline: none;
}
}
@@ -390,21 +395,6 @@
flex-shrink: 0;
background-color: rgba(0, 0, 0, 0.3);
border: 1px solid globals.$border-color;
padding: 0;
cursor: pointer;
transition:
opacity 0.2s ease,
transform 0.2s ease;
&:hover {
opacity: 0.9;
}
&:focus,
&:focus-visible {
outline: 2px solid rgba(255, 255, 255, 0.5);
outline-offset: 2px;
}
img {
width: 100%;
@@ -421,21 +411,6 @@
gap: calc(globals.$spacing-unit / 1);
}
&__simple-title-button {
background: none;
border: none;
padding: 0;
cursor: pointer;
text-align: left;
width: 100%;
transition: opacity 0.2s ease;
&:focus,
&:focus-visible {
outline: none;
}
}
&__simple-title {
font-size: 16px;
font-weight: 600;
@@ -536,9 +511,5 @@
background-color: #fff;
transition: width 0.3s ease;
border-radius: 4px;
&--extraction {
background-color: #fff;
}
}
}

View File

@@ -128,20 +128,16 @@ function SpeedChart({
g = 255,
b = 255;
if (color.startsWith("#")) {
let hex = color.replace("#", "");
// Handle shorthand hex colors (e.g., "#fff" -> "#ffffff")
if (hex.length === 3) {
hex = hex[0] + hex[0] + hex[1] + hex[1] + hex[2] + hex[2];
}
r = Number.parseInt(hex.substring(0, 2), 16) || 255;
g = Number.parseInt(hex.substring(2, 4), 16) || 255;
b = Number.parseInt(hex.substring(4, 6), 16) || 255;
const hex = color.replace("#", "");
r = Number.parseInt(hex.substring(0, 2), 16);
g = Number.parseInt(hex.substring(2, 4), 16);
b = Number.parseInt(hex.substring(4, 6), 16);
} else if (color.startsWith("rgb")) {
const matches = color.match(/\d+/g);
if (matches && matches.length >= 3) {
r = Number.parseInt(matches[0]) || 255;
g = Number.parseInt(matches[1]) || 255;
b = Number.parseInt(matches[2]) || 255;
r = Number.parseInt(matches[0]);
g = Number.parseInt(matches[1]);
b = Number.parseInt(matches[2]);
}
}
const displaySpeeds = speeds.slice(-totalBars);
@@ -207,7 +203,6 @@ function SpeedChart({
interface HeroDownloadViewProps {
game: LibraryGame;
isGameDownloading: boolean;
isGameExtracting?: boolean;
downloadSpeed: number;
finalDownloadSize: string;
peakSpeed: number;
@@ -226,7 +221,6 @@ interface HeroDownloadViewProps {
function HeroDownloadView({
game,
isGameDownloading,
isGameExtracting = false,
downloadSpeed,
finalDownloadSize,
peakSpeed,
@@ -284,17 +278,11 @@ function HeroDownloadView({
<div className="download-group__progress-row download-group__progress-row--bar">
<div className="download-group__progress-wrapper">
<div className="download-group__progress-info-row">
{isGameExtracting && (
<span className="download-group__progress-status">
{t("extracting")}
</span>
)}
{!isGameExtracting && lastPacket?.isCheckingFiles && (
{lastPacket?.isCheckingFiles ? (
<span className="download-group__progress-status">
{t("checking_files")}
</span>
)}
{!isGameExtracting && !lastPacket?.isCheckingFiles && (
) : (
<span className="download-group__progress-size">
<DownloadIcon size={14} />
{isGameDownloading && lastPacket
@@ -305,7 +293,7 @@ function HeroDownloadView({
<span></span>
</div>
<div className="download-group__progress-info-row">
{!lastPacket?.isCheckingFiles && !isGameExtracting && (
{!lastPacket?.isCheckingFiles && (
<span className="download-group__progress-time">
{isGameDownloading &&
lastPacket?.timeRemaining &&
@@ -317,50 +305,50 @@ function HeroDownloadView({
)}
</span>
)}
<span className="download-group__progress-percentage">
<AnimatedPercentage value={currentProgress} />
</span>
{(!lastPacket?.isCheckingFiles || currentProgress > 0) && (
<span className="download-group__progress-percentage">
<AnimatedPercentage value={currentProgress} />
</span>
)}
</div>
<div className="download-group__progress-bar">
<div
className={`download-group__progress-fill ${isGameExtracting ? "download-group__progress-fill--extraction" : ""}`}
className="download-group__progress-fill"
style={{
width: `${currentProgress * 100}%`,
}}
/>
</div>
</div>
{!isGameExtracting && (
<div className="download-group__hero-buttons">
{isGameDownloading ? (
<button
type="button"
onClick={() => pauseDownload(game.shop, game.objectId)}
className="download-group__glass-btn"
>
<ColumnsIcon size={14} />
{t("pause")}
</button>
) : (
<button
type="button"
onClick={() => resumeDownload(game.shop, game.objectId)}
className="download-group__glass-btn"
>
<PlayIcon size={14} />
{t("resume")}
</button>
)}
<div className="download-group__hero-buttons">
{isGameDownloading ? (
<button
type="button"
onClick={() => cancelDownload(game.shop, game.objectId)}
onClick={() => pauseDownload(game.shop, game.objectId)}
className="download-group__glass-btn"
>
<XCircleIcon size={14} />
{t("cancel")}
<ColumnsIcon size={14} />
{t("pause")}
</button>
</div>
)}
) : (
<button
type="button"
onClick={() => resumeDownload(game.shop, game.objectId)}
className="download-group__glass-btn"
>
<PlayIcon size={14} />
{t("resume")}
</button>
)}
<button
type="button"
onClick={() => cancelDownload(game.shop, game.objectId)}
className="download-group__glass-btn"
>
<XCircleIcon size={14} />
{t("cancel")}
</button>
</div>
</div>
</div>
@@ -372,7 +360,7 @@ function HeroDownloadView({
</span>
<div className="download-group__stat-content">
<span className="download-group__stat-label">
{t("network")}:
{t("network")}
</span>
<span className="download-group__stat-value">
{isGameDownloading ? formatSpeed(downloadSpeed) : "0 B/s"}
@@ -385,39 +373,38 @@ function HeroDownloadView({
<GraphIcon size={16} />
</span>
<div className="download-group__stat-content">
<span className="download-group__stat-label">{t("peak")}:</span>
<span className="download-group__stat-label">{t("peak")}</span>
<span className="download-group__stat-value">
{peakSpeed > 0 ? formatSpeed(peakSpeed) : "0 B/s"}
</span>
</div>
</div>
{game.download?.downloader === Downloader.Torrent &&
isGameDownloading &&
lastPacket &&
(lastPacket.numSeeds > 0 || lastPacket.numPeers > 0) && (
<div className="download-group__stat-item">
<div className="download-group__stat-content">
<span className="download-group__stat-label">
Seeds:{" "}
<span className="download-group__stat-value">
{lastPacket.numSeeds}
</span>
, Peers:{" "}
<span className="download-group__stat-value">
{lastPacket.numPeers}
</span>
</span>
</div>
</div>
)}
{game.download?.downloader !== undefined && (
{game.download?.downloader && (
<div className="download-group__stat-item">
<div className="download-group__stat-content">
<Badge>
{DOWNLOADER_NAME[Number(game.download.downloader)]}
</Badge>
<div
className="download-group__stat-content"
style={{
justifyContent: "space-between",
alignItems: "center",
}}
>
<Badge>{DOWNLOADER_NAME[game.download.downloader]}</Badge>
{game.download?.downloader === Downloader.Torrent &&
isGameDownloading &&
lastPacket &&
(lastPacket.numSeeds > 0 || lastPacket.numPeers > 0) && (
<span className="download-group__stat-label">
{t("seeds")}{" "}
<span className="download-group__stat-value">
{lastPacket.numSeeds}
</span>
, {t("peers")}{" "}
<span className="download-group__stat-value">
{lastPacket.numPeers}
</span>
</span>
)}
</div>
</div>
)}
@@ -458,8 +445,6 @@ export function DownloadGroup({
(state) => state.userPreferences.value
);
const extraction = useAppSelector((state) => state.download.extraction);
const { updateLibrary } = useLibrary();
const {
@@ -514,9 +499,8 @@ export function DownloadGroup({
const { formatDistance } = useDate();
// Get speed history and peak speeds from Redux (centralized state)
const speedHistory = useAppSelector((state) => state.download.speedHistory);
const peakSpeeds = useAppSelector((state) => state.download.peakSpeeds);
const [peakSpeeds, setPeakSpeeds] = useState<Record<string, number>>({});
const speedHistoryRef = useRef<Record<string, number[]>>({});
const [dominantColors, setDominantColors] = useState<Record<string, string>>(
{}
);
@@ -579,8 +563,68 @@ export function DownloadGroup({
});
}, [library, lastPacket?.gameId]);
// Speed history and peak speeds are now tracked in Redux (in setLastPacket reducer)
// No local effect needed - data is updated atomically when packets arrive
useEffect(() => {
if (lastPacket?.gameId && lastPacket.downloadSpeed !== undefined) {
const gameId = lastPacket.gameId;
const currentPeak = peakSpeeds[gameId] || 0;
if (lastPacket.downloadSpeed > currentPeak) {
setPeakSpeeds((prev) => ({
...prev,
[gameId]: lastPacket.downloadSpeed,
}));
}
if (!speedHistoryRef.current[gameId]) {
speedHistoryRef.current[gameId] = [];
}
speedHistoryRef.current[gameId].push(lastPacket.downloadSpeed);
if (speedHistoryRef.current[gameId].length > 120) {
speedHistoryRef.current[gameId].shift();
}
}
}, [lastPacket?.gameId, lastPacket?.downloadSpeed, peakSpeeds]);
useEffect(() => {
for (const game of library) {
if (
game.download &&
game.download.progress < 0.01 &&
game.download.status !== "paused"
) {
// Fresh download - clear any old data
if (speedHistoryRef.current[game.id]?.length > 0) {
speedHistoryRef.current[game.id] = [];
setPeakSpeeds((prev) => ({ ...prev, [game.id]: 0 }));
}
}
}
}, [library]);
useEffect(() => {
const timeouts: NodeJS.Timeout[] = [];
for (const game of library) {
if (
game.download?.progress === 1 &&
speedHistoryRef.current[game.id]?.length > 0
) {
const timeout = setTimeout(() => {
speedHistoryRef.current[game.id] = [];
setPeakSpeeds((prev) => ({ ...prev, [game.id]: 0 }));
}, 10_000);
timeouts.push(timeout);
}
}
return () => {
for (const timeout of timeouts) {
clearTimeout(timeout);
}
};
}, [library]);
useEffect(() => {
if (library.length > 0 && title === t("download_in_progress")) {
@@ -778,28 +822,16 @@ export function DownloadGroup({
if (isDownloadingGroup && library.length > 0) {
const game = library[0];
const isGameExtracting = extraction?.visibleId === game.id;
const isGameDownloading =
isGameDownloadingMap[game.id] && !isGameExtracting;
const isGameDownloading = isGameDownloadingMap[game.id];
const downloadSpeed = isGameDownloading
? (lastPacket?.downloadSpeed ?? 0)
: 0;
const finalDownloadSize = getFinalDownloadSize(game);
// Use lastPacket.gameId for lookup since that's the key used to store the data
// Fall back to game.id if lastPacket is not available
const dataKey = lastPacket?.gameId ?? game.id;
const gameSpeedHistory = speedHistory[dataKey] ?? [];
const storedPeak = peakSpeeds[dataKey];
// Use stored peak if available and > 0, otherwise use current speed as initial value
const peakSpeed =
storedPeak !== undefined && storedPeak > 0 ? storedPeak : downloadSpeed;
let currentProgress = game.download?.progress || 0;
if (isGameExtracting) {
currentProgress = extraction.progress;
} else if (isGameDownloading && lastPacket) {
currentProgress = lastPacket.progress;
}
const peakSpeed = peakSpeeds[game.id] || 0;
const currentProgress =
isGameDownloading && lastPacket
? lastPacket.progress
: game.download?.progress || 0;
const dominantColor = dominantColors[game.id] || "#fff";
@@ -807,14 +839,13 @@ export function DownloadGroup({
<HeroDownloadView
game={game}
isGameDownloading={isGameDownloading}
isGameExtracting={isGameExtracting}
downloadSpeed={downloadSpeed}
finalDownloadSize={finalDownloadSize}
peakSpeed={peakSpeed}
currentProgress={currentProgress}
dominantColor={dominantColor}
lastPacket={lastPacket}
speedHistory={gameSpeedHistory}
speedHistory={speedHistoryRef.current[game.id] || []}
formatSpeed={formatSpeed}
calculateETA={calculateETA}
pauseDownload={pauseDownload}
@@ -842,8 +873,14 @@ export function DownloadGroup({
<li key={game.id} className="download-group__simple-card">
<button
type="button"
onClick={() => navigate(buildGameDetailsPath(game))}
className="download-group__simple-thumbnail"
onClick={() => navigate(buildGameDetailsPath(game))}
style={{
background: "none",
border: "none",
padding: 0,
cursor: "pointer",
}}
>
<img src={game.libraryImageUrl || ""} alt={game.title} />
</button>
@@ -851,22 +888,27 @@ export function DownloadGroup({
<div className="download-group__simple-info">
<button
type="button"
className="download-group__simple-title"
onClick={() => navigate(buildGameDetailsPath(game))}
className="download-group__simple-title-button"
style={{
background: "none",
border: "none",
padding: 0,
cursor: "pointer",
textAlign: "left",
width: "100%",
}}
>
<h3 className="download-group__simple-title">{game.title}</h3>
{game.title}
</button>
<div className="download-group__simple-meta">
<div className="download-group__simple-meta-row">
<Badge>
{DOWNLOADER_NAME[Number(game.download!.downloader)]}
</Badge>
<Badge>{DOWNLOADER_NAME[game.download!.downloader]}</Badge>
</div>
<div className="download-group__simple-meta-row">
{extraction?.visibleId === game.id ? (
{game.download?.extracting ? (
<span className="download-group__simple-extracting">
{t("extracting")} (
{Math.round(extraction.progress * 100)}%)
{t("extracting")}
</span>
) : (
<span className="download-group__simple-size">

View File

@@ -1,6 +1,6 @@
import { useTranslation } from "react-i18next";
import { useAppSelector, useDownload, useLibrary } from "@renderer/hooks";
import { useDownload, useLibrary } from "@renderer/hooks";
import { useEffect, useMemo, useRef, useState } from "react";
import { BinaryNotFoundModal } from "../shared-modals/binary-not-found-modal";
@@ -13,7 +13,6 @@ import { ArrowDownIcon } from "@primer/octicons-react";
export default function Downloads() {
const { library, updateLibrary } = useLibrary();
const extraction = useAppSelector((state) => state.download.extraction);
const { t } = useTranslation("downloads");
@@ -40,13 +39,11 @@ export default function Downloads() {
useEffect(() => {
window.electron.onSeedingStatus((value) => setSeedingStatus(value));
const unsubscribeExtraction = window.electron.onExtractionComplete(() => {
const unsubscribe = window.electron.onExtractionComplete(() => {
updateLibrary();
});
return () => {
unsubscribeExtraction();
};
return () => unsubscribe();
}, [updateLibrary]);
const handleOpenGameInstaller = (shop: GameShop, objectId: string) =>
@@ -75,10 +72,8 @@ export default function Downloads() {
/* Game has been manually added to the library */
if (!next.download) return prev;
/* Is downloading or extracting */
const isExtracting =
next.download.extracting || extraction?.visibleId === next.id;
if (lastPacket?.gameId === next.id || isExtracting)
/* Is downloading */
if (lastPacket?.gameId === next.id || next.download.extracting)
return { ...prev, downloading: [...prev.downloading, next] };
/* Is either queued or paused */
@@ -101,7 +96,7 @@ export default function Downloads() {
queued,
complete,
};
}, [library, lastPacket?.gameId, extraction?.visibleId]);
}, [library, lastPacket?.gameId]);
const downloadGroups = [
{

View File

@@ -1,7 +1,6 @@
import { useContext, useEffect, useMemo, useRef, useState } from "react";
import { useContext, useEffect, useMemo, useState } from "react";
import { PencilIcon } from "@primer/octicons-react";
import { useTranslation } from "react-i18next";
import { useSearchParams } from "react-router-dom";
import { HeroPanel } from "./hero";
import { DescriptionHeader } from "./description-header/description-header";
@@ -56,8 +55,6 @@ const getImageWithCustomPriority = (
export function GameDetailsContent() {
const { t } = useTranslation("game_details");
const [searchParams] = useSearchParams();
const reviewsRef = useRef<HTMLDivElement>(null);
const {
objectId,
@@ -140,16 +137,6 @@ export function GameDetailsContent() {
getGameArtifacts();
}, [getGameArtifacts]);
// Scroll to reviews section if reviews=true in URL
useEffect(() => {
const shouldScrollToReviews = searchParams.get("reviews") === "true";
if (shouldScrollToReviews && reviewsRef.current) {
setTimeout(() => {
reviewsRef.current?.scrollIntoView({ behavior: "smooth" });
}, 500);
}
}, [searchParams, objectId]);
const isCustomGame = game?.shop === "custom";
const heroImage = isCustomGame
@@ -242,17 +229,15 @@ export function GameDetailsContent() {
)}
{shop !== "custom" && shop && objectId && (
<div ref={reviewsRef}>
<GameReviews
shop={shop}
objectId={objectId}
game={game}
userDetailsId={userDetails?.id}
isGameInLibrary={isGameInLibrary}
hasUserReviewed={hasUserReviewed}
onUserReviewedChange={setHasUserReviewed}
/>
</div>
<GameReviews
shop={shop}
objectId={objectId}
game={game}
userDetailsId={userDetails?.id}
isGameInLibrary={isGameInLibrary}
hasUserReviewed={hasUserReviewed}
onUserReviewedChange={setHasUserReviewed}
/>
)}
</div>

View File

@@ -1,12 +1,7 @@
import { useContext, useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { formatDownloadProgress } from "@renderer/helpers";
import {
useAppSelector,
useDate,
useDownload,
useFormat,
} from "@renderer/hooks";
import { useDate, useDownload, useFormat } from "@renderer/hooks";
import { Link } from "@renderer/components";
import { gameDetailsContext } from "@renderer/context";
import { MAX_MINUTES_TO_SHOW_IN_PLAYTIME } from "@renderer/constants";
@@ -22,9 +17,6 @@ export function HeroPanelPlaytime() {
const { numberFormatter } = useFormat();
const { progress, lastPacket } = useDownload();
const { formatDistance } = useDate();
const extraction = useAppSelector((state) => state.download.extraction);
const isExtracting = extraction?.visibleId === game?.id;
useEffect(() => {
if (game?.lastTimePlayed) {
@@ -60,16 +52,6 @@ export function HeroPanelPlaytime() {
const isGameDownloading =
game.download?.status === "active" && lastPacket?.gameId === game.id;
const extractionInProgressInfo = (
<div className="hero-panel-playtime__download-details">
<Link to="/downloads" className="hero-panel-playtime__downloads-link">
{t("extracting")}
</Link>
<small>{formatDownloadProgress(extraction?.progress ?? 0)}</small>
</div>
);
const downloadInProgressInfo = (
<div className="hero-panel-playtime__download-details">
<Link to="/downloads" className="hero-panel-playtime__downloads-link">
@@ -90,8 +72,7 @@ export function HeroPanelPlaytime() {
return (
<>
<p>{t("not_played_yet", { title: game?.title })}</p>
{isExtracting && extractionInProgressInfo}
{!isExtracting && hasDownload && downloadInProgressInfo}
{hasDownload && downloadInProgressInfo}
</>
);
}
@@ -100,8 +81,7 @@ export function HeroPanelPlaytime() {
return (
<>
<p>{t("playing_now")}</p>
{isExtracting && extractionInProgressInfo}
{!isExtracting && hasDownload && downloadInProgressInfo}
{hasDownload && downloadInProgressInfo}
</>
);
}
@@ -133,9 +113,9 @@ export function HeroPanelPlaytime() {
})}
</p>
{isExtracting && extractionInProgressInfo}
{!isExtracting && hasDownload && downloadInProgressInfo}
{!isExtracting && !hasDownload && (
{hasDownload ? (
downloadInProgressInfo
) : (
<p>
{t("last_time_played", {
period: lastTimePlayed,

View File

@@ -80,11 +80,5 @@
&--disabled {
opacity: globals.$disabled-opacity;
}
&--extraction {
&::-webkit-progress-value {
background-color: #fff;
}
}
}
}

View File

@@ -1,7 +1,7 @@
import { useContext } from "react";
import { useTranslation } from "react-i18next";
import { useAppSelector, useDate, useDownload } from "@renderer/hooks";
import { useDate, useDownload } from "@renderer/hooks";
import { HeroPanelActions } from "./hero-panel-actions";
import { HeroPanelPlaytime } from "./hero-panel-playtime";
@@ -18,13 +18,9 @@ export function HeroPanel() {
const { lastPacket } = useDownload();
const extraction = useAppSelector((state) => state.download.extraction);
const isGameDownloading =
game?.download?.status === "active" && lastPacket?.gameId === game?.id;
const isExtracting = extraction?.visibleId === game?.id;
const getInfo = () => {
if (!game) {
const [latestRepack] = repacks;
@@ -53,8 +49,6 @@ export function HeroPanel() {
(game?.download?.status === "active" && game?.download?.progress < 1) ||
game?.download?.status === "paused";
const showExtractionProgressBar = isExtracting;
return (
<div className="hero-panel__container">
<div className="hero-panel">
@@ -78,14 +72,6 @@ export function HeroPanel() {
}`}
/>
)}
{showExtractionProgressBar && (
<progress
max={1}
value={extraction?.progress ?? 0}
className="hero-panel__progress-bar hero-panel__progress-bar--extraction"
/>
)}
</div>
</div>
);

View File

@@ -21,12 +21,7 @@ import { DownloadSettingsModal } from "./download-settings-modal";
import { gameDetailsContext } from "@renderer/context";
import { Downloader } from "@shared";
import { orderBy } from "lodash-es";
import {
useDate,
useFeature,
useAppDispatch,
useAppSelector,
} from "@renderer/hooks";
import { useDate, useFeature, useAppDispatch } from "@renderer/hooks";
import { clearNewDownloadOptions } from "@renderer/features";
import { levelDBService } from "@renderer/services/leveldb.service";
import { getGameKey } from "@renderer/helpers";
@@ -75,9 +70,6 @@ export function RepacksModal({
const { formatDate } = useDate();
const navigate = useNavigate();
const dispatch = useAppDispatch();
const userPreferences = useAppSelector(
(state) => state.userPreferences.value
);
const getHashFromMagnet = (magnet: string) => {
if (!magnet || typeof magnet !== "string") {
@@ -137,12 +129,10 @@ export function RepacksModal({
}
};
if (visible && userPreferences?.enableNewDownloadOptionsBadges !== false) {
if (visible) {
fetchLastCheckTimestamp();
} else {
setIsLoadingTimestamp(false);
}
}, [visible, repacks, userPreferences?.enableNewDownloadOptionsBadges]);
}, [visible, repacks]);
useEffect(() => {
if (
@@ -373,13 +363,11 @@ export function RepacksModal({
>
<p className="repacks-modal__repack-title">
{repack.title}
{userPreferences?.enableNewDownloadOptionsBadges !==
false &&
isNewRepack(repack) && (
<span className="repacks-modal__new-badge">
{t("new_download_option")}
</span>
)}
{isNewRepack(repack) && (
<span className="repacks-modal__new-badge">
{t("new_download_option")}
</span>
)}
</p>
{isLastDownloadedOption && (

View File

@@ -1,103 +0,0 @@
import { useCallback } from "react";
import {
XIcon,
DownloadIcon,
PackageIcon,
SyncIcon,
TrophyIcon,
ClockIcon,
} from "@primer/octicons-react";
import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom";
import { useDate } from "@renderer/hooks";
import cn from "classnames";
import type { LocalNotification } from "@types";
import "./notification-item.scss";
interface LocalNotificationItemProps {
notification: LocalNotification;
onDismiss: (id: string) => void;
onMarkAsRead: (id: string) => void;
}
export function LocalNotificationItem({
notification,
onDismiss,
onMarkAsRead,
}: Readonly<LocalNotificationItemProps>) {
const { t } = useTranslation("notifications_page");
const { formatDistance } = useDate();
const navigate = useNavigate();
const handleClick = useCallback(() => {
if (!notification.isRead) {
onMarkAsRead(notification.id);
}
if (notification.url) {
navigate(notification.url);
}
}, [notification, onMarkAsRead, navigate]);
const handleDismiss = useCallback(
(e: React.MouseEvent) => {
e.stopPropagation();
onDismiss(notification.id);
},
[notification.id, onDismiss]
);
const getIcon = () => {
switch (notification.type) {
case "DOWNLOAD_COMPLETE":
return <DownloadIcon size={24} />;
case "EXTRACTION_COMPLETE":
return <PackageIcon size={24} />;
case "UPDATE_AVAILABLE":
return <SyncIcon size={24} />;
case "ACHIEVEMENT_UNLOCKED":
return <TrophyIcon size={24} />;
default:
return <DownloadIcon size={24} />;
}
};
return (
<button
type="button"
className={cn("notification-item", {
"notification-item--unread": !notification.isRead,
})}
onClick={handleClick}
>
<div className="notification-item__picture">
{notification.pictureUrl ? (
<img src={notification.pictureUrl} alt="" />
) : (
getIcon()
)}
</div>
<div className="notification-item__content">
<span className="notification-item__title">{notification.title}</span>
<span className="notification-item__description">
{notification.description}
</span>
<span className="notification-item__time">
<ClockIcon size={12} />
{formatDistance(new Date(notification.createdAt), new Date())}
</span>
</div>
<button
type="button"
className="notification-item__dismiss"
onClick={handleDismiss}
title={t("dismiss")}
>
<XIcon size={16} />
</button>
</button>
);
}

View File

@@ -1,127 +0,0 @@
@use "../../scss/globals.scss";
.notification-item {
display: flex;
align-items: center;
gap: calc(globals.$spacing-unit * 2);
padding: calc(globals.$spacing-unit * 2);
background-color: globals.$dark-background-color;
border: 1px solid globals.$border-color;
border-radius: 8px;
transition: all ease 0.2s;
position: relative;
opacity: 0.4;
width: 100%;
text-align: left;
&:hover {
background-color: rgba(255, 255, 255, 0.03);
opacity: 0.6;
}
&--unread {
border-left: 3px solid globals.$brand-teal;
opacity: 1;
&:hover {
opacity: 1;
}
.notification-item__title {
color: #fff;
}
}
&__picture {
flex-shrink: 0;
width: 48px;
height: 48px;
border-radius: 8px;
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
background-color: globals.$background-color;
color: #fff;
img {
width: 100%;
height: 100%;
object-fit: cover;
}
svg {
color: inherit;
}
}
&__badge-picture {
border-radius: 8px;
background-color: globals.$background-color;
img {
width: 32px;
height: 32px;
object-fit: contain;
}
}
&__review-picture {
color: #f5a623;
}
&__content {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: calc(globals.$spacing-unit / 2);
}
&__title {
font-size: globals.$body-font-size;
font-weight: 600;
color: globals.$muted-color;
}
&__description {
font-size: globals.$small-font-size;
color: globals.$body-color;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
&__time {
display: flex;
align-items: center;
gap: calc(globals.$spacing-unit / 2);
font-size: globals.$small-font-size;
color: rgba(255, 255, 255, 0.5);
}
&__actions {
display: flex;
gap: globals.$spacing-unit;
flex-shrink: 0;
}
&__dismiss {
position: absolute;
top: calc(globals.$spacing-unit / 2);
right: calc(globals.$spacing-unit / 2);
background: transparent;
border: none;
color: globals.$body-color;
cursor: pointer;
padding: calc(globals.$spacing-unit / 2);
border-radius: 50%;
transition: all ease 0.2s;
opacity: 0.5;
&:hover {
opacity: 1;
background-color: rgba(255, 255, 255, 0.1);
}
}
}

View File

@@ -1,228 +0,0 @@
import { useCallback, useMemo } from "react";
import {
XIcon,
PersonIcon,
ClockIcon,
StarFillIcon,
} from "@primer/octicons-react";
import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom";
import { Button } from "@renderer/components";
import { useDate, useUserDetails } from "@renderer/hooks";
import cn from "classnames";
import type { Notification, Badge } from "@types";
import "./notification-item.scss";
const parseNotificationUrl = (notificationUrl: string): string => {
const url = new URL(notificationUrl, "http://localhost");
const userId = url.searchParams.get("userId");
const badgeName = url.searchParams.get("name");
const gameTitle = url.searchParams.get("title");
const showReviews = url.searchParams.get("reviews");
if (url.pathname === "/profile" && userId) {
return `/profile/${userId}`;
}
if (url.pathname === "/badges" && badgeName) {
return `/badges/${badgeName}`;
}
if (url.pathname.startsWith("/game/")) {
const params = new URLSearchParams();
if (gameTitle) params.set("title", gameTitle);
if (showReviews) params.set("reviews", showReviews);
const queryString = params.toString();
return queryString ? `${url.pathname}?${queryString}` : url.pathname;
}
return notificationUrl;
};
interface NotificationItemProps {
notification: Notification;
badges: Badge[];
onDismiss: (id: string) => void;
onMarkAsRead: (id: string) => void;
onAcceptFriendRequest?: (senderId: string) => void;
onRefuseFriendRequest?: (senderId: string) => void;
}
export function NotificationItem({
notification,
badges,
onDismiss,
onMarkAsRead,
onAcceptFriendRequest,
onRefuseFriendRequest,
}: Readonly<NotificationItemProps>) {
const { t } = useTranslation("notifications_page");
const { formatDistance } = useDate();
const navigate = useNavigate();
const { updateFriendRequestState } = useUserDetails();
const badge = useMemo(() => {
if (notification.type !== "BADGE_RECEIVED") return null;
return badges.find((b) => b.name === notification.variables.badgeName);
}, [notification, badges]);
const handleClick = useCallback(() => {
if (!notification.isRead) {
onMarkAsRead(notification.id);
}
if (notification.url) {
navigate(parseNotificationUrl(notification.url));
}
}, [notification, onMarkAsRead, navigate]);
const handleAccept = useCallback(
async (e: React.MouseEvent) => {
e.stopPropagation();
const senderId = notification.variables.senderId;
if (senderId) {
await updateFriendRequestState(senderId, "ACCEPTED");
onAcceptFriendRequest?.(senderId);
onDismiss(notification.id);
}
},
[notification, updateFriendRequestState, onAcceptFriendRequest, onDismiss]
);
const handleRefuse = useCallback(
async (e: React.MouseEvent) => {
e.stopPropagation();
const senderId = notification.variables.senderId;
if (senderId) {
await updateFriendRequestState(senderId, "REFUSED");
onRefuseFriendRequest?.(senderId);
onDismiss(notification.id);
}
},
[notification, updateFriendRequestState, onRefuseFriendRequest, onDismiss]
);
const handleDismiss = useCallback(
(e: React.MouseEvent) => {
e.stopPropagation();
onDismiss(notification.id);
},
[notification.id, onDismiss]
);
const getNotificationContent = () => {
switch (notification.type) {
case "FRIEND_REQUEST_RECEIVED":
return {
title: t("friend_request_received_title"),
description: t("friend_request_received_description", {
displayName: notification.variables.senderDisplayName,
}),
showActions: true,
};
case "FRIEND_REQUEST_ACCEPTED":
return {
title: t("friend_request_accepted_title"),
description: t("friend_request_accepted_description", {
displayName: notification.variables.accepterDisplayName,
}),
showActions: false,
};
case "BADGE_RECEIVED":
return {
title: t("badge_received_title"),
description: badge?.description || notification.variables.badgeName,
showActions: false,
};
case "REVIEW_UPVOTE":
return {
title: t("review_upvote_title", {
gameTitle: notification.variables.gameTitle,
}),
description: t("review_upvote_description", {
count: Number.parseInt(
notification.variables.upvoteCount || "1",
10
),
}),
showActions: false,
};
default:
return {
title: t("notification"),
description: "",
showActions: false,
};
}
};
const content = getNotificationContent();
const isBadge = notification.type === "BADGE_RECEIVED";
const isReview = notification.type === "REVIEW_UPVOTE";
const getIcon = () => {
if (notification.pictureUrl) {
return <img src={notification.pictureUrl} alt="" />;
}
if (isReview) {
return <StarFillIcon size={24} />;
}
return <PersonIcon size={24} />;
};
return (
<button
type="button"
className={cn("notification-item", {
"notification-item--unread":
!notification.isRead ||
notification.type === "FRIEND_REQUEST_RECEIVED",
})}
onClick={handleClick}
>
<div
className={cn("notification-item__picture", {
"notification-item__badge-picture": isBadge,
"notification-item__review-picture": isReview,
})}
>
{getIcon()}
</div>
<div className="notification-item__content">
<span className="notification-item__title">{content.title}</span>
<span className="notification-item__description">
{content.description}
</span>
<span className="notification-item__time">
<ClockIcon size={12} />
{formatDistance(new Date(notification.createdAt), new Date())}
</span>
</div>
{content.showActions &&
notification.type === "FRIEND_REQUEST_RECEIVED" && (
<div className="notification-item__actions">
<Button theme="primary" onClick={handleAccept}>
{t("accept")}
</Button>
<Button theme="outline" onClick={handleRefuse}>
{t("refuse")}
</Button>
</div>
)}
{notification.type !== "FRIEND_REQUEST_RECEIVED" && (
<button
type="button"
className="notification-item__dismiss"
onClick={handleDismiss}
title={t("dismiss")}
>
<XIcon size={16} />
</button>
)}
</button>
);
}

View File

@@ -1,58 +0,0 @@
@use "../../scss/globals.scss";
.notifications {
display: flex;
flex-direction: column;
gap: calc(globals.$spacing-unit * 2);
padding: calc(globals.$spacing-unit * 3);
width: 100%;
max-width: 800px;
margin: 0 auto;
&__actions {
display: flex;
gap: globals.$spacing-unit;
justify-content: flex-end;
}
&__list {
display: flex;
flex-direction: column;
gap: globals.$spacing-unit;
}
&__empty {
display: flex;
width: 100%;
height: 100%;
justify-content: center;
align-items: center;
flex-direction: column;
gap: globals.$spacing-unit;
}
&__icon-container {
width: 60px;
height: 60px;
border-radius: 50%;
background-color: rgba(255, 255, 255, 0.06);
display: flex;
align-items: center;
justify-content: center;
margin-bottom: calc(globals.$spacing-unit * 2);
}
&__loading {
display: flex;
width: 100%;
height: 100%;
justify-content: center;
align-items: center;
}
&__load-more {
display: flex;
justify-content: center;
padding: calc(globals.$spacing-unit * 2);
}
}

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