diff --git a/package.json b/package.json index 3c8b57f7..bb74198f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "hydralauncher", - "version": "3.7.5", + "version": "3.7.6", "description": "Hydra", "main": "./out/main/index.js", "author": "Los Broxas", @@ -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 ed8c7d4e..85a44236 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", diff --git a/src/locales/es/translation.json b/src/locales/es/translation.json index 4a582ef8..12dae377 100644 --- a/src/locales/es/translation.json +++ b/src/locales/es/translation.json @@ -458,6 +458,7 @@ "description_confirmation_delete_all_sources": "Vas a eliminar todas las fuentes de descargas", "button_delete_all_sources": "Eliminar todo", "added_download_source": "Añadir fuente de descarga", + "adding": "Añadiendo…", "download_sources_synced": "Todas las fuentes de descarga están sincronizadas", "insert_valid_json_url": "Introducí una URL de json válida", "found_download_option_zero": "Sin opciones de descargas encontrada", @@ -563,6 +564,19 @@ "debrid_description": "Los servicios Debrid son descargadores premium sin restricciones que te dejan descargar más rápido archivos alojados en servicios de alojamiento siendo que la única limitación es tu velocidad de internet.", "enable_friend_start_game_notifications": "Cuando un amigo está jugando un juego", "autoplay_trailers_on_game_page": "Reproducir trailers automáticamente en la página del juego", + "change_achievement_sound": "Cambiar sonido de logro", + "download_source_already_exists": "Esta fuente de descarga URL ya existe.", + "download_source_failed": "Error", + "download_source_matched": "Actualizado", + "download_source_matching": "Actualizando", + "download_source_no_information": "Sin información disponible", + "download_source_pending_matching": "Actualizando pronto", + "download_sources_synced_successfully": "Todas las fuentes de descarga están sincronizadas", + "failed_add_download_source": "Error al añadir la fuente de descarga. Por favor intentá de nuevo.", + "hydra_cloud": "Hydra Cloud", + "preview_sound": "Vista previa de sonido", + "remove_achievement_sound": "Eliminar sonido de logros", + "removed_all_download_sources": "Todas las fuentes de descarga eliminadas", "hide_to_tray_on_game_start": "Ocultar Hydra en la bandeja al iniciar un juego" }, "notifications": { 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..4375698f 100644 --- a/src/main/events/torrenting/start-game-download.ts +++ b/src/main/events/torrenting/start-game-download.ts @@ -82,6 +82,7 @@ const startGameDownload = async ( queued: true, extracting: false, automaticallyExtract, + extractionProgress: 0, }; try { diff --git a/src/main/main.ts b/src/main/main.ts index c176efa7..86bfb458 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..c208fa32 100644 --- a/src/main/services/download/download-manager.ts +++ b/src/main/services/download/download-manager.ts @@ -126,21 +126,10 @@ export class DownloadManager { } ); - if (WindowManager.mainWindow && download) { - WindowManager.mainWindow.setProgressBar(progress === 1 ? -1 : progress); - WindowManager.mainWindow.webContents.send( - "on-download-progress", - JSON.parse( - JSON.stringify({ - ...status, - game, - }) - ) - ); - } - const shouldExtract = download.automaticallyExtract; + // Handle download completion BEFORE sending progress to renderer + // This ensures extraction starts and DB is updated before UI reacts if (progress === 1 && download) { publishDownloadCompleteNotification(game); @@ -154,6 +143,7 @@ export class DownloadManager { shouldSeed: true, queued: false, extracting: shouldExtract, + extractionProgress: shouldExtract ? 0 : download.extractionProgress, }); } else { await downloadsSublevel.put(gameId, { @@ -162,12 +152,22 @@ export class DownloadManager { shouldSeed: false, queued: false, extracting: shouldExtract, + extractionProgress: shouldExtract ? 0 : download.extractionProgress, }); this.cancelDownload(gameId); } if (shouldExtract) { + // Send initial extraction progress BEFORE download progress + // This ensures the UI shows extraction immediately + WindowManager.mainWindow?.webContents.send( + "on-extraction-progress", + game.shop, + game.objectId, + 0 + ); + const gameFilesManager = new GameFilesManager( game.shop, game.objectId @@ -209,6 +209,18 @@ export class DownloadManager { this.downloadingGameId = null; } } + + // Send progress to renderer after completion handling + if (WindowManager.mainWindow && download) { + WindowManager.mainWindow.setProgressBar(progress === 1 ? -1 : progress); + WindowManager.mainWindow.webContents.send( + "on-download-progress", + structuredClone({ + ...status, + game, + }) + ); + } } } 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/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/hydra-api.ts b/src/main/services/hydra-api.ts index 7846571e..fa712105 100644 --- a/src/main/services/hydra-api.ts +++ b/src/main/services/hydra-api.ts @@ -58,7 +58,13 @@ export class HydraApi { const decodedBase64 = atob(payload as string); const jsonData = JSON.parse(decodedBase64); - const { accessToken, expiresIn, refreshToken } = jsonData; + const { + accessToken, + expiresIn, + refreshToken, + featurebaseJwt, + workwondersJwt, + } = jsonData; const now = new Date(); @@ -85,6 +91,8 @@ export class HydraApi { accessToken, refreshToken, tokenExpirationTimestamp, + featurebaseJwt, + workwondersJwt, }, { valueEncoding: "json" } ); 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 b11b4a9b..04c77619 100644 --- a/src/main/services/window-manager.ts +++ b/src/main/services/window-manager.ts @@ -138,7 +138,8 @@ export class WindowManager { (details, callback) => { if ( details.webContentsId !== this.mainWindow?.webContents.id || - details.url.includes("chatwoot") + details.url.includes("chatwoot") || + details.url.includes("workwonders") ) { return callback(details); } @@ -159,7 +160,8 @@ export class WindowManager { if ( details.webContentsId !== this.mainWindow?.webContents.id || details.url.includes("featurebase") || - details.url.includes("chatwoot") + details.url.includes("chatwoot") || + details.url.includes("workwonders") ) { return callback(details); } 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 ( @@ -122,10 +140,10 @@ export function BottomPanel() { + +
+ {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/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-hero/profile-hero.tsx b/src/renderer/src/pages/profile/profile-hero/profile-hero.tsx index fc354d01..f9109067 100644 --- a/src/renderer/src/pages/profile/profile-hero/profile-hero.tsx +++ b/src/renderer/src/pages/profile/profile-hero/profile-hero.tsx @@ -9,7 +9,12 @@ import { XCircleFillIcon, } from "@primer/octicons-react"; import { buildGameDetailsPath } from "@renderer/helpers"; -import { Avatar, Button, Link } from "@renderer/components"; +import { + Avatar, + Button, + FullscreenMediaModal, + Link, +} from "@renderer/components"; import { useTranslation } from "react-i18next"; import { useAppSelector, @@ -33,6 +38,7 @@ type FriendAction = export function ProfileHero() { const [showEditProfileModal, setShowEditProfileModal] = useState(false); + const [showFullscreenAvatar, setShowFullscreenAvatar] = useState(false); const [isPerformingAction, setIsPerformingAction] = useState(false); const { @@ -246,10 +252,12 @@ export function ProfileHero() { ]); const handleAvatarClick = useCallback(() => { - if (isMe) { + if (userProfile?.profileImageUrl) { + setShowFullscreenAvatar(true); + } else if (isMe) { setShowEditProfileModal(true); } - }, [isMe]); + }, [isMe, userProfile?.profileImageUrl]); const currentGame = useMemo(() => { if (isMe) { @@ -272,6 +280,13 @@ export function ProfileHero() { onClose={() => setShowEditProfileModal(false)} /> + setShowFullscreenAvatar(false)} + src={userProfile?.profileImageUrl} + alt={userProfile?.displayName} + /> +