diff --git a/package.json b/package.json index da6918b5..bb74198f 100644 --- a/package.json +++ b/package.json @@ -70,6 +70,7 @@ "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", diff --git a/proto b/proto index 7a23620f..6f11c99c 160000 --- a/proto +++ b/proto @@ -1 +1 @@ -Subproject commit 7a23620f930f6fbb84c0abcaab5149a34ab4b4eb +Subproject commit 6f11c99c572420a282ba5149b6866e39b8a4569c diff --git a/src/locales/en/translation.json b/src/locales/en/translation.json index 15f8c3a9..f3a233cc 100755 --- a/src/locales/en/translation.json +++ b/src/locales/en/translation.json @@ -115,6 +115,7 @@ "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" @@ -202,6 +203,7 @@ "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", @@ -414,7 +416,13 @@ "resume_seeding": "Resume seeding", "options": "Manage", "extract": "Extract files", - "extracting": "Extracting files…" + "extracting": "Extracting files…", + "delete_archive_title": "Would you like to delete {{fileName}}?", + "delete_archive_description": "The file has been successfully extracted and it's no longer needed.", + "yes": "Yes", + "no": "No", + "network": "NETWORK", + "peak": "PEAK" }, "settings": { "downloads_path": "Downloads path", @@ -716,7 +724,10 @@ "karma_description": "Earned from positive likes on reviews", "user_reviews": "Reviews", "delete_review": "Delete Review", - "loading_reviews": "Loading reviews..." + "loading_reviews": "Loading reviews...", + "wrapped_2025": "Wrapped 2025", + "view_my_wrapped_button": "View My Wrapped 2025", + "view_wrapped_button": "View {{displayName}}'s Wrapped 2025" }, "library": { "library": "Library", diff --git a/src/locales/fr/translation.json b/src/locales/fr/translation.json index 8fc07722..48bf6086 100644 --- a/src/locales/fr/translation.json +++ b/src/locales/fr/translation.json @@ -27,7 +27,69 @@ "friends": "Amis", "need_help": "Besoin d'aide ?", "favorites": "Favoris", - "playable_button_title": "Afficher uniquement les jeux que vous pouvez jouer maintenant" + "playable_button_title": "Afficher uniquement les jeux que vous pouvez jouer maintenant", + "library": "Bibliothèque", + "add_custom_game_tooltip": "Ajouter un jeu personnalisé", + "show_playable_only_tooltip": "Afficher uniquement les jeux jouables", + "custom_game_modal": "Ajouter un jeu personnalisé", + "custom_game_modal_description": "Ajoutez un jeu personnalisé à votre bibliothèque en sélectionnant un fichier exécutable", + "custom_game_modal_executable_path": "Chemin de l'exécutable", + "custom_game_modal_select_executable": "Sélectionner un fichier exécutable", + "custom_game_modal_title": "Titre", + "custom_game_modal_enter_title": "Entrer le titre", + "custom_game_modal_browse": "Parcourir", + "custom_game_modal_cancel": "Annuler", + "custom_game_modal_add": "Ajouter le jeu", + "custom_game_modal_adding": "Ajout du jeu…", + "custom_game_modal_success": "Jeu personnalisé ajouté avec succès", + "custom_game_modal_failed": "Échec de l’ajout 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 l’image", + "edit_game_modal_icon": "Icône", + "edit_game_modal_select_icon": "Sélectionner une icône", + "edit_game_modal_icon_preview": "Aperçu de l’icô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 l’image 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 l’image de l’icône ici", + "edit_game_modal_drop_logo_image_here": "Déposez l’image du logo ici", + "edit_game_modal_drop_hero_image_here": "Déposez l’image de la bannière ici", + "edit_game_modal_drop_to_replace_icon": "Déposez pour remplacer l’icô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 l’installation du plugin Decky : {{error}}", + "decky_plugin_installation_error": "Erreur lors de l’installation du plugin Decky : {{error}}", + "confirm": "Confirmer", + "cancel": "Annuler" }, "header": { "search": "Rechercher", @@ -37,7 +99,15 @@ "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." + "version_available_download": "Version {{version}} disponible. Cliquez ici pour télécharger.", + "search_library": "Rechercher dans la bibliothèque", + "recent_searches": "Recherches récentes", + "suggestions": "Suggestions", + "clear_history": "Effacer", + "remove_from_history": "Supprimer de l'historique", + "loading": "Chargement…", + "no_results": "Aucun résultat", + "library": "Bibliothèque" }, "bottom_panel": { "no_downloads_in_progress": "Aucun téléchargement en cours", @@ -47,7 +117,8 @@ "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" + "installation_complete_message": "Redistribuables communs installés avec succès", + "extracting": "Extraction de {{title}}… ({{percentage}} terminé)" }, "catalogue": { "search": "Filtrer…", @@ -198,7 +269,113 @@ "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" + "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 l’envoi de l’avis. Veuillez réessayer.", + "review_cannot_be_empty": "Le champ de l’avis ne peut pas être vide.", + "review_deleted_successfully": "Avis supprimé avec succès.", + "review_deletion_failed": "Échec de la suppression de l’avis.", + "loading_reviews": "Chargement des avis…", + "loading_more_reviews": "Chargement de plus d’avis…", + "load_more_reviews": "Charger plus d’avis", + "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 l’avis", + "remove_review": "Retirer l’avis", + "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 l’enregistrement de votre vote. Veuillez réessayer.", + "show_original": "Afficher l’original", + "show_translation": "Afficher la traduction", + "show_original_translated_from": "Afficher l’original (traduit depuis {{language}})", + "hide_original": "Masquer l’original", + "review_from_blocked_user": "Avis d’un utilisateur bloqué", + "show": "Afficher", + "hide": "Masquer" }, "activation": { "title": "Activer Hydra", @@ -237,7 +414,11 @@ "resume_seeding": "Reprendre le partage", "options": "Gérer", "extract": "Extraire les fichiers", - "extracting": "Extraction des fichiers…" + "extracting": "Extraction des fichiers…", + "delete_archive_title": "Voulez-vous supprimer {{fileName}} ?", + "delete_archive_description": "Le fichier a été extrait avec succès et n’est plus nécessaire.", + "yes": "Oui", + "no": "Non" }, "settings": { "downloads_path": "Chemin des téléchargements", @@ -366,7 +547,40 @@ "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" + "enable_friend_start_game_notifications": "Quand un ami commence à jouer à un jeu", + "adding": "Ajout…", + "failed_add_download_source": "Échec de l’ajout 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 d’un jeu" }, "notifications": { "download_complete": "Téléchargement terminé", diff --git a/src/locales/hu/translation.json b/src/locales/hu/translation.json index c2a59873..b83fec51 100644 --- a/src/locales/hu/translation.json +++ b/src/locales/hu/translation.json @@ -22,7 +22,7 @@ "downloading": "{{title}} ({{percentage}} - Letöltés…)", "filter": "Könyvtár szűrése", "home": "Főoldal", - "queued": "A(z) {{title}} (Várakozósorban van)", + "queued": "{{title}} (Várakozásban)", "game_has_no_executable": "A játékhoz nincs tallózva futtatható fájl", "sign_in": "Bejelentkezés", "friends": "Barátok", @@ -94,6 +94,12 @@ "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", @@ -109,6 +115,7 @@ "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" @@ -165,7 +172,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ások menüjében változtathatod meg", + "select_folder_hint": "A letöltési mappát a <0>Beállításokban 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", @@ -196,6 +203,7 @@ "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", @@ -397,7 +405,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ósoron lévő letöltések", + "queued_downloads": "Várakozásban lévő letöltések", "downloads_completed": "Befejezett", "queued": "Várakozásban", "no_downloads_title": "Oly üres..", @@ -408,7 +416,11 @@ "resume_seeding": "Seedelés folytatása", "options": "Kezelés", "extract": "Fájlok kibontása", - "extracting": "Fájlok kibontása…" + "extracting": "Fájlok kibontása…", + "delete_archive_title": "Szeretnéd törölni ezt a fájlt? {{fileName}}", + "delete_archive_description": "A tömörített fájl ki lett csomagolva, s többé nincs rá szükség. ", + "yes": "Igen", + "no": "Nem" }, "settings": { "downloads_path": "Letöltési útvonalak", @@ -669,7 +681,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ások menüjébe", + "privacy_hint": "Hogy beállítsd ki láthassa ezt, menj a <0>Beállításokba", "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ő", diff --git a/src/locales/pt-BR/translation.json b/src/locales/pt-BR/translation.json index 6702c310..719f72f7 100755 --- a/src/locales/pt-BR/translation.json +++ b/src/locales/pt-BR/translation.json @@ -115,6 +115,7 @@ "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" @@ -190,6 +191,7 @@ "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", @@ -402,7 +404,13 @@ "resume_seeding": "Semear", "options": "Gerenciar", "extract": "Extrair arquivos", - "extracting": "Extraindo arquivos…" + "extracting": "Extraindo arquivos…", + "delete_archive_title": "Deseja deletar {{fileName}}?", + "delete_archive_description": "O arquivo foi extraído com sucesso e não é mais necessário.", + "yes": "Sim", + "no": "Não", + "network": "REDE", + "peak": "PICO" }, "settings": { "downloads_path": "Diretório dos downloads", diff --git a/src/main/events/library/delete-archive.ts b/src/main/events/library/delete-archive.ts new file mode 100644 index 00000000..9cf64a63 --- /dev/null +++ b/src/main/events/library/delete-archive.ts @@ -0,0 +1,23 @@ +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); diff --git a/src/main/events/library/extract-game-download.ts b/src/main/events/library/extract-game-download.ts index 8fb24b81..b393e6b7 100644 --- a/src/main/events/library/extract-game-download.ts +++ b/src/main/events/library/extract-game-download.ts @@ -22,6 +22,7 @@ const extractGameDownload = async ( await downloadsSublevel.put(gameKey, { ...download, extracting: true, + extractionProgress: 0, }); const gameFilesManager = new GameFilesManager(shop, objectId); diff --git a/src/main/events/library/index.ts b/src/main/events/library/index.ts index d9d628d0..75fc5cd9 100644 --- a/src/main/events/library/index.ts +++ b/src/main/events/library/index.ts @@ -8,6 +8,7 @@ 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"; diff --git a/src/main/events/torrenting/start-game-download.ts b/src/main/events/torrenting/start-game-download.ts index 79d55ec3..e44ba936 100644 --- a/src/main/events/torrenting/start-game-download.ts +++ b/src/main/events/torrenting/start-game-download.ts @@ -41,7 +41,6 @@ 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) { @@ -82,6 +81,7 @@ const startGameDownload = async ( queued: true, extracting: false, automaticallyExtract, + extractionProgress: 0, }; try { @@ -123,6 +123,42 @@ 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 }; } diff --git a/src/main/main.ts b/src/main/main.ts index 9f0ce47c..82ea7c47 100644 --- a/src/main/main.ts +++ b/src/main/main.ts @@ -33,9 +33,7 @@ export const loadState = async () => { await import("./events"); - if (process.platform !== "darwin") { - Aria2.spawn(); - } + Aria2.spawn(); if (userPreferences?.realDebridApiToken) { RealDebridClient.authorize(userPreferences.realDebridApiToken); diff --git a/src/main/services/7zip.ts b/src/main/services/7zip.ts index 9a9f85be..0fa333dc 100644 --- a/src/main/services/7zip.ts +++ b/src/main/services/7zip.ts @@ -1,5 +1,5 @@ import { app } from "electron"; -import cp from "node:child_process"; +import Seven, { CommandLineSwitches } from "node-7z"; import path from "node:path"; import { logger } from "./logger"; @@ -9,6 +9,17 @@ 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]) @@ -32,43 +43,109 @@ export class SevenZip { cwd?: string; passwords?: string[]; }, - successCb: () => void, - errorCb: () => void - ) { - const tryPassword = (index = -1) => { - const password = passwords[index] ?? ""; - logger.info(`Trying password ${password} on ${filePath}`); + onProgress?: (progress: ExtractionProgress) => void + ): Promise { + return new Promise((resolve, reject) => { + const tryPassword = (index = -1) => { + const password = passwords[index] ?? ""; + logger.info( + `Trying password "${password || "(empty)"}" on ${filePath}` + ); - const args = ["x", filePath, "-y", "-p" + password]; + const extractedFiles: string[] = []; + let fileCount = 0; - if (outputPath) { - args.push("-o" + outputPath); - } + const options: CommandLineSwitches = { + $bin: this.binaryPath, + $progress: true, + yes: true, + password: password || undefined, + }; - const child = cp.execFile(this.binaryPath, args, { - cwd, - }); - - child.once("exit", (code) => { - if (code === 0) { - successCb(); - return; + if (outputPath) { + options.outputDir = outputPath; } - if (index < passwords.length - 1) { + 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", () => { logger.info( - `Failed to extract file: ${filePath} with password: ${password}. Trying next password...` + `Successfully extracted ${filePath} (${extractedFiles.length} files)` ); + resolve({ + success: true, + extractedFiles, + }); + }); - tryPassword(index + 1); - } else { - logger.info(`Failed to extract file: ${filePath}`); + stream.on("error", (err) => { + logger.error(`Extraction error for ${filePath}:`, err); - errorCb(); + 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 { + 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); } }); - }; - tryPassword(); + stream.on("end", () => { + resolve(files); + }); + + stream.on("error", (err) => { + reject(err); + }); + }); } } diff --git a/src/main/services/aria2.ts b/src/main/services/aria2.ts index f6835558..f3f49018 100644 --- a/src/main/services/aria2.ts +++ b/src/main/services/aria2.ts @@ -7,9 +7,12 @@ export class Aria2 { private static process: cp.ChildProcess | null = null; public static spawn() { - const binaryPath = app.isPackaged - ? path.join(process.resourcesPath, "aria2c") - : path.join(__dirname, "..", "..", "binaries", "aria2c"); + const binaryPath = + process.platform === "darwin" + ? "aria2c" + : app.isPackaged + ? path.join(process.resourcesPath, "aria2c") + : path.join(__dirname, "..", "..", "binaries", "aria2c"); this.process = cp.spawn( binaryPath, diff --git a/src/main/services/decky-plugin.ts b/src/main/services/decky-plugin.ts index 4dc1fdad..cb8999c3 100644 --- a/src/main/services/decky-plugin.ts +++ b/src/main/services/decky-plugin.ts @@ -74,21 +74,16 @@ export class DeckyPlugin { await fs.promises.mkdir(extractPath, { recursive: true }); - 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")); - } - ); - }); + try { + await SevenZip.extractFile({ + filePath: zipPath, + outputPath: extractPath, + }); + logger.log(`Plugin extracted to: ${extractPath}`); + return extractPath; + } catch { + throw new Error("Failed to extract plugin"); + } } private static needsSudo(): boolean { diff --git a/src/main/services/download/download-manager.ts b/src/main/services/download/download-manager.ts index 1a79f8f0..bc9ddf1d 100644 --- a/src/main/services/download/download-manager.ts +++ b/src/main/services/download/download-manager.ts @@ -20,14 +20,59 @@ import { RealDebridClient } from "./real-debrid"; import path from "path"; import { logger } from "../logger"; import { db, downloadsSublevel, gamesSublevel, levelKeys } from "@main/level"; -import { orderBy } from "lodash-es"; +import { sortBy } 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')) return hashPart; + } + + if (url.includes('#')) { + const hashPart = url.split('#')[1]; + if (hashPart && !hashPart.startsWith('http')) return hashPart; + } + + try { + const urlObj = new URL(url); + const filename = urlObj.pathname.split('/').pop(); + if (filename?.length) return filename; + } catch { + // Invalid URL + } + + return undefined; + } + + private static sanitizeFilename(filename: string): string { + return filename.replace(/[<>:"/\\|?*]/g, '_'); + } + + private static createDownloadPayload(directUrl: string, originalUrl: string, downloadId: string, savePath: string) { + const filename = this.extractFilename(directUrl, originalUrl); + const sanitizedFilename = filename ? this.sanitizeFilename(filename) : undefined; + + if (sanitizedFilename) { + logger.log(`[DownloadManager] Using filename: ${sanitizedFilename}`); + } + + 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[] @@ -53,9 +98,7 @@ export class DownloadManager { } private static async getDownloadStatus() { - const response = await PythonRPC.rpc.get( - "/status" - ); + const response = await PythonRPC.rpc.get("/status"); if (response.data === null || !this.downloadingGameId) return null; const downloadId = this.downloadingGameId; @@ -71,8 +114,7 @@ export class DownloadManager { status, } = response.data; - const isDownloadingMetadata = - status === LibtorrentStatus.DownloadingMetadata; + const isDownloadingMetadata = status === LibtorrentStatus.DownloadingMetadata; const isCheckingFiles = status === LibtorrentStatus.CheckingFiles; const download = await downloadsSublevel.get(downloadId); @@ -121,21 +163,14 @@ export class DownloadManager { const userPreferences = await db.get( 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 })) ); } @@ -144,10 +179,7 @@ export class DownloadManager { if (progress === 1 && download) { publishDownloadCompleteNotification(game); - if ( - userPreferences?.seedAfterDownloadComplete && - download.downloader === Downloader.Torrent - ) { + if (userPreferences?.seedAfterDownloadComplete && download.downloader === Downloader.Torrent) { await downloadsSublevel.put(gameId, { ...download, status: "seeding", @@ -168,38 +200,25 @@ export class DownloadManager { } if (shouldExtract) { - const gameFilesManager = new GameFilesManager( - game.shop, - game.objectId - ); + const gameFilesManager = new GameFilesManager(game.shop, game.objectId); - if ( - FILE_EXTENSIONS_TO_EXTRACT.some((ext) => - download.folderName?.endsWith(ext) - ) - ) { + if (FILE_EXTENSIONS_TO_EXTRACT.some((ext) => download.folderName?.endsWith(ext))) { gameFilesManager.extractDownloadedFile(); } else { gameFilesManager - .extractFilesInDirectory( - path.join(download.downloadPath, download.folderName!) - ) - .then(() => { - gameFilesManager.setExtractionComplete(); - }); + .extractFilesInDirectory(path.join(download.downloadPath, download.folderName!)) + .then(() => gameFilesManager.setExtractionComplete()); } } const downloads = await downloadsSublevel .values() .all() - .then((games) => { - return orderBy( - games.filter((game) => game.status === "paused" && game.queued), - "timestamp", - "desc" - ); - }); + .then((games) => sortBy( + games.filter((game) => game.status === "paused" && game.queued), + "timestamp", + "DESC" + )); const [nextItemOnQueue] = downloads; @@ -226,9 +245,7 @@ export class DownloadManager { if (!download) return; - const totalSize = await getDirSize( - path.join(download.downloadPath, status.folderName) - ); + const totalSize = await getDirSize(path.join(download.downloadPath, status.folderName)); if (totalSize < status.fileSize) { await this.cancelDownload(status.gameId); @@ -249,10 +266,7 @@ export class DownloadManager { static async pauseDownload(downloadKey = this.downloadingGameId) { await PythonRPC.rpc - .post("/action", { - action: "pause", - game_id: downloadKey, - } as PauseDownloadPayload) + .post("/action", { action: "pause", game_id: downloadKey } as PauseDownloadPayload) .catch(() => {}); if (downloadKey === this.downloadingGameId) { @@ -267,13 +281,8 @@ 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); @@ -306,7 +315,6 @@ 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 { @@ -348,9 +356,30 @@ 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, @@ -367,7 +396,6 @@ export class DownloadManager { }; case Downloader.RealDebrid: { const downloadUrl = await RealDebridClient.getDownloadUrl(download.uri); - if (!downloadUrl) throw new Error(DownloadError.NotCachedOnRealDebrid); return { @@ -380,7 +408,6 @@ export class DownloadManager { } case Downloader.TorBox: { const { name, url } = await TorBoxClient.getDownloadInfo(download.uri); - if (!url) return; return { action: "start", @@ -392,10 +419,7 @@ export class DownloadManager { }; } case Downloader.Hydra: { - const downloadUrl = await HydraDebridClient.getDownloadUrl( - download.uri - ); - + const downloadUrl = await HydraDebridClient.getDownloadUrl(download.uri); if (!downloadUrl) throw new Error(DownloadError.NotCachedOnHydra); return { diff --git a/src/main/services/game-files-manager.ts b/src/main/services/game-files-manager.ts index 120b3e8f..f3684a0a 100644 --- a/src/main/services/game-files-manager.ts +++ b/src/main/services/game-files-manager.ts @@ -3,24 +3,58 @@ 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 } from "./7zip"; +import { SevenZip, ExtractionProgress } 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 async clearExtractionState() { - const gameKey = levelKeys.game(this.shop, this.objectId); - const download = await downloadsSublevel.get(gameKey); + private get gameKey() { + return levelKeys.game(this.shop, this.objectId); + } - await downloadsSublevel.put(gameKey, { - ...download!, + 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; + + await downloadsSublevel.put(this.gameKey, { + ...download, extracting: false, + extractionProgress: 0, }); WindowManager.mainWindow?.webContents.send( @@ -30,6 +64,10 @@ 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); @@ -42,53 +80,66 @@ export class GameFilesManager { (file) => /part1\.rar$/i.test(file) || !/part\d+\.rar$/i.test(file) ); - 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(); - } - ); - }); - }) - ); + if (filesToExtract.length === 0) return; - compressedFiles.forEach((file) => { - const extractionPath = path.join(directoryPath, file); + await this.updateExtractionProgress(0, true); - if (fs.existsSync(extractionPath)) { - fs.unlink(extractionPath, (err) => { - if (err) { - logger.error(`Failed to delete file: ${file}`, err); + const totalFiles = filesToExtract.length; + let completedFiles = 0; - this.clearExtractionState(); + 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 + ); + } + } catch (err) { + logger.error(`Failed to extract file: ${file}`, err); + await this.clearExtractionState(); + return; } - }); + } + + 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(gameKey), - gamesSublevel.get(gameKey), + downloadsSublevel.get(this.gameKey), + gamesSublevel.get(this.gameKey), ]); - await downloadsSublevel.put(gameKey, { - ...download!, + if (!download) return; + + await downloadsSublevel.put(this.gameKey, { + ...download, extracting: false, + extractionProgress: 0, }); WindowManager.mainWindow?.webContents.send( @@ -97,17 +148,15 @@ export class GameFilesManager { this.objectId ); - if (publishNotification) { - publishExtractionCompleteNotification(game!); + if (publishNotification && game) { + publishExtractionCompleteNotification(game); } } async extractDownloadedFile() { - const gameKey = levelKeys.game(this.shop, this.objectId); - const [download, game] = await Promise.all([ - downloadsSublevel.get(gameKey), - gamesSublevel.get(gameKey), + downloadsSublevel.get(this.gameKey), + gamesSublevel.get(this.gameKey), ]); if (!download || !game) return false; @@ -119,39 +168,39 @@ export class GameFilesManager { path.parse(download.folderName!).name ); - SevenZip.extractFile( - { - filePath, - outputPath: extractionPath, - passwords: ["online-fix.me", "steamrip.com"], - }, - async () => { + 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) { await this.extractFilesInDirectory(extractionPath); if (fs.existsSync(extractionPath) && fs.existsSync(filePath)) { - fs.unlink(filePath, (err) => { - if (err) { - logger.error( - `Failed to delete file: ${download.folderName}`, - err - ); - - this.clearExtractionState(); - } - }); + WindowManager.mainWindow?.webContents.send( + "on-archive-deletion-prompt", + [filePath] + ); } - await downloadsSublevel.put(gameKey, { - ...download!, + await downloadsSublevel.put(this.gameKey, { + ...download, folderName: path.parse(download.folderName!).name, }); - this.setExtractionComplete(); - }, - () => { - this.clearExtractionState(); + await this.setExtractionComplete(); } - ); + } catch (err) { + logger.error(`Failed to extract downloaded file: ${filePath}`, err); + await this.clearExtractionState(); + } return true; } diff --git a/src/main/services/hosters/buzzheavier.ts b/src/main/services/hosters/buzzheavier.ts new file mode 100644 index 00000000..819c8f25 --- /dev/null +++ b/src/main/services/hosters/buzzheavier.ts @@ -0,0 +1,82 @@ +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 { + 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 { + 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 { + return extractHosterFilename(url, directUrl); + } +} diff --git a/src/main/services/hosters/fuckingfast.ts b/src/main/services/hosters/fuckingfast.ts new file mode 100644 index 00000000..00d0ff58 --- /dev/null +++ b/src/main/services/hosters/fuckingfast.ts @@ -0,0 +1,129 @@ +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 { + 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 { + 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 { + 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 { + return extractHosterFilename(url, directUrl); + } +} diff --git a/src/main/services/hosters/gofile.ts b/src/main/services/hosters/gofile.ts index 5560ad31..fb9b97e3 100644 --- a/src/main/services/hosters/gofile.ts +++ b/src/main/services/hosters/gofile.ts @@ -36,16 +36,13 @@ 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}?${searchParams.toString()}`, { + }>(`https://api.gofile.io/contents/${id}`, { headers: { Authorization: `Bearer ${this.token}`, + "X-Website-Token": WT, }, }); diff --git a/src/main/services/hosters/index.ts b/src/main/services/hosters/index.ts index 3f3b9ac9..5f918811 100644 --- a/src/main/services/hosters/index.ts +++ b/src/main/services/hosters/index.ts @@ -3,3 +3,5 @@ export * from "./qiwi"; export * from "./datanodes"; export * from "./mediafire"; export * from "./pixeldrain"; +export * from "./buzzheavier"; +export * from "./fuckingfast"; diff --git a/src/main/services/node-7z.d.ts b/src/main/services/node-7z.d.ts new file mode 100644 index 00000000..3877346a --- /dev/null +++ b/src/main/services/node-7z.d.ts @@ -0,0 +1,87 @@ +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; + _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; +} diff --git a/src/main/services/window-manager.ts b/src/main/services/window-manager.ts index 04c77619..26d13228 100644 --- a/src/main/services/window-manager.ts +++ b/src/main/services/window-manager.ts @@ -36,9 +36,9 @@ export class WindowManager { private static initialConfigInitializationMainWindow: Electron.BrowserWindowConstructorOptions = { width: 1200, - height: 720, + height: 860, minWidth: 1024, - minHeight: 540, + minHeight: 860, backgroundColor: "#1c1c1c", titleBarStyle: process.platform === "linux" ? "default" : "hidden", icon, @@ -106,7 +106,7 @@ export class WindowManager { valueEncoding: "json", } ); - return data ?? { isMaximized: false, height: 720, width: 1200 }; + return data ?? { isMaximized: false, height: 860, width: 1200 }; } private static updateInitialConfig( @@ -224,7 +224,7 @@ export class WindowManager { ? { x: undefined, y: undefined, - height: this.initialConfigInitializationMainWindow.height ?? 720, + height: this.initialConfigInitializationMainWindow.height ?? 860, width: this.initialConfigInitializationMainWindow.width ?? 1200, isMaximized: true, } diff --git a/src/preload/index.ts b/src/preload/index.ts index f7c062cb..5579b6fb 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -267,6 +267,29 @@ 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) => diff --git a/src/renderer/src/app.tsx b/src/renderer/src/app.tsx index 9badd12e..6619c890 100644 --- a/src/renderer/src/app.tsx +++ b/src/renderer/src/app.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useRef } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import { Sidebar, BottomPanel, Header, Toast } from "@renderer/components"; import { @@ -19,11 +19,14 @@ 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, @@ -78,6 +81,10 @@ export function App() { const { showSuccessToast } = useToast(); + const [showArchiveDeletionModal, setShowArchiveDeletionModal] = + useState(false); + const [archivePaths, setArchivePaths] = useState([]); + useEffect(() => { Promise.all([ levelDBService.get("userPreferences", null, "json"), @@ -184,12 +191,23 @@ 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]); + }, [onSignIn, updateLibrary, clearUserDetails, dispatch]); useEffect(() => { if (contentRef.current) contentRef.current.scrollTop = 0; @@ -281,6 +299,12 @@ export function App() { feature={hydraCloudFeature} /> + setShowArchiveDeletionModal(false)} + /> + {userDetails && ( state.download.extraction); + const [version, setVersion] = useState(""); const [sessionHash, setSessionHash] = useState(""); const [commonRedistStatus, setCommonRedistStatus] = useState( @@ -68,6 +71,20 @@ 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; @@ -109,6 +126,7 @@ export function BottomPanel() { eta, downloadSpeed, commonRedistStatus, + extraction, ]); return ( diff --git a/src/renderer/src/components/fullscreen-media-modal/fullscreen-media-modal.scss b/src/renderer/src/components/fullscreen-media-modal/fullscreen-media-modal.scss new file mode 100644 index 00000000..d46958ff --- /dev/null +++ b/src/renderer/src/components/fullscreen-media-modal/fullscreen-media-modal.scss @@ -0,0 +1,82 @@ +@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); + } +} diff --git a/src/renderer/src/components/fullscreen-media-modal/fullscreen-media-modal.tsx b/src/renderer/src/components/fullscreen-media-modal/fullscreen-media-modal.tsx new file mode 100644 index 00000000..f3722154 --- /dev/null +++ b/src/renderer/src/components/fullscreen-media-modal/fullscreen-media-modal.tsx @@ -0,0 +1,87 @@ +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(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( +
+ + + +
+ {alt} +
+
+
, + document.body + ); +} diff --git a/src/renderer/src/components/index.ts b/src/renderer/src/components/index.ts index e8876fcb..8bb028bd 100644 --- a/src/renderer/src/components/index.ts +++ b/src/renderer/src/components/index.ts @@ -20,3 +20,4 @@ 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"; diff --git a/src/renderer/src/constants.ts b/src/renderer/src/constants.ts index 3329a0cc..89de5503 100644 --- a/src/renderer/src/constants.ts +++ b/src/renderer/src/constants.ts @@ -10,6 +10,8 @@ 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", }; diff --git a/src/renderer/src/declaration.d.ts b/src/renderer/src/declaration.d.ts index 56205b2f..6975967e 100644 --- a/src/renderer/src/declaration.d.ts +++ b/src/renderer/src/declaration.d.ts @@ -208,6 +208,13 @@ 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; getDefaultWinePrefixSelectionPath: () => Promise; createSteamShortcut: (shop: GameShop, objectId: string) => Promise; diff --git a/src/renderer/src/features/download-slice.ts b/src/renderer/src/features/download-slice.ts index cb638cda..f70421c0 100644 --- a/src/renderer/src/features/download-slice.ts +++ b/src/renderer/src/features/download-slice.ts @@ -1,17 +1,28 @@ import { createSlice } from "@reduxjs/toolkit"; import type { PayloadAction } from "@reduxjs/toolkit"; -import type { DownloadProgress } from "@types"; +import type { DownloadProgress, GameShop } from "@types"; + +export interface ExtractionInfo { + visibleId: string; + progress: number; +} export interface DownloadState { lastPacket: DownloadProgress | null; gameId: string | null; gamesWithDeletionInProgress: string[]; + extraction: ExtractionInfo | null; + peakSpeeds: Record; + speedHistory: Record; } const initialState: DownloadState = { lastPacket: null, gameId: null, gamesWithDeletionInProgress: [], + extraction: null, + peakSpeeds: {}, + speedHistory: {}, }; export const downloadSlice = createSlice({ @@ -21,6 +32,27 @@ export const downloadSlice = createSlice({ setLastPacket: (state, action: PayloadAction) => { 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; @@ -38,6 +70,37 @@ 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) => { + state.peakSpeeds[action.payload] = 0; + state.speedHistory[action.payload] = []; + }, }, }); @@ -46,4 +109,8 @@ export const { clearDownload, setGameDeleting, removeGameFromDeleting, + setExtractionProgress, + clearExtraction, + updatePeakSpeed, + clearPeakSpeed, } = downloadSlice.actions; diff --git a/src/renderer/src/pages/downloads/archive-deletion-error-modal.tsx b/src/renderer/src/pages/downloads/archive-deletion-error-modal.tsx new file mode 100644 index 00000000..ff931a61 --- /dev/null +++ b/src/renderer/src/pages/downloads/archive-deletion-error-modal.tsx @@ -0,0 +1,44 @@ +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) { + 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 ( + + ); +} diff --git a/src/renderer/src/pages/downloads/download-group.scss b/src/renderer/src/pages/downloads/download-group.scss index 0b9deea3..bfd8fbda 100644 --- a/src/renderer/src/pages/downloads/download-group.scss +++ b/src/renderer/src/pages/downloads/download-group.scss @@ -108,16 +108,11 @@ cursor: pointer; display: flex; align-items: center; - transition: opacity 0.2s ease; + transition: scale 0.2s ease; outline: none; &:hover { - opacity: 0.8; - } - - &:focus, - &:focus-visible { - outline: none; + scale: 1.05; } } @@ -395,6 +390,21 @@ 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%; @@ -411,6 +421,21 @@ 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; @@ -511,5 +536,9 @@ background-color: #fff; transition: width 0.3s ease; border-radius: 4px; + + &--extraction { + background-color: #fff; + } } } diff --git a/src/renderer/src/pages/downloads/download-group.tsx b/src/renderer/src/pages/downloads/download-group.tsx index bcecbc7c..6a22148a 100644 --- a/src/renderer/src/pages/downloads/download-group.tsx +++ b/src/renderer/src/pages/downloads/download-group.tsx @@ -128,16 +128,20 @@ function SpeedChart({ g = 255, b = 255; if (color.startsWith("#")) { - 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); + 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; } else if (color.startsWith("rgb")) { const matches = color.match(/\d+/g); if (matches && matches.length >= 3) { - r = Number.parseInt(matches[0]); - g = Number.parseInt(matches[1]); - b = Number.parseInt(matches[2]); + r = Number.parseInt(matches[0]) || 255; + g = Number.parseInt(matches[1]) || 255; + b = Number.parseInt(matches[2]) || 255; } } const displaySpeeds = speeds.slice(-totalBars); @@ -203,6 +207,7 @@ function SpeedChart({ interface HeroDownloadViewProps { game: LibraryGame; isGameDownloading: boolean; + isGameExtracting?: boolean; downloadSpeed: number; finalDownloadSize: string; peakSpeed: number; @@ -221,6 +226,7 @@ interface HeroDownloadViewProps { function HeroDownloadView({ game, isGameDownloading, + isGameExtracting = false, downloadSpeed, finalDownloadSize, peakSpeed, @@ -278,11 +284,17 @@ function HeroDownloadView({
- {lastPacket?.isCheckingFiles ? ( + {isGameExtracting && ( + + {t("extracting")} + + )} + {!isGameExtracting && lastPacket?.isCheckingFiles && ( {t("checking_files")} - ) : ( + )} + {!isGameExtracting && !lastPacket?.isCheckingFiles && ( {isGameDownloading && lastPacket @@ -293,7 +305,7 @@ function HeroDownloadView({
- {!lastPacket?.isCheckingFiles && ( + {!lastPacket?.isCheckingFiles && !isGameExtracting && ( {isGameDownloading && lastPacket?.timeRemaining && @@ -311,42 +323,44 @@ function HeroDownloadView({
-
- {isGameDownloading ? ( + {!isGameExtracting && ( +
+ {isGameDownloading ? ( + + ) : ( + + )} - ) : ( - - )} - -
+
+ )}
@@ -398,10 +412,12 @@ function HeroDownloadView({ )} - {game.download?.downloader && ( + {game.download?.downloader !== undefined && (
- {DOWNLOADER_NAME[game.download.downloader]} + + {DOWNLOADER_NAME[Number(game.download.downloader)]} +
)} @@ -436,11 +452,14 @@ export function DownloadGroup({ seedingStatus, }: Readonly) { const { t } = useTranslation("downloads"); + const navigate = useNavigate(); const userPreferences = useAppSelector( (state) => state.userPreferences.value ); + const extraction = useAppSelector((state) => state.download.extraction); + const { updateLibrary } = useLibrary(); const { @@ -495,8 +514,9 @@ export function DownloadGroup({ const { formatDistance } = useDate(); - const [peakSpeeds, setPeakSpeeds] = useState>({}); - const speedHistoryRef = useRef>({}); + // 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 [dominantColors, setDominantColors] = useState>( {} ); @@ -559,68 +579,8 @@ export function DownloadGroup({ }); }, [library, lastPacket?.gameId]); - 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]); + // 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 (library.length > 0 && title === t("download_in_progress")) { @@ -818,16 +778,28 @@ export function DownloadGroup({ if (isDownloadingGroup && library.length > 0) { const game = library[0]; - const isGameDownloading = isGameDownloadingMap[game.id]; + const isGameExtracting = extraction?.visibleId === game.id; + const isGameDownloading = + isGameDownloadingMap[game.id] && !isGameExtracting; const downloadSpeed = isGameDownloading ? (lastPacket?.downloadSpeed ?? 0) : 0; const finalDownloadSize = getFinalDownloadSize(game); - const peakSpeed = peakSpeeds[game.id] || 0; - const currentProgress = - isGameDownloading && lastPacket - ? lastPacket.progress - : game.download?.progress || 0; + // 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 dominantColor = dominantColors[game.id] || "#fff"; @@ -835,13 +807,14 @@ export function DownloadGroup({ { return (
  • -
    +
    +
    -

    {game.title}

    +
    - {DOWNLOADER_NAME[game.download!.downloader]} + + {DOWNLOADER_NAME[Number(game.download!.downloader)]} +
    - {game.download?.extracting ? ( + {extraction?.visibleId === game.id ? ( - {t("extracting")} + {t("extracting")} ( + {Math.round(extraction.progress * 100)}%) ) : ( diff --git a/src/renderer/src/pages/downloads/downloads.tsx b/src/renderer/src/pages/downloads/downloads.tsx index c222ab65..10d817f1 100644 --- a/src/renderer/src/pages/downloads/downloads.tsx +++ b/src/renderer/src/pages/downloads/downloads.tsx @@ -1,6 +1,6 @@ import { useTranslation } from "react-i18next"; -import { useDownload, useLibrary } from "@renderer/hooks"; +import { useAppSelector, useDownload, useLibrary } from "@renderer/hooks"; import { useEffect, useMemo, useRef, useState } from "react"; import { BinaryNotFoundModal } from "../shared-modals/binary-not-found-modal"; @@ -13,6 +13,7 @@ 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"); @@ -39,11 +40,13 @@ export default function Downloads() { useEffect(() => { window.electron.onSeedingStatus((value) => setSeedingStatus(value)); - const unsubscribe = window.electron.onExtractionComplete(() => { + const unsubscribeExtraction = window.electron.onExtractionComplete(() => { updateLibrary(); }); - return () => unsubscribe(); + return () => { + unsubscribeExtraction(); + }; }, [updateLibrary]); const handleOpenGameInstaller = (shop: GameShop, objectId: string) => @@ -72,8 +75,10 @@ export default function Downloads() { /* Game has been manually added to the library */ if (!next.download) return prev; - /* Is downloading */ - if (lastPacket?.gameId === next.id || next.download.extracting) + /* Is downloading or extracting */ + const isExtracting = + next.download.extracting || extraction?.visibleId === next.id; + if (lastPacket?.gameId === next.id || isExtracting) return { ...prev, downloading: [...prev.downloading, next] }; /* Is either queued or paused */ @@ -96,7 +101,7 @@ export default function Downloads() { queued, complete, }; - }, [library, lastPacket?.gameId]); + }, [library, lastPacket?.gameId, extraction?.visibleId]); const downloadGroups = [ { diff --git a/src/renderer/src/pages/game-details/hero/hero-panel-playtime.tsx b/src/renderer/src/pages/game-details/hero/hero-panel-playtime.tsx index 270ed030..24c37b18 100644 --- a/src/renderer/src/pages/game-details/hero/hero-panel-playtime.tsx +++ b/src/renderer/src/pages/game-details/hero/hero-panel-playtime.tsx @@ -1,7 +1,12 @@ import { useContext, useEffect, useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; import { formatDownloadProgress } from "@renderer/helpers"; -import { useDate, useDownload, useFormat } from "@renderer/hooks"; +import { + useAppSelector, + 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"; @@ -17,6 +22,9 @@ 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) { @@ -52,6 +60,16 @@ export function HeroPanelPlaytime() { const isGameDownloading = game.download?.status === "active" && lastPacket?.gameId === game.id; + const extractionInProgressInfo = ( +
    + + {t("extracting")} + + + {formatDownloadProgress(extraction?.progress ?? 0)} +
    + ); + const downloadInProgressInfo = (
    @@ -72,7 +90,8 @@ export function HeroPanelPlaytime() { return ( <>

    {t("not_played_yet", { title: game?.title })}

    - {hasDownload && downloadInProgressInfo} + {isExtracting && extractionInProgressInfo} + {!isExtracting && hasDownload && downloadInProgressInfo} ); } @@ -81,7 +100,8 @@ export function HeroPanelPlaytime() { return ( <>

    {t("playing_now")}

    - {hasDownload && downloadInProgressInfo} + {isExtracting && extractionInProgressInfo} + {!isExtracting && hasDownload && downloadInProgressInfo} ); } @@ -113,9 +133,9 @@ export function HeroPanelPlaytime() { })}

    - {hasDownload ? ( - downloadInProgressInfo - ) : ( + {isExtracting && extractionInProgressInfo} + {!isExtracting && hasDownload && downloadInProgressInfo} + {!isExtracting && !hasDownload && (

    {t("last_time_played", { period: lastTimePlayed, diff --git a/src/renderer/src/pages/game-details/hero/hero-panel.scss b/src/renderer/src/pages/game-details/hero/hero-panel.scss index c91e685c..6aa4d311 100644 --- a/src/renderer/src/pages/game-details/hero/hero-panel.scss +++ b/src/renderer/src/pages/game-details/hero/hero-panel.scss @@ -80,5 +80,11 @@ &--disabled { opacity: globals.$disabled-opacity; } + + &--extraction { + &::-webkit-progress-value { + background-color: #fff; + } + } } } diff --git a/src/renderer/src/pages/game-details/hero/hero-panel.tsx b/src/renderer/src/pages/game-details/hero/hero-panel.tsx index 799f2c36..48cda106 100644 --- a/src/renderer/src/pages/game-details/hero/hero-panel.tsx +++ b/src/renderer/src/pages/game-details/hero/hero-panel.tsx @@ -1,7 +1,7 @@ import { useContext } from "react"; import { useTranslation } from "react-i18next"; -import { useDate, useDownload } from "@renderer/hooks"; +import { useAppSelector, useDate, useDownload } from "@renderer/hooks"; import { HeroPanelActions } from "./hero-panel-actions"; import { HeroPanelPlaytime } from "./hero-panel-playtime"; @@ -18,9 +18,13 @@ 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; @@ -49,6 +53,8 @@ export function HeroPanel() { (game?.download?.status === "active" && game?.download?.progress < 1) || game?.download?.status === "paused"; + const showExtractionProgressBar = isExtracting; + return (

    @@ -72,6 +78,14 @@ export function HeroPanel() { }`} /> )} + + {showExtractionProgressBar && ( + + )}
    ); diff --git a/src/renderer/src/pages/profile/profile-content/profile-content.tsx b/src/renderer/src/pages/profile/profile-content/profile-content.tsx index 8176bace..6e20e686 100644 --- a/src/renderer/src/pages/profile/profile-content/profile-content.tsx +++ b/src/renderer/src/pages/profile/profile-content/profile-content.tsx @@ -21,7 +21,7 @@ import { UserKarmaBox } from "./user-karma-box"; import { DeleteReviewModal } from "@renderer/pages/game-details/modals/delete-review-modal"; import { GAME_STATS_ANIMATION_DURATION_IN_MS } from "./profile-animations"; import { MAX_MINUTES_TO_SHOW_IN_PLAYTIME } from "@renderer/constants"; -import { ProfileTabs } from "./profile-tabs"; +import { ProfileTabs, type ProfileTabType } from "./profile-tabs"; import { LibraryTab } from "./library-tab"; import { ReviewsTab } from "./reviews-tab"; import { AnimatePresence } from "framer-motion"; @@ -95,7 +95,7 @@ export function ProfileContent() { const [sortBy, setSortBy] = useState("playedRecently"); const statsAnimation = useRef(-1); - const [activeTab, setActiveTab] = useState<"library" | "reviews">("library"); + const [activeTab, setActiveTab] = useState("library"); // User reviews state const [reviews, setReviews] = useState([]); diff --git a/src/renderer/src/pages/profile/profile-content/profile-tabs.tsx b/src/renderer/src/pages/profile/profile-content/profile-tabs.tsx index bc76f40c..84d1dd4d 100644 --- a/src/renderer/src/pages/profile/profile-content/profile-tabs.tsx +++ b/src/renderer/src/pages/profile/profile-content/profile-tabs.tsx @@ -2,10 +2,12 @@ import { motion } from "framer-motion"; import { useTranslation } from "react-i18next"; import "./profile-content.scss"; +export type ProfileTabType = "library" | "reviews"; + interface ProfileTabsProps { - activeTab: "library" | "reviews"; + activeTab: ProfileTabType; reviewsTotalCount: number; - onTabChange: (tab: "library" | "reviews") => void; + onTabChange: (tab: ProfileTabType) => void; } export function ProfileTabs({ diff --git a/src/renderer/src/pages/profile/profile-content/wrapped-tab.scss b/src/renderer/src/pages/profile/profile-content/wrapped-tab.scss new file mode 100644 index 00000000..0dc45d8d --- /dev/null +++ b/src/renderer/src/pages/profile/profile-content/wrapped-tab.scss @@ -0,0 +1,100 @@ +@use "../../../scss/globals.scss"; + +.wrapped-fullscreen-modal { + position: fixed; + inset: 0; + z-index: 999; + display: flex; + align-items: center; + justify-content: center; + padding: 0; + margin: 0; + border: none; + background: transparent; + width: 100%; + height: 100%; + + &__backdrop { + position: absolute; + inset: 0; + background: rgba(0, 0, 0, 0.9); + border: none; + z-index: 1; + } + + &__container { + position: relative; + display: flex; + align-items: center; + justify-content: center; + width: 100%; + height: 100%; + padding: calc(globals.$spacing-unit * 2); + pointer-events: none; + z-index: 2; + } + + &__close-button { + position: absolute; + top: calc(globals.$spacing-unit * 5); + right: calc(globals.$spacing-unit * 5); + background: rgba(255, 255, 255, 0.1); + border: none; + border-radius: 50%; + width: 40px; + height: 40px; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + color: white; + transition: background 0.2s ease; + z-index: 10; + pointer-events: auto; + + &:hover { + background: rgba(255, 255, 255, 0.2); + } + } + + &__content { + position: relative; + border-radius: 8px; + overflow: hidden; + border: 1px solid rgba(255, 255, 255, 0.1); + box-shadow: 0 8px 48px rgba(0, 0, 0, 0.5); + pointer-events: auto; + background: rgba(0, 0, 0, 0.5); + } + + &__loader { + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + background: rgba(0, 0, 0, 0.5); + z-index: 1; + } + + &__spinner { + width: 40px; + height: 40px; + border: 3px solid rgba(255, 255, 255, 0.2); + border-top-color: white; + border-radius: 50%; + animation: wrapped-spin 0.8s linear infinite; + } + + &__iframe { + width: 100%; + height: 100%; + border: none; + } +} + +@keyframes wrapped-spin { + to { + transform: rotate(360deg); + } +} diff --git a/src/renderer/src/pages/profile/profile-content/wrapped-tab.tsx b/src/renderer/src/pages/profile/profile-content/wrapped-tab.tsx new file mode 100644 index 00000000..a7ca2797 --- /dev/null +++ b/src/renderer/src/pages/profile/profile-content/wrapped-tab.tsx @@ -0,0 +1,94 @@ +import { useEffect, useState } from "react"; +import { XIcon } from "@primer/octicons-react"; +import "./wrapped-tab.scss"; + +interface WrappedFullscreenModalProps { + userId: string; + isOpen: boolean; + onClose: () => void; +} + +interface ScaleConfig { + scale: number; + width: number; + height: number; +} + +const SCALE_CONFIGS: Record = { + 0.25: { scale: 0.25, width: 270, height: 480 }, + 0.3: { scale: 0.3, width: 324, height: 576 }, + 0.5: { scale: 0.5, width: 540, height: 960 }, +}; + +const getScaleConfigForHeight = (height: number): ScaleConfig => { + if (height >= 1000) return SCALE_CONFIGS[0.5]; + if (height >= 650) return SCALE_CONFIGS[0.3]; + return SCALE_CONFIGS[0.25]; +}; + +export function WrappedFullscreenModal({ + userId, + isOpen, + onClose, +}: Readonly) { + const [config, setConfig] = useState(SCALE_CONFIGS[0.5]); + const [isLoading, setIsLoading] = useState(true); + + useEffect(() => { + if (!isOpen) return; + + const updateConfig = () => { + setConfig(getScaleConfigForHeight(window.innerHeight)); + }; + + updateConfig(); + window.addEventListener("resize", updateConfig); + return () => window.removeEventListener("resize", updateConfig); + }, [isOpen]); + + useEffect(() => { + if (isOpen) { + setIsLoading(true); + } + }, [isOpen]); + + if (!isOpen) return null; + + return ( + + + +
    + {isLoading && ( +
    +
    +
    + )} +