This commit is contained in:
Kiwo.2
2025-12-14 15:05:52 +01:00
107 changed files with 4144 additions and 843 deletions

View File

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

View File

@@ -1,65 +0,0 @@
name: Bug Report
description: Create a report to help us improve. Write in English.
title: "[BUG] Write a title for your bug"
labels: ["bug"]
body:
- type: markdown
attributes:
value: |
Thank you for creating a bug report to help us improve!
- type: textarea
id: bug-description
attributes:
label: Describe the bug
description: A clear and concise description of what the bug is.
validations:
required: true
- type: textarea
id: bug-reproduce
attributes:
label: Steps to Reproduce
description: Steps to reproduce the behavior. For example, "1. Go to '...', 2. Click on '...', 3. See error"
validations:
required: true
- type: textarea
id: expected-behavior
attributes:
label: Expected behavior
description: A clear and concise description of what you expected to happen.
validations:
required: false
- type: textarea
id: additional-info
attributes:
label: Additional information and data
description: |
Add screenshots and upload your all logs file here.
Logs location on Windows: "%appdata%/hydralauncher/logs"
Logs location on Linux: "~/.config/hydralauncher/logs"
validations:
required: true
- type: input
id: OS
attributes:
label: Operating System
description: Which operating system are you using (e.g., Windows 11/Linux Distro/Steam Deck)?
validations:
required: true
- type: input
id: hydra-version
attributes:
label: Hydra Version
description: Please provide the version of Hydra you are using.
validations:
required: true
- type: checkboxes
id: terms
attributes:
label: Before opening this Issue
options:
- label: I have searched the issues of this repository and believe that this is not a duplicate.
required: true
- label: I am aware that Hydra team does not offer any support or help regarding the downloaded games.
required: true
- label: I have read the [Frequently Asked Questions (FAQ)](https://github.com/hydralauncher/hydra/wiki/FAQ).
required: true

View File

@@ -1,37 +0,0 @@
name: Feature Request
description: Request a new feature.
title: "[REQUEST] "
labels: ["enhancement"]
body:
- type: markdown
attributes:
value: |
Thank you for taking the time to suggest a new feature!
- type: textarea
id: problem-related
attributes:
label: Is your feature request related to a problem? Please describe.
description: A clear and concise description of what the problem is.
validations:
required: true
- type: textarea
id: solution
attributes:
label: Describe the solution you'd like
description: A clear and concise description of what you want to happen.
validations:
required: true
- type: textarea
id: alternatives
attributes:
label: Describe alternatives you've considered
description: A clear and concise description of any alternative solutions or features you've considered.
validations:
required: false
- type: textarea
id: additional-context
attributes:
label: Additional context
description: Add any other context or screenshots about the feature request here.
validations:
required: false

View File

@@ -2,11 +2,9 @@
**When submitting this pull request, I confirm the following (please check the boxes):**
- [ ] I have read and understood the [Contributor Guidelines](https://github.com/hydralauncher/hydra?tab=readme-ov-file#ways-you-can-contribute).
- [ ] I have read the [Hydra documentation](https://docs.hydralauncher.gg/getting-started.html).
- [ ] I have checked that there are no duplicate pull requests related to this request.
- [ ] I have considered, and confirm that this submission is valuable to others.
- [ ] I accept that this submission may not be used and the pull request may be closed at the discretion of the maintainers.
**Fill in the PR content:**
-

View File

@@ -137,7 +137,7 @@ jobs:
if git diff --staged --quiet; then
echo "No changes to commit"
else
COMMIT_MSG="v${{ steps.get-version.outputs.version }}"
COMMIT_MSG="${{ steps.get-version.outputs.version }}"
git commit -m "$COMMIT_MSG"

View File

@@ -1,6 +1,6 @@
<div align="center">
[<img src="./resources/icon.png" width="144"/>](https://help.hydralauncher.gg)
[<img src="https://raw.githubusercontent.com/hydralauncher/hydra/refs/heads/main/resources/icon.png" width="144"/>](https://help.hydralauncher.gg)
<h1 align="center">Hydra Launcher</h1>

View File

@@ -1,6 +1,6 @@
{
"name": "hydralauncher",
"version": "3.7.4",
"version": "3.7.6",
"description": "Hydra",
"main": "./out/main/index.js",
"author": "Los Broxas",
@@ -63,12 +63,14 @@
"embla-carousel-react": "^8.6.0",
"file-type": "^20.5.0",
"framer-motion": "^12.15.0",
"hls.js": "^1.5.12",
"i18next": "^23.11.2",
"i18next-browser-languagedetector": "^7.2.1",
"jsdom": "^24.0.0",
"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",
@@ -84,7 +86,7 @@
"sound-play": "^1.1.0",
"steam-shortcut-editor": "https://github.com/hydralauncher/steam-shortcut-editor",
"sudo-prompt": "^9.2.1",
"tar": "^7.4.3",
"tar": "^7.5.2",
"tough-cookie": "^5.1.1",
"user-agents": "^1.1.387",
"uuid": "^13.0.0",

View File

@@ -153,8 +153,11 @@ def profile_image():
data = request.get_json()
image_path = data.get('image_path')
# use webp as default value for target_extension
target_extension = data.get('target_extension') or 'webp'
try:
processed_image_path, mime_type = ProfileImageProcessor.process_image(image_path)
processed_image_path, mime_type = ProfileImageProcessor.process_image(image_path, target_extension)
return jsonify({'imagePath': processed_image_path, 'mimeType': mime_type}), 200
except Exception as e:
return jsonify({"error": str(e)}), 400

View File

@@ -4,7 +4,7 @@ import os, uuid, tempfile
class ProfileImageProcessor:
@staticmethod
def get_parsed_image_data(image_path):
def get_parsed_image_data(image_path, target_extension):
Image.MAX_IMAGE_PIXELS = 933120000
image = Image.open(image_path)
@@ -16,7 +16,7 @@ class ProfileImageProcessor:
return image_path, mime_type
else:
new_uuid = str(uuid.uuid4())
new_image_path = os.path.join(tempfile.gettempdir(), new_uuid) + ".webp"
new_image_path = os.path.join(tempfile.gettempdir(), new_uuid) + "." + target_extension
image.save(new_image_path)
new_image = Image.open(new_image_path)
@@ -26,5 +26,5 @@ class ProfileImageProcessor:
@staticmethod
def process_image(image_path):
return ProfileImageProcessor.get_parsed_image_data(image_path)
def process_image(image_path, target_extension):
return ProfileImageProcessor.get_parsed_image_data(image_path, target_extension)

View File

@@ -94,6 +94,12 @@
"header": {
"search": "Search games",
"search_library": "Search library",
"recent_searches": "Recent Searches",
"suggestions": "Suggestions",
"clear_history": "Clear",
"remove_from_history": "Remove from history",
"loading": "Loading...",
"no_results": "No results",
"home": "Home",
"catalogue": "Catalogue",
"library": "Library",
@@ -109,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"
@@ -196,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",
@@ -408,7 +416,11 @@
"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"
},
"settings": {
"downloads_path": "Downloads path",

View File

@@ -93,8 +93,16 @@
},
"header": {
"search": "Buscar juegos",
"search_library": "Buscar en la librería",
"recent_searches": "Búsquedas Recientes",
"suggestions": "Sugerencias",
"clear_history": "Limpiar",
"remove_from_history": "Eliminar del historial",
"loading": "Cargando...",
"no_results": "Sin resultados",
"home": "Inicio",
"catalogue": "Catálogo",
"library": "Librería",
"downloads": "Descargas",
"search_results": "Resultados de búsqueda",
"settings": "Ajustes",
@@ -450,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",
@@ -555,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": {

View File

@@ -93,11 +93,19 @@
},
"header": {
"search": "Buscar jogos",
"search_library": "Buscar na biblioteca",
"recent_searches": "Buscas Recentes",
"suggestions": "Sugestões",
"clear_history": "Limpar",
"remove_from_history": "Remover do histórico",
"loading": "Carregando...",
"no_results": "Sem resultados",
"home": "Início",
"catalogue": "Catálogo",
"library": "Biblioteca",
"downloads": "Downloads",
"search_results": "Resultados da busca",
"settings": "Ajustes",
"home": "Início",
"version_available_install": "Versão {{version}} disponível. Clique aqui para reiniciar e instalar.",
"version_available_download": "Versão {{version}} disponível. Clique aqui para fazer o download."
},
@@ -107,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"
@@ -182,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",
@@ -394,7 +404,11 @@
"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"
},
"settings": {
"downloads_path": "Diretório dos downloads",

View File

@@ -30,11 +30,19 @@
},
"header": {
"search": "Procurar jogos",
"search_library": "Procurar na biblioteca",
"recent_searches": "Pesquisas Recentes",
"suggestions": "Sugestões",
"clear_history": "Limpar",
"remove_from_history": "Remover do histórico",
"loading": "A carregar...",
"no_results": "Sem resultados",
"home": "Início",
"catalogue": "Catálogo",
"library": "Biblioteca",
"downloads": "Transferências",
"search_results": "Resultados da pesquisa",
"settings": "Definições",
"home": "Início",
"version_available_install": "Versão {{version}} disponível. Clica aqui para reiniciar e instalar.",
"version_available_download": "Versão {{version}} disponível. Clica aqui para fazer o download."
},

View File

@@ -93,8 +93,16 @@
},
"header": {
"search": "Поиск",
"search_library": "Поиск в библиотеке",
"recent_searches": "Недавние поиски",
"suggestions": "Предложения",
"clear_history": "Очистить",
"remove_from_history": "Удалить из истории",
"loading": "Загрузка...",
"no_results": "Нет результатов",
"home": "Главная",
"catalogue": "Каталог",
"library": "Библиотека",
"downloads": "Загрузки",
"search_results": "Результаты поиска",
"settings": "Настройки",

View File

@@ -16,6 +16,7 @@
"downloads": "İndirilenler",
"settings": "Ayarlar",
"my_library": "Kütüphanem",
"library": "Kütüphane",
"downloading_metadata": "{{title}} (Meta verileri indiriliyor…)",
"paused": "{{title}} (Duraklatıldı)",
"downloading": "{{title}} (%{{percentage}} - İndiriliyor…)",
@@ -26,7 +27,69 @@
"sign_in": "Giriş Yap",
"friends": "Arkadaşlar",
"need_help": "Yardıma mı ihtiyacınız var?",
"favorites": "Favoriler"
"favorites": "Favoriler",
"playable_button_title": "Şu anda oynayabileceğin oyunları göster",
"add_custom_game_tooltip": "Özel Oyun Ekle",
"show_playable_only_tooltip": "Sadece Oynanabilirleri Göster",
"custom_game_modal": "Özel Oyun Ekle",
"custom_game_modal_description": "Çalıştırılabilir bir dosya seçerek kütüphanene özel oyun ekle",
"custom_game_modal_executable_path": "Çalıştırılabilir Dosya Yolu",
"custom_game_modal_select_executable": "Çalıştırılabilir dosya seç",
"custom_game_modal_title": "Başlık",
"custom_game_modal_enter_title": "Başlık gir",
"custom_game_modal_browse": "Gözat",
"custom_game_modal_cancel": "İptal",
"custom_game_modal_add": "Oyun Ekle",
"custom_game_modal_adding": "Oyun Ekleniyor...",
"custom_game_modal_success": "Özel oyun başarıyla eklendi",
"custom_game_modal_failed": "Özel oyun eklenemedi",
"custom_game_modal_executable": "Çalıştırılabilir",
"edit_game_modal": "Varlıkları Özelleştir",
"edit_game_modal_description": "Oyun varlıklarını ve detaylarını özelleştir",
"edit_game_modal_title": "Başlık",
"edit_game_modal_enter_title": "Başlık gir",
"edit_game_modal_image": "Görsel",
"edit_game_modal_select_image": "Görsel seç",
"edit_game_modal_browse": "Gözat",
"edit_game_modal_image_preview": "Görsel önizleme",
"edit_game_modal_icon": "İkon",
"edit_game_modal_select_icon": "İkon seç",
"edit_game_modal_icon_preview": "İkon önizleme",
"edit_game_modal_logo": "Logo",
"edit_game_modal_select_logo": "Logo seç",
"edit_game_modal_logo_preview": "Logo önizleme",
"edit_game_modal_hero": "Kütüphane Hero",
"edit_game_modal_select_hero": "Kütüphane hero görseli seç",
"edit_game_modal_hero_preview": "Kütüphane hero görseli önizleme",
"edit_game_modal_cancel": "İptal et",
"edit_game_modal_update": "Güncelle",
"edit_game_modal_updating": "Güncelleniyor...",
"edit_game_modal_fill_required": "Lütfen tüm gerekli alanları doldur",
"edit_game_modal_success": "Varlıklar başarıyla güncellendi",
"edit_game_modal_failed": "Varlıklar güncellenemedi",
"edit_game_modal_image_filter": "Görsel",
"edit_game_modal_icon_resolution": "Önerilen çözünürlük: 256x256px",
"edit_game_modal_logo_resolution": "Önerilen çözünürlük: 640x360px",
"edit_game_modal_hero_resolution": "Önerilen çözünürlük: 1920x620px",
"edit_game_modal_assets": "Varlıklar",
"edit_game_modal_drop_icon_image_here": "İkon görselini buraya bırak",
"edit_game_modal_drop_logo_image_here": "Logo görselini buraya bırak",
"edit_game_modal_drop_hero_image_here": "Hero görselini buraya bırak",
"edit_game_modal_drop_to_replace_icon": "İkonu değiştirmek için buraya bırak",
"edit_game_modal_drop_to_replace_logo": "Logoyu değiştirmek için buraya bırak",
"edit_game_modal_drop_to_replace_hero": "Hero'yu değiştirmek için buraya bırak",
"install_decky_plugin": "Decky Plugin Kur",
"update_decky_plugin": "Decky Plugin Güncelle",
"decky_plugin_installed_version": "Decky Plugin (v{{version}})",
"install_decky_plugin_title": "Hydra Decky Plugin Kur",
"install_decky_plugin_message": "Bu işlem Decky Loader için Hydra plugin'ini indirecek ve kuracak. Bu işlem yükseltilmiş izinler gerektirebilir. Devam et?",
"update_decky_plugin_title": "Hydra Decky Plugin Güncelle",
"update_decky_plugin_message": "Hydra Decky plugin'inin yeni bir sürümü mevcut. Şimdi güncellemek ister misin?",
"decky_plugin_installed": "Decky plugin v{{version}} başarıyla kuruldu",
"decky_plugin_installation_failed": "Decky plugin kurulamadı: {{error}}",
"decky_plugin_installation_error": "Decky plugin kurulumu hatası: {{error}}",
"confirm": "Onayla",
"cancel": "İptal"
},
"header": {
"search": "Oyunlarda Ara",
@@ -35,6 +98,8 @@
"downloads": "İndirilenler",
"search_results": "Arama Sonuçları",
"settings": "Ayarlar",
"search_library": "Kütüphanede ara",
"library": "Kütüphane",
"version_available_install": "{{version}} sürümü mevcut. Yeniden başlatıp yüklemek için tıklayın.",
"version_available_download": "{{version}} sürümü mevcut. İndirmek için tıklayın."
},
@@ -203,7 +268,108 @@
"create_start_menu_shortcut": "Başlat Menüsüne kısayol oluştur",
"invalid_wine_prefix_path": "Geçersiz Wine ön ek yolu",
"invalid_wine_prefix_path_description": "Wine ön ek yolu hatalı. Lütfen yolu kontrol edin ve tekrar deneyin.",
"missing_wine_prefix": "Linux'ta yedekleme oluşturmak için Wine ön eki gereklidir"
"missing_wine_prefix": "Linux'ta yedekleme oluşturmak için Wine ön eki gereklidir",
"already_in_library": "Zaten kütüphanede",
"create_shortcut_simple": "Kısayol oluştur",
"properties": "Özellikler",
"new_download_option": "Yeni",
"add_to_favorites": "Favorilere ekle",
"remove_from_favorites": "Favorilerden çıkar",
"failed_update_favorites": "Favoriler güncellenemedi",
"game_removed_from_library": "Oyun kütüphaneden çıkarıldı",
"failed_remove_from_library": "Kütüphaneden çıkarılamadı",
"files_removed_success": "Dosyalar başarıyla kaldırıldı",
"failed_remove_files": "Dosyalar kaldırılamadı",
"rating_count": "Puan",
"show_more": "Daha fazla göster",
"show_less": "Daha az göster",
"reviews": "İncelemeler",
"review_played_for": "Oynama süresi",
"leave_a_review": "İnceleme Yap",
"write_review_placeholder": "Bu oyun hakkındaki düşüncelerini paylaş...",
"sort_newest": "En yeni",
"no_reviews_yet": "Henüz inceleme yok",
"be_first_to_review": "Bu oyun hakkındaki düşüncelerini paylaşan ilk kişi ol!",
"sort_oldest": "En eski",
"sort_highest_score": "En yüksek puan",
"sort_lowest_score": "En düşük puan",
"sort_most_voted": "En çok oy",
"rating": "Puan",
"rating_stats": "Puan",
"rating_very_negative": "Çok Olumsuz",
"rating_negative": "Olumsuz",
"rating_neutral": "Nötr",
"rating_positive": "Olumlu",
"rating_very_positive": "Çok Olumlu",
"submit_review": "Gönder",
"submitting": "Gönderiliyor...",
"review_submitted_successfully": "İnceleme başarıyla gönderildi!",
"review_submission_failed": "İnceleme gönderilemedi. Lütfen tekrar dene.",
"review_cannot_be_empty": "İnceleme metin alanı boş olamaz.",
"review_deleted_successfully": "İnceleme başarıyla silindi.",
"review_deletion_failed": "İnceleme silinemedi. Lütfen tekrar dene.",
"loading_reviews": "İncelemeler yükleniyor...",
"loading_more_reviews": "Daha fazla inceleme yükleniyor...",
"load_more_reviews": "Daha fazla inceleme yükle",
"you_seemed_to_enjoy_this_game": "Bu oyunu beğenmiş görünüyorsun",
"would_you_recommend_this_game": "Bu oyun hakkında bir inceleme yazmak ister misin?",
"yes": "Evet",
"maybe_later": "Belki sonra",
"backup_failed": "Yedekleme başarısız",
"update_playtime_title": "Oynama süresini güncelle",
"update_playtime_description": "{{game}} için oynama süresini manuel olarak güncelle",
"update_playtime": "Oynama süresini güncelle",
"update_playtime_success": "Oynama süresi başarıyla güncellendi",
"update_playtime_error": "Oynama süresi güncellenemedi",
"update_game_playtime": "Oyun oynama süresini güncelle",
"manual_playtime_warning": "Saatlerin manuel olarak güncellendiği işaretlenecek ve bu geri alınamaz.",
"manual_playtime_tooltip": "Bu oynama süresi manuel olarak güncellendi",
"game_removed_from_pinned": "Oyun sabitlenmişlerden çıkarıldı",
"game_added_to_pinned": "Oyun sabitlenmişlere eklendi",
"artifact_renamed": "Yedekleme başarıyla yeniden adlandırıldı",
"rename_artifact": "Yedeklemeyi Yeniden Adlandır",
"rename_artifact_description": "Yedeklemeyi daha açıklayıcı bir isimle yeniden adlandır",
"artifact_name_label": "Yedekleme adı",
"artifact_name_placeholder": "Yedekleme için bir isim gir",
"save_changes": "Değişiklikleri kaydet",
"required_field": "Bu alan gereklidir",
"max_length_field": "Bu alan {{length}} karakterden az olmalıdır",
"freeze_backup": "Otomatik yedeklemeler tarafından üzerine yazılmasın diye sabitle",
"unfreeze_backup": "Sabitlemeyi kaldır",
"backup_frozen": "Yedekleme sabitlendi",
"backup_unfrozen": "Yedekleme sabitlemesi kaldırıldı",
"backup_freeze_failed": "Yedekleme sabitlenemedi",
"backup_freeze_failed_description": "Otomatik yedeklemeler için en az bir boş alan bırakmalısın",
"edit_game_modal_button": "Oyun varlıklarını özelleştir",
"game_details": "Oyun Detayları",
"currency_symbol": "₺",
"currency_country": "tr",
"prices": "Fiyatlar",
"no_prices_found": "Fiyat bulunamadı",
"view_all_prices": "Tüm fiyatları görüntülemek için tıkla",
"retail_price": "Perakende fiyatı",
"keyshop_price": "Anahtar dükkanı fiyatı",
"historical_retail": "Geçmiş perakende",
"historical_keyshop": "Geçmiş anahtar dükkanı",
"language": "Dil",
"caption": "Altyazı",
"audio": "Ses",
"filter_by_source": "Kaynağa göre filtrele",
"no_repacks_found": "Bu oyun için kaynak bulunamadı",
"delete_review": "İncelemeyi sil",
"remove_review": "İncelemeyi Kaldır",
"delete_review_modal_title": "İncelemeni silmek istediğinden emin misin?",
"delete_review_modal_description": "Bu işlem geri alınamaz.",
"delete_review_modal_delete_button": "Sil",
"delete_review_modal_cancel_button": "İptal",
"vote_failed": "Oyun kaydı başarısız oldu. Lütfen tekrar dene.",
"show_original": "Orijinali göster",
"show_translation": "Çeviriyi göster",
"show_original_translated_from": "Orijinali göster ({{language}} dilinden çevrilmiştir)",
"hide_original": "Orijinali gizle",
"review_from_blocked_user": "Engellenen kullanıcıdan gelen inceleme",
"show": "Göster",
"hide": "Gizle"
},
"activation": {
"title": "Hydra'yı Etkinleştir",
@@ -379,7 +545,33 @@
"hidden": "Gizli",
"test_notification": "Test bildirimi",
"notification_preview": "Başarı Bildirimi Önizlemesi",
"enable_friend_start_game_notifications": "Bir arkadaşınız oyun oynamaya başladığında"
"enable_friend_start_game_notifications": "Bir arkadaşınız oyun oynamaya başladığında",
"adding": "Ekleniyor…",
"failed_add_download_source": "İndirme kaynağı eklenemedi. Lütfen tekrar dene.",
"download_source_already_exists": "Bu indirme kaynağı URL'si zaten mevcut.",
"download_source_pending_matching": "Yakında güncellenecek",
"download_source_matched": "Güncel",
"download_source_matching": "Güncelleniyor",
"download_source_failed": "Hata",
"download_source_no_information": "Bilgi mevcut değil",
"removed_all_download_sources": "Tüm indirme kaynakları kaldırıldı",
"download_sources_synced_successfully": "Tüm indirme kaynakları senkronize edildi",
"importing": "İçe aktarılıyor...",
"hydra_cloud": "Hydra Cloud",
"debrid": "Debrid",
"debrid_description": "Debrid servisleri, internet hızınızla sınırlı, çeşitli dosya barındırma hizmetlerinde barındırılan dosyaları hızla indirmenize olanak tanıyan premium sınırsız indiricilerdir.",
"enable_steam_achievements": "Steam başarımları aramasını etkinleştir",
"achievement_sound_volume": "Başarım ses seviyesi",
"select_achievement_sound": "Başarım sesi seç",
"change_achievement_sound": "Başarım sesini değiştir",
"remove_achievement_sound": "Başarım sesini kaldır",
"preview_sound": "Sesi önizle",
"select": "Seç",
"preview": "Önizle",
"remove": "Kaldır",
"no_sound_file_selected": "Ses dosyası seçilmedi",
"autoplay_trailers_on_game_page": "Oyun sayfasında fragmanları otomatik olarak oynat",
"hide_to_tray_on_game_start": "Oyun başlatıldığında Hydra'yı sistem tepsisine gizle"
},
"notifications": {
"download_complete": "İndirme tamamlandı",
@@ -406,7 +598,8 @@
"game_card": {
"available_one": "Mevcut",
"available_other": "Mevcut",
"no_downloads": "İndirme mevcut değil"
"no_downloads": "İndirme mevcut değil",
"calculating": "Hesaplanıyor"
},
"binary_not_found_modal": {
"title": "Programlar Yüklü Değil",
@@ -498,7 +691,46 @@
"achievements_unlocked": "Açılan başarımlar",
"earned_points": "Kazanılan puanlar",
"show_achievements_on_profile": "Başarımlarını profilinde göster",
"show_points_on_profile": "Kazanılan puanlarını profilinde göster"
"show_points_on_profile": "Kazanılan puanlarını profilinde göster",
"amount_hours_short": "{{amount}}s",
"amount_minutes_short": "{{amount}}d",
"pinned": "Sabitlenmiş",
"sort_by": "Sırala:",
"achievements_earned": "Kazanılan başarımlar",
"played_recently": "Son oynanan",
"playtime": "Oynama süresi",
"manual_playtime_tooltip": "Bu oynama süresi manuel olarak güncellendi",
"error_adding_friend": "Arkadaş isteği gönderilemedi. Lütfen arkadaş kodunu kontrol et",
"friend_code_length_error": "Arkadaş kodu 8 karakter olmalıdır",
"game_removed_from_pinned": "Oyun sabitlenmişlerden çıkarıldı",
"game_added_to_pinned": "Oyun sabitlenmişlere eklendi",
"karma": "Karma",
"karma_count": "karma",
"karma_description": "İncelemelerdeki olumlu beğenilerden kazanılır",
"user_reviews": "İncelemeler",
"delete_review": "İncelemeyi Sil",
"loading_reviews": "İncelemeler yükleniyor..."
},
"library": {
"library": "Kütüphane",
"play": "Oyna",
"download": "İndir",
"downloading": "İndiriliyor",
"game": "oyun",
"games": "oyunlar",
"grid_view": "Izgara görünümü",
"compact_view": "Kompakt görünüm",
"large_view": "Büyük görünüm",
"no_games_title": "Kütüphanen boş",
"no_games_description": "Başlamak için katalogdan oyun ekle veya indir",
"amount_hours": "{{amount}} saat",
"amount_minutes": "{{amount}} dakika",
"amount_hours_short": "{{amount}}s",
"amount_minutes_short": "{{amount}}d",
"manual_playtime_tooltip": "Bu oynama süresi manuel olarak güncellendi",
"all_games": "Tüm Oyunlar",
"recently_played": "Son Oynanan",
"favorites": "Favoriler"
},
"achievement": {
"achievement_unlocked": "Başarım açıldı",

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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);

View File

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

View File

@@ -2,6 +2,7 @@ import type { LibraryGame } from "@types";
import { registerEvent } from "../register-event";
import {
downloadsSublevel,
gameAchievementsSublevel,
gamesShopAssetsSublevel,
gamesSublevel,
} from "@main/level";
@@ -18,11 +19,20 @@ const getLibrary = async (): Promise<LibraryGame[]> => {
const download = await downloadsSublevel.get(key);
const gameAssets = await gamesShopAssetsSublevel.get(key);
let unlockedAchievementCount = game.unlockedAchievementCount ?? 0;
if (!game.unlockedAchievementCount) {
const achievements = await gameAchievementsSublevel.get(key);
unlockedAchievementCount =
achievements?.unlockedAchievements.length ?? 0;
}
return {
id: key,
...game,
download: download ?? null,
unlockedAchievementCount: game.unlockedAchievementCount ?? 0,
unlockedAchievementCount,
achievementCount: game.achievementCount ?? 0,
// Spread gameAssets last to ensure all image URLs are properly set
...gameAssets,

View File

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

View File

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

View File

@@ -0,0 +1,3 @@
import "./publish-new-repacks-notification";
import "./show-achievement-test-notification";
import "./update-achievement-notification-window";

View File

@@ -0,0 +1,4 @@
import "./get-me";
import "./process-profile-image";
import "./sync-friend-requests";
import "./update-profile";

View File

@@ -1,16 +1,20 @@
import { registerEvent } from "../register-event";
import { PythonRPC } from "@main/services/python-rpc";
const processProfileImage = async (
const processProfileImageEvent = async (
_event: Electron.IpcMainInvokeEvent,
path: string
) => {
return processProfileImage(path, "webp");
};
export const processProfileImage = async (path: string, extension?: string) => {
return PythonRPC.rpc
.post<{
imagePath: string;
mimeType: string;
}>("/profile-image", { image_path: path })
}>("/profile-image", { image_path: path, target_extension: extension })
.then((response) => response.data);
};
registerEvent("processProfileImage", processProfileImage);
registerEvent("processProfileImage", processProfileImageEvent);

View File

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

View File

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

View File

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

View File

@@ -82,6 +82,7 @@ const startGameDownload = async (
queued: true,
extracting: false,
automaticallyExtract,
extractionProgress: 0,
};
try {

View File

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

View File

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

View File

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

View File

@@ -1,5 +1,5 @@
import { downloadsSublevel } from "./level/sublevels/downloads";
import { sortBy } from "lodash-es";
import { orderBy } from "lodash-es";
import { Downloader } from "@shared";
import { levelKeys, db } from "./level";
import type { UserPreferences } from "@types";
@@ -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);
@@ -68,7 +66,7 @@ export const loadState = async () => {
.values()
.all()
.then((games) => {
return sortBy(games, "timestamp", "DESC");
return orderBy(games, "timestamp", "desc");
});
downloads.forEach((download) => {

View File

@@ -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<ExtractionResult> {
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<string[]> {
return new Promise((resolve, reject) => {
const files: string[] = [];
const options: CommandLineSwitches = {
$bin: this.binaryPath,
password: password || undefined,
};
const stream = Seven.list(filePath, options);
stream.on("data", (data) => {
if (data.file) {
files.push(data.file);
}
});
};
tryPassword();
stream.on("end", () => {
resolve(files);
});
stream.on("error", (err) => {
reject(err);
});
});
}
}

View File

@@ -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,

View File

@@ -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 {

View File

@@ -20,7 +20,7 @@ import { RealDebridClient } from "./real-debrid";
import path from "path";
import { logger } from "../logger";
import { db, downloadsSublevel, gamesSublevel, levelKeys } from "@main/level";
import { sortBy } from "lodash-es";
import { orderBy } from "lodash-es";
import { TorBoxClient } from "./torbox";
import { GameFilesManager } from "../game-files-manager";
import { HydraDebridClient } from "./hydra-debrid";
@@ -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
@@ -194,10 +194,10 @@ export class DownloadManager {
.values()
.all()
.then((games) => {
return sortBy(
return orderBy(
games.filter((game) => game.status === "paused" && game.queued),
"timestamp",
"DESC"
"desc"
);
});
@@ -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,
})
);
}
}
}

View File

@@ -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;
}

View File

@@ -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,
},
});

View File

@@ -30,7 +30,7 @@ export class HydraApi {
private static instance: AxiosInstance;
private static readonly EXPIRATION_OFFSET_IN_MS = 1000 * 60 * 5; // 5 minutes
private static readonly ADD_LOG_INTERCEPTOR = true;
private static readonly ADD_LOG_INTERCEPTOR = false;
private static secondsToMilliseconds(seconds: number) {
return seconds * 1000;
@@ -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" }
);

87
src/main/services/node-7z.d.ts vendored Normal file
View File

@@ -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<string, unknown>;
_childProcess?: ChildProcess;
}
export function extractFull(
archive: string,
output: string,
options?: CommandLineSwitches
): ZipStream;
export function extract(
archive: string,
output: string,
options?: CommandLineSwitches
): ZipStream;
export function list(
archive: string,
options?: CommandLineSwitches
): ZipStream;
export function add(
archive: string,
files: string | string[],
options?: CommandLineSwitches
): ZipStream;
export function update(
archive: string,
files: string | string[],
options?: CommandLineSwitches
): ZipStream;
export function deleteFiles(
archive: string,
files: string | string[],
options?: CommandLineSwitches
): ZipStream;
export function test(
archive: string,
options?: CommandLineSwitches
): ZipStream;
const Seven: {
extractFull: typeof extractFull;
extract: typeof extract;
list: typeof list;
add: typeof add;
update: typeof update;
delete: typeof deleteFiles;
test: typeof test;
};
export default Seven;
}

View File

@@ -15,6 +15,13 @@ import { db, levelKeys, themesSublevel } from "@main/level";
import { restartAndInstallUpdate } from "@main/events/autoupdater/restart-and-install-update";
import { SystemPath } from "../system-path";
import { getThemeSoundPath } from "@main/helpers";
import { processProfileImage } from "@main/events/profile/process-profile-image";
const getStaticImage = async (path: string) => {
return processProfileImage(path, "jpg")
.then((response) => response.imagePath)
.catch(() => path);
};
async function downloadImage(url: string | null) {
if (!url) return undefined;
@@ -31,8 +38,9 @@ async function downloadImage(url: string | null) {
response.data.pipe(writer);
return new Promise<string | undefined>((resolve) => {
writer.on("finish", () => {
resolve(outputPath);
writer.on("finish", async () => {
const staticImagePath = await getStaticImage(outputPath);
resolve(staticImagePath);
});
writer.on("error", () => {
logger.error("Failed to download image", { url });

View File

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

View File

@@ -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);
}

View File

@@ -8,9 +8,11 @@ export const friendRequestEvent = async (payload: FriendRequest) => {
friendRequestCount: payload.friendRequestCount,
});
const user = await HydraApi.get(`/users/${payload.senderId}`);
if (payload.senderId) {
const user = await HydraApi.get(`/users/${payload.senderId}`);
if (user) {
publishNewFriendRequestNotification(user);
if (user) {
publishNewFriendRequestNotification(user);
}
}
};

View File

@@ -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) =>
@@ -619,4 +642,28 @@ contextBridge.exposeInMainWorld("electron", {
},
closeEditorWindow: (themeId?: string) =>
ipcRenderer.invoke("closeEditorWindow", themeId),
/* LevelDB Generic CRUD */
leveldb: {
get: (
key: string,
sublevelName?: string | null,
valueEncoding?: "json" | "utf8"
) => ipcRenderer.invoke("leveldbGet", key, sublevelName, valueEncoding),
put: (
key: string,
value: unknown,
sublevelName?: string | null,
valueEncoding?: "json" | "utf8"
) =>
ipcRenderer.invoke("leveldbPut", key, value, sublevelName, valueEncoding),
del: (key: string, sublevelName?: string | null) =>
ipcRenderer.invoke("leveldbDel", key, sublevelName),
clear: (sublevelName: string) =>
ipcRenderer.invoke("leveldbClear", sublevelName),
values: (sublevelName: string) =>
ipcRenderer.invoke("leveldbValues", sublevelName),
iterator: (sublevelName: string) =>
ipcRenderer.invoke("leveldbIterator", sublevelName),
},
});

View File

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

View File

@@ -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,
@@ -31,6 +34,8 @@ import {
getAchievementSoundUrl,
getAchievementSoundVolume,
} from "./helpers";
import { levelDBService } from "./services/leveldb.service";
import type { UserPreferences } from "@types";
import "./app.scss";
export interface AppProps {
@@ -76,12 +81,17 @@ export function App() {
const { showSuccessToast } = useToast();
const [showArchiveDeletionModal, setShowArchiveDeletionModal] =
useState(false);
const [archivePaths, setArchivePaths] = useState<string[]>([]);
useEffect(() => {
Promise.all([window.electron.getUserPreferences(), updateLibrary()]).then(
([preferences]) => {
dispatch(setUserPreferences(preferences));
}
);
Promise.all([
levelDBService.get("userPreferences", null, "json"),
updateLibrary(),
]).then(([preferences]) => {
dispatch(setUserPreferences(preferences as UserPreferences | null));
});
}, [navigate, location.pathname, dispatch, updateLibrary]);
useEffect(() => {
@@ -181,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;
@@ -204,7 +225,11 @@ export function App() {
}, [dispatch, draggingDisabled]);
const loadAndApplyTheme = useCallback(async () => {
const activeTheme = await window.electron.getActiveCustomTheme();
const allThemes = (await levelDBService.values("themes")) as {
isActive?: boolean;
code?: string;
}[];
const activeTheme = allThemes.find((theme) => theme.isActive);
if (activeTheme?.code) {
injectCustomCss(activeTheme.code);
} else {
@@ -274,6 +299,12 @@ export function App() {
feature={hydraCloudFeature}
/>
<ArchiveDeletionModal
visible={showArchiveDeletionModal}
archivePaths={archivePaths}
onClose={() => setShowArchiveDeletionModal(false)}
/>
{userDetails && (
<UserFriendModal
visible={isFriendsModalVisible}

View File

@@ -2,6 +2,7 @@ import { useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import {
useAppSelector,
useDownload,
useLibrary,
useToast,
@@ -26,6 +27,8 @@ export function BottomPanel() {
const { lastPacket, progress, downloadSpeed, eta } = useDownload();
const extraction = useAppSelector((state) => state.download.extraction);
const [version, setVersion] = useState("");
const [sessionHash, setSessionHash] = useState<null | string>("");
const [commonRedistStatus, setCommonRedistStatus] = useState<string | null>(
@@ -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() {
</button>
<button
data-featurebase-changelog
data-open-workwonders-changelog-mini
className="bottom-panel__version-button"
>
<small data-featurebase-changelog>
<small>
{sessionHash ? `${sessionHash} -` : ""} v{version} &quot;
{VERSION_CODENAME}&quot;
</small>

View File

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

View File

@@ -3,12 +3,20 @@ import { useEffect, useMemo, useRef, useState } from "react";
import { useLocation, useNavigate } from "react-router-dom";
import { ArrowLeftIcon, SearchIcon, XIcon } from "@primer/octicons-react";
import { useAppDispatch, useAppSelector } from "@renderer/hooks";
import {
useAppDispatch,
useAppSelector,
useSearchHistory,
useSearchSuggestions,
} from "@renderer/hooks";
import "./header.scss";
import { AutoUpdateSubHeader } from "./auto-update-sub-header";
import { setFilters, setLibrarySearchQuery } from "@renderer/features";
import cn from "classnames";
import { SearchDropdown } from "@renderer/components";
import { buildGameDetailsPath } from "@renderer/helpers";
import type { GameShop } from "@types";
const pathTitle: Record<string, string> = {
"/": "home",
@@ -20,6 +28,7 @@ const pathTitle: Record<string, string> = {
export function Header() {
const inputRef = useRef<HTMLInputElement>(null);
const searchContainerRef = useRef<HTMLDivElement>(null);
const navigate = useNavigate();
const location = useLocation();
@@ -37,6 +46,7 @@ export function Header() {
);
const isOnLibraryPage = location.pathname.startsWith("/library");
const isOnCataloguePage = location.pathname.startsWith("/catalogue");
const searchValue = isOnLibraryPage
? librarySearchValue
@@ -45,9 +55,29 @@ export function Header() {
const dispatch = useAppDispatch();
const [isFocused, setIsFocused] = useState(false);
const [isDropdownVisible, setIsDropdownVisible] = useState(false);
const [activeIndex, setActiveIndex] = useState(-1);
const [dropdownPosition, setDropdownPosition] = useState({
x: 0,
y: 0,
});
const { t } = useTranslation("header");
const { addToHistory, removeFromHistory, clearHistory, getRecentHistory } =
useSearchHistory();
const { suggestions, isLoading: isLoadingSuggestions } = useSearchSuggestions(
searchValue,
isOnLibraryPage,
isDropdownVisible && isFocused && !isOnCataloguePage
);
const historyItems = getRecentHistory(
isOnLibraryPage ? "library" : "catalogue",
3
);
const title = useMemo(() => {
if (location.pathname.startsWith("/game")) return headerTitle;
if (location.pathname.startsWith("/achievements")) return headerTitle;
@@ -59,13 +89,43 @@ export function Header() {
return t(pathTitle[location.pathname]);
}, [location.pathname, headerTitle, t]);
const totalItems = historyItems.length + suggestions.length;
const updateDropdownPosition = () => {
if (searchContainerRef.current) {
const rect = searchContainerRef.current.getBoundingClientRect();
setDropdownPosition({
x: rect.left,
y: rect.bottom,
});
}
};
const focusInput = () => {
setIsFocused(true);
inputRef.current?.focus();
};
const handleFocus = () => {
if (isFocused && isDropdownVisible) {
updateDropdownPosition();
return;
}
setIsFocused(true);
setActiveIndex(-1);
setTimeout(() => {
updateDropdownPosition();
setIsDropdownVisible(true);
}, 220);
};
const handleBlur = () => {
setIsFocused(false);
setTimeout(() => {
setIsFocused(false);
setIsDropdownVisible(false);
setActiveIndex(-1);
}, 200);
};
const handleBackButtonClick = () => {
@@ -77,10 +137,37 @@ export function Header() {
dispatch(setLibrarySearchQuery(value.slice(0, 255)));
} else {
dispatch(setFilters({ title: value.slice(0, 255) }));
if (!location.pathname.startsWith("/catalogue")) {
navigate("/catalogue");
}
}
setActiveIndex(-1);
};
const executeSearch = (query: string) => {
const context = isOnLibraryPage ? "library" : "catalogue";
if (query.trim()) {
addToHistory(query, context);
}
handleSearch(query);
if (!isOnLibraryPage && !location.pathname.startsWith("/catalogue")) {
navigate("/catalogue");
}
setIsDropdownVisible(false);
inputRef.current?.blur();
};
const handleSelectHistory = (query: string) => {
executeSearch(query);
};
const handleSelectSuggestion = (suggestion: {
title: string;
objectId: string;
shop: GameShop;
}) => {
setIsDropdownVisible(false);
inputRef.current?.blur();
navigate(buildGameDetailsPath(suggestion));
};
const handleClearSearch = () => {
@@ -89,14 +176,79 @@ export function Header() {
} else {
dispatch(setFilters({ title: "" }));
}
setActiveIndex(-1);
};
const handleRemoveHistoryItem = (query: string) => {
removeFromHistory(query);
};
const handleClearHistory = () => {
clearHistory();
};
const handleKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
if (event.key === "Enter") {
event.preventDefault();
if (activeIndex >= 0 && activeIndex < totalItems) {
if (activeIndex < historyItems.length) {
handleSelectHistory(historyItems[activeIndex].query);
} else {
const suggestionIndex = activeIndex - historyItems.length;
handleSelectSuggestion(suggestions[suggestionIndex]);
}
} else if (searchValue.trim()) {
executeSearch(searchValue);
}
} else if (event.key === "ArrowDown") {
event.preventDefault();
setActiveIndex((prev) => (prev < totalItems - 1 ? prev + 1 : prev));
if (!isDropdownVisible) {
setIsDropdownVisible(true);
updateDropdownPosition();
}
} else if (event.key === "ArrowUp") {
event.preventDefault();
setActiveIndex((prev) => (prev > -1 ? prev - 1 : -1));
} else if (event.key === "Escape") {
event.preventDefault();
setIsDropdownVisible(false);
setActiveIndex(-1);
inputRef.current?.blur();
}
};
const handleCloseDropdown = () => {
setIsDropdownVisible(false);
setActiveIndex(-1);
};
useEffect(() => {
if (!location.pathname.startsWith("/catalogue") && catalogueSearchValue) {
const prevPath = sessionStorage.getItem("prevPath");
const currentPath = location.pathname;
if (
prevPath?.startsWith("/catalogue") &&
!currentPath.startsWith("/catalogue") &&
catalogueSearchValue
) {
dispatch(setFilters({ title: "" }));
}
sessionStorage.setItem("prevPath", currentPath);
}, [location.pathname, catalogueSearchValue, dispatch]);
useEffect(() => {
if (!isDropdownVisible) return;
const handleResize = () => {
updateDropdownPosition();
};
window.addEventListener("resize", handleResize);
return () => window.removeEventListener("resize", handleResize);
}, [isDropdownVisible]);
return (
<>
<header
@@ -128,6 +280,7 @@ export function Header() {
<section className="header__section">
<div
ref={searchContainerRef}
className={cn("header__search", {
"header__search--focused": isFocused,
})}
@@ -148,8 +301,9 @@ export function Header() {
value={searchValue}
className="header__search-input"
onChange={(event) => handleSearch(event.target.value)}
onFocus={() => setIsFocused(true)}
onFocus={handleFocus}
onBlur={handleBlur}
onKeyDown={handleKeyDown}
/>
{searchValue && (
@@ -165,6 +319,27 @@ export function Header() {
</section>
</header>
<AutoUpdateSubHeader />
<SearchDropdown
visible={
isDropdownVisible &&
(historyItems.length > 0 ||
suggestions.length > 0 ||
isLoadingSuggestions)
}
position={dropdownPosition}
historyItems={historyItems}
suggestions={suggestions}
isLoadingSuggestions={isLoadingSuggestions}
onSelectHistory={handleSelectHistory}
onSelectSuggestion={handleSelectSuggestion}
onRemoveHistoryItem={handleRemoveHistoryItem}
onClearHistory={handleClearHistory}
onClose={handleCloseDropdown}
activeIndex={activeIndex}
currentQuery={searchValue}
searchContainerRef={searchContainerRef}
/>
</>
);
}

View File

@@ -19,3 +19,4 @@ export * from "./context-menu/context-menu";
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";

View File

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

View File

@@ -0,0 +1,153 @@
@use "../../scss/globals.scss";
.search-dropdown {
position: fixed;
background-color: globals.$dark-background-color;
border: 1px solid globals.$border-color;
border-radius: 8px;
max-height: 300px;
overflow-y: auto;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
z-index: 1000;
margin-top: 4px;
width: 250px;
&__section {
padding: 4px 0;
&:not(:last-child) {
border-bottom: 1px solid globals.$border-color;
}
}
&__section-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 12px 8px;
margin-bottom: 4px;
}
&__section-title {
color: globals.$muted-color;
font-size: 12px;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.5px;
}
&__clear-text-button {
color: globals.$muted-color;
cursor: pointer;
padding: 0;
font-size: 11px;
font-weight: bold;
text-transform: uppercase;
transition: color ease 0.2s;
background: transparent;
border: none;
&:hover {
color: #ffffff;
}
}
&__list {
list-style: none;
padding: 0;
margin: 0;
}
&__item-container {
position: relative;
display: flex;
align-items: center;
&:hover .search-dropdown__item-remove {
opacity: 1;
}
}
&__item-remove {
position: absolute;
right: 8px;
top: 50%;
transform: translateY(-50%);
color: globals.$muted-color;
padding: 4px;
opacity: 0;
transition: opacity ease 0.15s;
display: flex;
align-items: center;
justify-content: center;
background-color: transparent;
&:hover {
color: #ff3333;
background-color: rgba(255, 85, 85, 0.2);
}
}
&__item {
width: 100%;
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
cursor: pointer;
transition: background-color 0.1s ease;
color: #dadbe1;
text-align: left;
border: none;
background: transparent;
&:hover,
&--active {
background-color: globals.$background-color;
}
&:focus {
outline: none;
}
}
&__item-icon {
flex-shrink: 0;
width: 16px;
height: 16px;
color: globals.$muted-color;
&--image {
border-radius: 2px;
object-fit: cover;
}
}
&__item-text {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 14px;
}
&__loading,
&__empty {
padding: 16px 12px;
text-align: center;
color: globals.$muted-color;
font-size: 14px;
}
&__empty {
font-style: italic;
}
&__highlight {
background-color: rgba(255, 193, 7, 0.4);
color: #ffa000;
font-weight: 600;
padding: 0 2px;
border-radius: 2px;
}
}

View File

@@ -0,0 +1,241 @@
import { useEffect, useRef, useCallback, useState } from "react";
import { createPortal } from "react-dom";
import { ClockIcon, SearchIcon, XIcon } from "@primer/octicons-react";
import cn from "classnames";
import { useTranslation } from "react-i18next";
import type { SearchHistoryEntry } from "@renderer/hooks/use-search-history";
import type { SearchSuggestion } from "@renderer/hooks/use-search-suggestions";
import { HighlightText } from "./highlight-text";
import "./search-dropdown.scss";
export interface SearchDropdownProps {
visible: boolean;
position: { x: number; y: number };
historyItems: SearchHistoryEntry[];
suggestions: SearchSuggestion[];
isLoadingSuggestions: boolean;
onSelectHistory: (query: string) => void;
onSelectSuggestion: (suggestion: SearchSuggestion) => void;
onRemoveHistoryItem: (query: string) => void;
onClearHistory: () => void;
onClose: () => void;
activeIndex: number;
currentQuery: string;
searchContainerRef?: React.RefObject<HTMLDivElement>;
}
export function SearchDropdown({
visible,
position,
historyItems,
suggestions,
isLoadingSuggestions,
onSelectHistory,
onSelectSuggestion,
onRemoveHistoryItem,
onClearHistory,
onClose,
activeIndex,
currentQuery,
searchContainerRef,
}: SearchDropdownProps) {
const dropdownRef = useRef<HTMLDivElement>(null);
const [adjustedPosition, setAdjustedPosition] = useState(position);
const { t } = useTranslation("header");
useEffect(() => {
if (!visible) {
setAdjustedPosition(position);
return;
}
const checkPosition = () => {
if (!dropdownRef.current) return;
const rect = dropdownRef.current.getBoundingClientRect();
const viewportWidth = window.innerWidth;
const viewportHeight = window.innerHeight;
let adjustedX = position.x;
let adjustedY = position.y;
if (adjustedX + 250 > viewportWidth - 10) {
adjustedX = Math.max(10, viewportWidth - 250 - 10);
}
if (adjustedY + rect.height > viewportHeight - 10) {
adjustedY = Math.max(10, viewportHeight - rect.height - 10);
}
setAdjustedPosition({ x: adjustedX, y: adjustedY });
};
requestAnimationFrame(checkPosition);
}, [visible, position]);
useEffect(() => {
if (!visible) return;
const handleClickOutside = (event: MouseEvent) => {
const target = event.target as Node;
if (
dropdownRef.current &&
!dropdownRef.current.contains(target) &&
!searchContainerRef?.current?.contains(target)
) {
onClose();
}
};
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, [visible, onClose, searchContainerRef]);
const handleItemClick = useCallback(
(
type: "history" | "suggestion",
item: SearchHistoryEntry | SearchSuggestion
) => {
if (type === "history") {
onSelectHistory((item as SearchHistoryEntry).query);
} else {
onSelectSuggestion(item as SearchSuggestion);
}
},
[onSelectHistory, onSelectSuggestion]
);
if (!visible) return null;
const totalItems = historyItems.length + suggestions.length;
const hasHistory = historyItems.length > 0;
const hasSuggestions = suggestions.length > 0;
const getItemIndex = (
section: "history" | "suggestion",
indexInSection: number
) => {
if (section === "history") {
return indexInSection;
}
return historyItems.length + indexInSection;
};
const dropdownContent = (
<div
ref={dropdownRef}
className="search-dropdown"
style={{
left: adjustedPosition.x,
top: adjustedPosition.y,
}}
>
{hasHistory && (
<div className="search-dropdown__section">
<div className="search-dropdown__section-header">
<span className="search-dropdown__section-title">
{t("recent_searches")}
</span>
<button
type="button"
className="search-dropdown__clear-text-button"
onClick={onClearHistory}
>
{t("clear_history")}
</button>
</div>
<ul className="search-dropdown__list">
{historyItems.map((item, index) => (
<li
key={`history-${item.query}-${item.timestamp}`}
className="search-dropdown__item-container"
>
<button
type="button"
className={cn("search-dropdown__item", {
"search-dropdown__item--active":
activeIndex === getItemIndex("history", index),
})}
onMouseDown={(e) => e.preventDefault()}
onClick={() => handleItemClick("history", item)}
>
<ClockIcon size={16} className="search-dropdown__item-icon" />
<span className="search-dropdown__item-text">
{item.query}
</span>
</button>
<button
type="button"
className="search-dropdown__item-remove"
onMouseDown={(e) => e.preventDefault()}
onClick={(e) => {
e.stopPropagation();
onRemoveHistoryItem(item.query);
}}
title={t("remove_from_history")}
>
<XIcon size={12} />
</button>
</li>
))}
</ul>
</div>
)}
{hasSuggestions && (
<div className="search-dropdown__section">
<div className="search-dropdown__section-header">
<span className="search-dropdown__section-title">
{t("suggestions")}
</span>
</div>
<ul className="search-dropdown__list">
{suggestions.map((item, index) => (
<li key={`suggestion-${item.objectId}-${item.shop}`}>
<button
type="button"
className={cn("search-dropdown__item", {
"search-dropdown__item--active":
activeIndex === getItemIndex("suggestion", index),
})}
onMouseDown={(e) => e.preventDefault()}
onClick={() => handleItemClick("suggestion", item)}
>
{item.iconUrl ? (
<img
src={item.iconUrl}
alt=""
className="search-dropdown__item-icon search-dropdown__item-icon--image"
/>
) : (
<SearchIcon
size={16}
className="search-dropdown__item-icon"
/>
)}
<span className="search-dropdown__item-text">
<HighlightText text={item.title} query={currentQuery} />
</span>
</button>
</li>
))}
</ul>
</div>
)}
{isLoadingSuggestions && !hasSuggestions && !hasHistory && (
<div className="search-dropdown__loading">{t("loading")}</div>
)}
{!isLoadingSuggestions &&
!hasHistory &&
!hasSuggestions &&
totalItems === 0 && (
<div className="search-dropdown__empty">{t("no_results")}</div>
)}
</div>
);
return createPortal(dropdownContent, document.body);
}

View File

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

View File

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

View File

@@ -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<boolean>;
getDefaultWinePrefixSelectionPath: () => Promise<string | null>;
createSteamShortcut: (shop: GameShop, objectId: string) => Promise<void>;
@@ -438,6 +445,25 @@ declare global {
onNewDownloadOptions: (
cb: (gamesWithNewOptions: { gameId: string; count: number }[]) => void
) => () => Electron.IpcRenderer;
/* LevelDB Generic CRUD */
leveldb: {
get: (
key: string,
sublevelName?: string | null,
valueEncoding?: "json" | "utf8"
) => Promise<unknown>;
put: (
key: string,
value: unknown,
sublevelName?: string | null,
valueEncoding?: "json" | "utf8"
) => Promise<void>;
del: (key: string, sublevelName?: string | null) => Promise<void>;
clear: (sublevelName: string) => Promise<void>;
values: (sublevelName: string) => Promise<unknown[]>;
iterator: (sublevelName: string) => Promise<[string, unknown][]>;
};
}
interface Window {

View File

@@ -1,17 +1,24 @@
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;
}
const initialState: DownloadState = {
lastPacket: null,
gameId: null,
gamesWithDeletionInProgress: [],
extraction: null,
};
export const downloadSlice = createSlice({
@@ -38,6 +45,23 @@ 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;
},
},
});
@@ -46,4 +70,6 @@ export const {
clearDownload,
setGameDeleting,
removeGameFromDeleting,
setExtractionProgress,
clearExtraction,
} = downloadSlice.actions;

View File

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

View File

@@ -8,3 +8,6 @@ export * from "./use-format";
export * from "./use-feature";
export * from "./use-download-options-listener";
export * from "./use-game-card";
export * from "./use-search-history";
export * from "./use-search-suggestions";
export * from "./use-hls-video";

View File

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

View File

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

View File

@@ -0,0 +1,89 @@
import { useState, useCallback, useEffect, useRef } from "react";
import { levelDBService } from "@renderer/services/leveldb.service";
export interface SearchHistoryEntry {
query: string;
timestamp: number;
context: "library" | "catalogue";
}
const LEVELDB_KEY = "searchHistory";
const MAX_HISTORY_ENTRIES = 15;
export function useSearchHistory() {
const [history, setHistory] = useState<SearchHistoryEntry[]>([]);
const isInitialized = useRef(false);
useEffect(() => {
const loadHistory = async () => {
if (isInitialized.current) return;
isInitialized.current = true;
try {
const data = (await levelDBService.get(LEVELDB_KEY, null, "json")) as
| SearchHistoryEntry[]
| null;
if (data) {
setHistory(data);
}
} catch {
setHistory([]);
}
};
loadHistory();
}, []);
const addToHistory = useCallback(
(query: string, context: "library" | "catalogue") => {
if (!query.trim()) return;
const newEntry: SearchHistoryEntry = {
query: query.trim(),
timestamp: Date.now(),
context,
};
setHistory((prev) => {
const filtered = prev.filter(
(entry) => entry.query.toLowerCase() !== query.toLowerCase().trim()
);
const updated = [newEntry, ...filtered].slice(0, MAX_HISTORY_ENTRIES);
levelDBService.put(LEVELDB_KEY, updated, null, "json");
return updated;
});
},
[]
);
const removeFromHistory = useCallback((query: string) => {
setHistory((prev) => {
const updated = prev.filter((entry) => entry.query !== query);
levelDBService.put(LEVELDB_KEY, updated, null, "json");
return updated;
});
}, []);
const clearHistory = useCallback(() => {
setHistory([]);
levelDBService.del(LEVELDB_KEY, null);
}, []);
const getRecentHistory = useCallback(
(context: "library" | "catalogue", limit: number = 3) => {
return history
.filter((entry) => entry.context === context)
.slice(0, limit);
},
[history]
);
return {
history,
addToHistory,
removeFromHistory,
clearHistory,
getRecentHistory,
};
}

View File

@@ -0,0 +1,163 @@
import { useState, useEffect, useCallback, useRef } from "react";
import { useAppSelector } from "./redux";
import { debounce } from "lodash-es";
import { logger } from "@renderer/logger";
import type { GameShop } from "@types";
export interface SearchSuggestion {
title: string;
objectId: string;
shop: GameShop;
iconUrl: string | null;
source: "library" | "catalogue";
}
export function useSearchSuggestions(
query: string,
isOnLibraryPage: boolean,
enabled: boolean = true
) {
const [suggestions, setSuggestions] = useState<SearchSuggestion[]>([]);
const [isLoading, setIsLoading] = useState(false);
const library = useAppSelector((state) => state.library.value);
const abortControllerRef = useRef<AbortController | null>(null);
const cacheRef = useRef<Map<string, SearchSuggestion[]>>(new Map());
const getLibrarySuggestions = useCallback(
(searchQuery: string, limit: number = 3): SearchSuggestion[] => {
if (!searchQuery.trim()) return [];
const queryLower = searchQuery.toLowerCase();
const matches: SearchSuggestion[] = [];
for (const game of library) {
if (matches.length >= limit) break;
const titleLower = game.title.toLowerCase();
let queryIndex = 0;
for (
let i = 0;
i < titleLower.length && queryIndex < queryLower.length;
i++
) {
if (titleLower[i] === queryLower[queryIndex]) {
queryIndex++;
}
}
if (queryIndex === queryLower.length) {
matches.push({
title: game.title,
objectId: game.objectId,
shop: game.shop,
iconUrl: game.iconUrl,
source: "library",
});
}
}
return matches;
},
[library]
);
const fetchCatalogueSuggestions = useCallback(
async (searchQuery: string, limit: number = 3) => {
if (!searchQuery.trim() || searchQuery.length < 2) {
setSuggestions([]);
setIsLoading(false);
return;
}
const cacheKey = `${searchQuery.toLowerCase()}_${limit}`;
const cachedResults = cacheRef.current.get(cacheKey);
if (cachedResults) {
setSuggestions(cachedResults);
setIsLoading(false);
return;
}
abortControllerRef.current?.abort();
const abortController = new AbortController();
abortControllerRef.current = abortController;
setIsLoading(true);
try {
const response = await window.electron.hydraApi.get<
{
title: string;
objectId: string;
shop: GameShop;
iconUrl: string | null;
}[]
>("/catalogue/search/suggestions", {
params: {
query: searchQuery,
limit,
},
needsAuth: false,
});
if (abortController.signal.aborted) return;
const catalogueSuggestions: SearchSuggestion[] = response.map(
(item) => ({
...item,
source: "catalogue" as const,
})
);
cacheRef.current.set(cacheKey, catalogueSuggestions);
setSuggestions(catalogueSuggestions);
} catch (error) {
if (!abortController.signal.aborted) {
setSuggestions([]);
logger.error("Failed to fetch catalogue suggestions", error);
}
} finally {
if (!abortController.signal.aborted) {
setIsLoading(false);
}
}
},
[]
);
const debouncedFetchCatalogue = useRef(
debounce(fetchCatalogueSuggestions, 300)
).current;
useEffect(() => {
if (!enabled || !query || query.length < 2) {
setSuggestions([]);
setIsLoading(false);
abortControllerRef.current?.abort();
debouncedFetchCatalogue.cancel();
return;
}
if (isOnLibraryPage) {
const librarySuggestions = getLibrarySuggestions(query, 3);
setSuggestions(librarySuggestions);
setIsLoading(false);
} else {
debouncedFetchCatalogue(query, 3);
}
return () => {
debouncedFetchCatalogue.cancel();
abortControllerRef.current?.abort();
};
}, [
query,
isOnLibraryPage,
enabled,
getLibrarySuggestions,
debouncedFetchCatalogue,
]);
return { suggestions, isLoading };
}

View File

@@ -21,6 +21,7 @@ import resources from "@locales";
import { logger } from "./logger";
import { addCookieInterceptor } from "./cookies";
import { levelDBService } from "./services/leveldb.service";
import Catalogue from "./pages/catalogue/catalogue";
import Home from "./pages/home/home";
import Downloads from "./pages/downloads/downloads";
@@ -48,7 +49,11 @@ i18n
},
})
.then(async () => {
const userPreferences = await window.electron.getUserPreferences();
const userPreferences = (await levelDBService.get(
"userPreferences",
null,
"json"
)) as { language?: string } | null;
if (userPreferences?.language) {
i18n.changeLanguage(userPreferences.language);

View File

@@ -11,6 +11,7 @@ import {
getAchievementSoundVolume,
} from "@renderer/helpers";
import { AchievementNotificationItem } from "@renderer/components/achievements/notification/achievement-notification";
import { levelDBService } from "@renderer/services/leveldb.service";
import app from "../../../app.scss?inline";
import styles from "../../../components/achievements/notification/achievement-notification.scss?inline";
import root from "react-shadow";
@@ -144,7 +145,11 @@ export function AchievementNotification() {
const loadAndApplyTheme = useCallback(async () => {
if (!shadowRootRef) return;
const activeTheme = await window.electron.getActiveCustomTheme();
const allThemes = (await levelDBService.values("themes")) as {
isActive?: boolean;
code?: string;
}[];
const activeTheme = allThemes.find((theme) => theme.isActive);
if (activeTheme?.code) {
injectCustomCss(activeTheme.code, shadowRootRef);
} else {

View File

@@ -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<ArchiveDeletionModalProps>) {
const { t } = useTranslation("downloads");
const fullFileName =
archivePaths.length > 0 ? (archivePaths[0].split(/[/\\]/).pop() ?? "") : "";
const maxLength = 40;
const fileName =
fullFileName.length > maxLength
? `${fullFileName.slice(0, maxLength)}`
: fullFileName;
const handleConfirm = async () => {
for (const archivePath of archivePaths) {
await window.electron.deleteArchive(archivePath);
}
onClose();
};
return (
<ConfirmationModal
visible={visible}
title={t("delete_archive_title", { fileName })}
descriptionText={t("delete_archive_description")}
confirmButtonLabel={t("yes")}
cancelButtonLabel={t("no")}
onConfirm={handleConfirm}
onClose={onClose}
/>
);
}

View File

@@ -4,158 +4,541 @@
display: flex;
flex-direction: column;
gap: calc(globals.$spacing-unit * 2);
margin-inline: calc(globals.$spacing-unit * 3);
padding-block: calc(globals.$spacing-unit * 3);
&__details-with-article {
display: flex;
align-items: center;
gap: calc(globals.$spacing-unit / 2);
align-self: flex-start;
cursor: pointer;
&--queued {
padding-bottom: 0;
}
&--completed {
padding-top: calc(globals.$spacing-unit * 3);
}
&__header {
display: flex;
align-items: center;
justify-content: space-between;
gap: calc(globals.$spacing-unit * 2);
gap: calc(globals.$spacing-unit);
&-divider {
&-title-group {
display: flex;
align-items: center;
gap: calc(globals.$spacing-unit);
flex: 1;
background-color: globals.$border-color;
height: 1px;
h2 {
margin: 0;
font-size: 20px;
font-weight: 700;
color: rgba(255, 255, 255, 0.95);
}
}
&-count {
font-weight: 400;
background-color: rgba(255, 255, 255, 0.1);
color: rgba(255, 255, 255, 0.7);
padding: 4px 8px;
border-radius: 6px;
font-size: 12px;
font-weight: 600;
min-width: 24px;
text-align: center;
flex-shrink: 0;
}
}
&__title-wrapper {
display: flex;
align-items: center;
margin-bottom: globals.$spacing-unit;
gap: globals.$spacing-unit;
}
&__title {
font-weight: bold;
cursor: pointer;
color: globals.$body-color;
text-align: left;
font-size: 16px;
display: block;
&:hover {
text-decoration: underline;
}
}
&__downloads {
&--hero {
width: 100%;
gap: calc(globals.$spacing-unit * 2);
display: flex;
flex-direction: column;
position: relative;
overflow: hidden;
margin: 0;
padding: 0;
margin-top: globals.$spacing-unit;
padding-bottom: globals.$spacing-unit;
}
&__item {
&__hero-background {
position: absolute;
top: 0;
left: 0;
width: 100%;
background-color: globals.$background-color;
display: flex;
border-radius: 8px;
border: solid 1px globals.$border-color;
overflow: hidden;
box-shadow: 0px 0px 5px 0px #000000;
transition: all ease 0.2s;
height: 140px;
min-height: 140px;
max-height: 140px;
position: relative;
height: 120%;
z-index: 0;
&--hydra {
box-shadow: 0px 0px 16px 0px rgba(12, 241, 202, 0.15);
img {
width: 100%;
height: 100%;
object-fit: cover;
object-position: 50% 20%;
}
}
&__cover {
width: 280px;
min-width: 280px;
height: auto;
border-right: solid 1px globals.$border-color;
// PLEASE FIX THE COLORS
&__hero-overlay {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: linear-gradient(
to bottom,
rgba(0, 0, 0, 0.3) 0%,
rgb(5, 5, 5) 70%,
rgb(26, 26, 26) 100%
);
}
&__hero-content {
position: relative;
z-index: 1;
&-content {
width: 100%;
height: 100%;
padding: globals.$spacing-unit;
display: flex;
align-items: flex-end;
justify-content: flex-end;
}
&-backdrop {
width: 100%;
height: 100%;
background: linear-gradient(
0deg,
rgba(0, 0, 0, 0.8) 5%,
transparent 100%
);
display: flex;
overflow: hidden;
z-index: 1;
}
&-image {
width: 100%;
height: 100%;
position: absolute;
z-index: -1;
}
}
&__right-content {
display: flex;
padding: calc(globals.$spacing-unit * 2);
flex: 1;
gap: globals.$spacing-unit;
background: linear-gradient(90deg, transparent 20%, rgb(0 0 0 / 20%) 100%);
}
&__details {
padding: calc(globals.$spacing-unit * 4);
padding-bottom: 0;
display: flex;
flex-direction: column;
flex: 1;
justify-content: center;
gap: calc(globals.$spacing-unit / 2);
font-size: 14px;
gap: calc(globals.$spacing-unit * 2);
}
&__actions {
&__hero-logo {
flex: 1;
min-width: 0;
display: flex;
align-items: center;
gap: globals.$spacing-unit;
&-button {
background: none;
border: none;
padding: 0;
cursor: pointer;
display: flex;
align-items: center;
transition: scale 0.2s ease;
outline: none;
&:hover {
scale: 1.05;
}
}
img {
max-width: 180px;
max-height: 60px;
object-fit: contain;
cursor: pointer;
transition: opacity 0.2s ease;
&:hover {
opacity: 0.8;
}
&:focus {
outline: 2px solid rgba(255, 255, 255, 0.5);
outline-offset: 4px;
border-radius: 4px;
}
@container #{globals.$app-container} (min-width: 700px) {
max-width: 220px;
max-height: 75px;
}
@container #{globals.$app-container} (min-width: 900px) {
max-width: 280px;
max-height: 95px;
}
@container #{globals.$app-container} (min-width: 1200px) {
max-width: 340px;
max-height: 115px;
}
@container #{globals.$app-container} (min-width: 1500px) {
max-width: 400px;
max-height: 130px;
}
}
h1 {
font-size: 20px;
font-weight: 700;
color: #ffffff;
text-shadow: 2px 2px 12px rgba(0, 0, 0, 0.9);
margin: 0;
cursor: pointer;
transition: opacity 0.2s ease;
&:hover {
opacity: 0.8;
}
&:focus {
outline: 2px solid rgba(255, 255, 255, 0.5);
outline-offset: 4px;
border-radius: 4px;
}
@container #{globals.$app-container} (min-width: 700px) {
font-size: 26px;
}
@container #{globals.$app-container} (min-width: 900px) {
font-size: 32px;
}
@container #{globals.$app-container} (min-width: 1200px) {
font-size: 38px;
}
@container #{globals.$app-container} (min-width: 1500px) {
font-size: 44px;
}
}
}
&__menu-button {
position: absolute;
top: 12px;
right: 12px;
border-radius: 50%;
&__hero-action-row {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: calc(globals.$spacing-unit * 3);
margin-top: calc(globals.$spacing-unit * 4);
margin-bottom: calc(globals.$spacing-unit * 2);
}
&__hero-buttons {
display: flex;
gap: calc(globals.$spacing-unit);
align-items: center;
flex-shrink: 0;
}
&__glass-btn {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 16px;
border-radius: 8px;
background: rgba(255, 255, 255, 0.1);
backdrop-filter: blur(12px);
border: 1px solid rgba(255, 255, 255, 0.2);
box-shadow:
0 10px 15px -3px rgba(0, 0, 0, 0.1),
0 4px 6px -4px rgba(0, 0, 0, 0.1);
color: #fff;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: background-color 0.2s ease;
&:hover {
background: rgba(255, 255, 255, 0.2);
}
}
&__hero-progress {
display: flex;
flex-direction: column;
}
&__progress-info-row {
flex: 1;
display: flex;
justify-content: space-between;
align-items: center;
}
&__progress-row {
display: flex;
align-items: flex-end;
gap: calc(globals.$spacing-unit * 2);
&--bar {
margin-top: calc(globals.$spacing-unit);
}
}
&__progress-status {
font-size: 13px;
font-weight: 600;
color: rgba(255, 255, 255, 0.9);
text-transform: uppercase;
letter-spacing: 0.5px;
}
&__progress-percentage {
font-size: 14px;
font-weight: 700;
color: #ffffff;
align-self: flex-end;
display: inline-block;
overflow: hidden;
line-height: 1.2;
span {
display: inline-block;
}
}
&__progress-size {
display: flex;
align-items: center;
gap: calc(globals.$spacing-unit);
font-size: 13px;
font-weight: 600;
color: rgba(255, 255, 255, 0.9);
}
&__progress-status {
font-size: 13px;
font-weight: 600;
color: rgba(255, 255, 255, 0.9);
}
&__progress-time {
display: flex;
align-items: center;
gap: calc(globals.$spacing-unit);
font-size: 13px;
color: globals.$muted-color;
}
&__hero-stats {
display: flex;
gap: calc(globals.$spacing-unit * 4);
padding: calc(globals.$spacing-unit * 2);
border-radius: 12px;
border: 1px solid rgba(255, 255, 255, 0.1);
background: rgba(26, 26, 26, 0.1);
backdrop-filter: blur(8px);
margin-top: calc(globals.$spacing-unit * 2);
}
&__stats-column {
display: flex;
flex-direction: column;
gap: calc(globals.$spacing-unit * 2);
min-width: 200px;
padding-right: calc(globals.$spacing-unit * 2);
border-right: 1px solid rgba(255, 255, 255, 0.1);
align-self: flex-start;
}
&__speed-chart {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
}
&__speed-chart-canvas {
width: 100%;
height: 80px;
image-rendering: crisp-edges;
}
&__stat-item {
display: flex;
align-items: flex-end;
gap: calc(globals.$spacing-unit);
svg {
opacity: 0.8;
flex-shrink: 0;
}
}
&__stat-content {
display: flex;
justify-content: space-between;
gap: calc(globals.$spacing-unit / 2);
width: 100%;
}
&__stat-label {
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
font-size: 10px;
color: rgba(255, 255, 255, 0.6);
}
&__stat-value {
color: #ffffff;
font-weight: 700;
font-size: 11px;
line-height: 1.2;
}
&__simple-list {
width: 100%;
display: flex;
flex-direction: column;
gap: calc(globals.$spacing-unit * 2);
margin: 0;
padding: 0;
list-style: none;
}
&__simple-card {
display: flex;
align-items: center;
gap: calc(globals.$spacing-unit * 2);
border-radius: 8px;
}
&__simple-thumbnail {
width: 200px;
height: 100px;
border-radius: 6px;
overflow: hidden;
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%;
height: 100%;
object-fit: cover;
}
}
&__simple-info {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: calc(globals.$spacing-unit / 1);
}
&__simple-title-button {
background: none;
border: none;
padding: 8px;
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;
color: #ffffff;
margin: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
&__simple-meta {
display: flex;
flex-direction: column;
gap: calc(globals.$spacing-unit * 1.5);
}
&__simple-meta-row {
display: flex;
align-items: center;
gap: calc(globals.$spacing-unit * 2);
font-size: 13px;
color: globals.$muted-color;
}
&__simple-size {
display: flex;
align-items: center;
gap: calc(globals.$spacing-unit / 2);
font-weight: 500;
}
&__simple-extracting {
display: flex;
align-items: center;
gap: calc(globals.$spacing-unit / 2);
font-weight: 500;
color: globals.$muted-color;
}
&__simple-seeding {
color: #4ade80;
font-weight: 600;
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.5px;
}
&__simple-progress {
display: flex;
flex-direction: column;
gap: calc(globals.$spacing-unit / 2);
width: 200px;
flex-shrink: 0;
}
&__simple-progress-text {
font-size: 12px;
font-weight: 600;
color: rgba(255, 255, 255, 0.8);
text-align: right;
}
&__simple-actions {
flex-shrink: 0;
display: flex;
justify-content: center;
align-items: center;
gap: calc(globals.$spacing-unit);
}
&__simple-menu-btn {
padding: calc(globals.$spacing-unit);
min-height: unset;
}
&__hydra-gradient {
background: linear-gradient(90deg, #01483c 0%, #0cf1ca 50%, #01483c 100%);
box-shadow: 0px 0px 8px 0px rgba(12, 241, 202, 0.15);
&__progress-wrapper {
flex: 1;
display: flex;
flex-direction: column;
gap: calc(globals.$spacing-unit / 2);
}
&__progress-bar {
width: 100%;
position: absolute;
bottom: 0;
height: 2px;
z-index: 1;
height: 8px;
background-color: rgba(255, 255, 255, 0.08);
border-radius: 4px;
overflow: hidden;
margin-top: calc(globals.$spacing-unit / 2);
&--small {
height: 6px;
}
}
&__progress-fill {
height: 100%;
background-color: #fff;
transition: width 0.3s ease;
border-radius: 4px;
&--extraction {
background-color: #fff;
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -3,7 +3,6 @@
.downloads {
&__container {
display: flex;
padding: calc(globals.$spacing-unit * 3);
flex-direction: column;
width: 100%;
}

View File

@@ -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 = [
{

View File

@@ -8,6 +8,7 @@ import {
import useEmblaCarousel from "embla-carousel-react";
import { gameDetailsContext } from "@renderer/context";
import { useAppSelector } from "@renderer/hooks";
import { VideoPlayer } from "./video-player";
import "./gallery-slider.scss";
export function GallerySlider() {
@@ -100,20 +101,44 @@ export function GallerySlider() {
src?: string;
poster?: string;
videoSrc?: string;
videoType?: string;
alt: string;
}> = [];
if (shopDetails?.movies) {
shopDetails.movies.forEach((video, index) => {
items.push({
id: String(video.id),
type: "video",
poster: video.thumbnail,
videoSrc: video.mp4.max.startsWith("http://")
? video.mp4.max.replace("http://", "https://")
: video.mp4.max,
alt: t("video", { number: String(index + 1) }),
});
let videoSrc: string | undefined;
let videoType: string | undefined;
if (video.hls_h264) {
videoSrc = video.hls_h264;
videoType = "application/x-mpegURL";
} else if (video.dash_h264) {
videoSrc = video.dash_h264;
videoType = "application/dash+xml";
} else if (video.dash_av1) {
videoSrc = video.dash_av1;
videoType = "application/dash+xml";
} else if (video.mp4?.max) {
videoSrc = video.mp4.max;
videoType = "video/mp4";
} else if (video.webm?.max) {
videoSrc = video.webm.max;
videoType = "video/webm";
}
if (videoSrc) {
items.push({
id: String(video.id),
type: "video",
poster: video.thumbnail,
videoSrc: videoSrc.startsWith("http://")
? videoSrc.replace("http://", "https://")
: videoSrc,
videoType,
alt: video.name || t("video", { number: String(index + 1) }),
});
}
});
}
@@ -163,17 +188,17 @@ export function GallerySlider() {
{mediaItems.map((item) => (
<div key={item.id} className="gallery-slider__slide">
{item.type === "video" ? (
<video
controls
className="gallery-slider__media"
<VideoPlayer
videoSrc={item.videoSrc}
videoType={item.videoType}
poster={item.poster}
autoplay={autoplayEnabled}
loop
muted
autoPlay={autoplayEnabled}
controls
className="gallery-slider__media"
tabIndex={-1}
>
<source src={item.videoSrc} />
</video>
/>
) : (
<img
className="gallery-slider__media"

View File

@@ -0,0 +1,70 @@
import { useRef } from "react";
import { useHlsVideo } from "@renderer/hooks";
interface VideoPlayerProps {
videoSrc?: string;
videoType?: string;
poster?: string;
autoplay?: boolean;
muted?: boolean;
loop?: boolean;
controls?: boolean;
tabIndex?: number;
className?: string;
}
export function VideoPlayer({
videoSrc,
videoType,
poster,
autoplay = false,
muted = true,
loop = false,
controls = true,
tabIndex = -1,
className,
}: VideoPlayerProps) {
const videoRef = useRef<HTMLVideoElement>(null);
const isHls = videoType === "application/x-mpegURL";
useHlsVideo(videoRef, {
videoSrc,
videoType,
autoplay,
muted,
loop,
});
if (isHls) {
return (
<video
ref={videoRef}
controls={controls}
className={className}
poster={poster}
loop={loop}
muted={muted}
autoPlay={autoplay}
tabIndex={tabIndex}
>
<track kind="captions" />
</video>
);
}
return (
<video
ref={videoRef}
controls={controls}
className={className}
poster={poster}
loop={loop}
muted={muted}
autoPlay={autoplay}
tabIndex={tabIndex}
>
{videoSrc && <source src={videoSrc} type={videoType} />}
<track kind="captions" />
</video>
);
}

View File

@@ -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 = (
<div className="hero-panel-playtime__download-details">
<Link to="/downloads" className="hero-panel-playtime__downloads-link">
{t("extracting")}
</Link>
<small>{formatDownloadProgress(extraction?.progress ?? 0)}</small>
</div>
);
const downloadInProgressInfo = (
<div className="hero-panel-playtime__download-details">
<Link to="/downloads" className="hero-panel-playtime__downloads-link">
@@ -72,7 +90,8 @@ export function HeroPanelPlaytime() {
return (
<>
<p>{t("not_played_yet", { title: game?.title })}</p>
{hasDownload && downloadInProgressInfo}
{isExtracting && extractionInProgressInfo}
{!isExtracting && hasDownload && downloadInProgressInfo}
</>
);
}
@@ -81,7 +100,8 @@ export function HeroPanelPlaytime() {
return (
<>
<p>{t("playing_now")}</p>
{hasDownload && downloadInProgressInfo}
{isExtracting && extractionInProgressInfo}
{!isExtracting && hasDownload && downloadInProgressInfo}
</>
);
}
@@ -113,9 +133,9 @@ export function HeroPanelPlaytime() {
})}
</p>
{hasDownload ? (
downloadInProgressInfo
) : (
{isExtracting && extractionInProgressInfo}
{!isExtracting && hasDownload && downloadInProgressInfo}
{!isExtracting && !hasDownload && (
<p>
{t("last_time_played", {
period: lastTimePlayed,

View File

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

View File

@@ -1,7 +1,7 @@
import { useContext } from "react";
import { useTranslation } from "react-i18next";
import { 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 (
<div className="hero-panel__container">
<div className="hero-panel">
@@ -72,6 +78,14 @@ export function HeroPanel() {
}`}
/>
)}
{showExtractionProgressBar && (
<progress
max={1}
value={extraction?.progress ?? 0}
className="hero-panel__progress-bar hero-panel__progress-bar--extraction"
/>
)}
</div>
</div>
);

View File

@@ -1,7 +1,7 @@
import { useContext, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { Button, CheckboxField, Modal, TextField } from "@renderer/components";
import type { LibraryGame, ShortcutLocation } from "@types";
import type { Game, LibraryGame, ShortcutLocation } from "@types";
import { gameDetailsContext } from "@renderer/context";
import { DeleteGameModal } from "@renderer/pages/downloads/delete-game-modal";
import { useDownload, useToast, useUserDetails } from "@renderer/hooks";
@@ -11,6 +11,8 @@ import { ChangeGamePlaytimeModal } from "./change-game-playtime-modal";
import { FileDirectoryIcon, FileIcon } from "@primer/octicons-react";
import SteamLogo from "@renderer/assets/steam-logo.svg?react";
import { debounce } from "lodash-es";
import { levelDBService } from "@renderer/services/leveldb.service";
import { getGameKey } from "@renderer/helpers";
import "./game-options-modal.scss";
import { logger } from "@renderer/logger";
@@ -75,11 +77,19 @@ export function GameOptionsModal({
const debounceUpdateLaunchOptions = useRef(
debounce(async (value: string) => {
await window.electron.updateLaunchOptions(
game.shop,
game.objectId,
value
);
const gameKey = getGameKey(game.shop, game.objectId);
const gameData = (await levelDBService.get(
gameKey,
"games"
)) as Game | null;
if (gameData) {
const trimmedValue = value.trim();
const updated = {
...gameData,
launchOptions: trimmedValue ? trimmedValue : null,
};
await levelDBService.put(gameKey, updated, "games");
}
updateGame();
}, 1000)
).current;
@@ -213,9 +223,16 @@ export function GameOptionsModal({
const handleClearLaunchOptions = async () => {
setLaunchOptions("");
window.electron
.updateLaunchOptions(game.shop, game.objectId, null)
.then(updateGame);
const gameKey = getGameKey(game.shop, game.objectId);
const gameData = (await levelDBService.get(
gameKey,
"games"
)) as Game | null;
if (gameData) {
const updated = { ...gameData, launchOptions: null };
await levelDBService.put(gameKey, updated, "games");
}
updateGame();
};
const shouldShowWinePrefixConfiguration =
@@ -256,11 +273,15 @@ export function GameOptionsModal({
) => {
setAutomaticCloudSync(event.target.checked);
await window.electron.toggleAutomaticCloudSync(
game.shop,
game.objectId,
event.target.checked
);
const gameKey = getGameKey(game.shop, game.objectId);
const gameData = (await levelDBService.get(
gameKey,
"games"
)) as Game | null;
if (gameData) {
const updated = { ...gameData, automaticCloudSync: event.target.checked };
await levelDBService.put(gameKey, updated, "games");
}
updateGame();
};

View File

@@ -15,7 +15,7 @@ import {
TextField,
CheckboxField,
} from "@renderer/components";
import type { DownloadSource, GameRepack } from "@types";
import type { DownloadSource, Game, GameRepack } from "@types";
import { DownloadSettingsModal } from "./download-settings-modal";
import { gameDetailsContext } from "@renderer/context";
@@ -23,6 +23,8 @@ import { Downloader } from "@shared";
import { orderBy } from "lodash-es";
import { useDate, useFeature, useAppDispatch } from "@renderer/hooks";
import { clearNewDownloadOptions } from "@renderer/features";
import { levelDBService } from "@renderer/services/leveldb.service";
import { getGameKey } from "@renderer/helpers";
import "./repacks-modal.scss";
export interface RepacksModalProps {
@@ -98,8 +100,11 @@ export function RepacksModal({
useEffect(() => {
const fetchDownloadSources = async () => {
const sources = await window.electron.getDownloadSources();
setDownloadSources(sources);
const sources = (await levelDBService.values(
"downloadSources"
)) as DownloadSource[];
const sorted = orderBy(sources, "createdAt", "desc");
setDownloadSources(sorted);
};
fetchDownloadSources();
@@ -109,10 +114,19 @@ export function RepacksModal({
const fetchLastCheckTimestamp = async () => {
setIsLoadingTimestamp(true);
const timestamp = await window.electron.getDownloadSourcesSinceValue();
try {
const timestamp = (await levelDBService.get(
"downloadSourcesSinceValue",
null,
"utf8"
)) as string | null;
setLastCheckTimestamp(timestamp);
setIsLoadingTimestamp(false);
setLastCheckTimestamp(timestamp);
} catch {
setLastCheckTimestamp(null);
} finally {
setIsLoadingTimestamp(false);
}
};
if (visible) {
@@ -126,7 +140,20 @@ export function RepacksModal({
game?.newDownloadOptionsCount &&
game.newDownloadOptionsCount > 0
) {
globalThis.electron.clearNewDownloadOptions(game.shop, game.objectId);
const gameKey = getGameKey(game.shop, game.objectId);
levelDBService
.get(gameKey, "games")
.then((gameData) => {
if (gameData) {
const updated = {
...(gameData as Game),
newDownloadOptionsCount: undefined,
};
return levelDBService.put(gameKey, updated, "games");
}
return Promise.resolve();
})
.catch(() => {});
const gameId = `${game.shop}:${game.objectId}`;
dispatch(clearNewDownloadOptions({ gameId }));
@@ -204,9 +231,19 @@ export function RepacksModal({
return false;
}
const lastCheckUtc = new Date(lastCheckTimestamp).toISOString();
try {
const lastCheckDate = new Date(lastCheckTimestamp);
return repack.createdAt > lastCheckUtc;
if (isNaN(lastCheckDate.getTime())) {
return false;
}
const lastCheckUtc = lastCheckDate.toISOString();
return repack.createdAt > lastCheckUtc;
} catch {
return false;
}
};
const [isFilterDrawerOpen, setIsFilterDrawerOpen] = useState(false);

View File

@@ -1,11 +1,13 @@
import { useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { levelDBService } from "@renderer/services/leveldb.service";
import { orderBy } from "lodash-es";
import { useNavigate } from "react-router-dom";
import Skeleton, { SkeletonTheme } from "react-loading-skeleton";
import { Button, GameCard, Hero } from "@renderer/components";
import type { ShopAssets, Steam250Game } from "@types";
import type { DownloadSource, ShopAssets, Steam250Game } from "@types";
import flameIconStatic from "@renderer/assets/icons/flame-static.png";
import flameIconAnimated from "@renderer/assets/icons/flame-animated.gif";
@@ -40,7 +42,10 @@ export default function Home() {
setCurrentCatalogueCategory(category);
setIsLoading(true);
const downloadSources = await window.electron.getDownloadSources();
const sources = (await levelDBService.values(
"downloadSources"
)) as DownloadSource[];
const downloadSources = orderBy(sources, "createdAt", "desc");
const params = {
take: 12,

View File

@@ -1,7 +1,7 @@
import { LibraryGame } from "@types";
import { useGameCard } from "@renderer/hooks";
import { ClockIcon, AlertFillIcon, TrophyIcon } from "@primer/octicons-react";
import { memo, useMemo } from "react";
import { memo, useEffect, useMemo, useState } from "react";
import "./library-game-card-large.scss";
interface LibraryGameCardLargeProps {
@@ -48,6 +48,22 @@ export const LibraryGameCardLarge = memo(function LibraryGameCardLarge({
]
);
const [unlockedAchievementsCount, setUnlockedAchievementsCount] = useState(
game.unlockedAchievementCount ?? 0
);
useEffect(() => {
if (game.unlockedAchievementCount) return;
window.electron
.getUnlockedAchievements(game.objectId, game.shop)
.then((achievements) => {
setUnlockedAchievementsCount(
achievements.filter((a) => a.unlocked).length
);
});
}, [game]);
const backgroundStyle = useMemo(
() =>
backgroundImage ? { backgroundImage: `url(${backgroundImage})` } : {},
@@ -56,9 +72,9 @@ export const LibraryGameCardLarge = memo(function LibraryGameCardLarge({
const achievementBarStyle = useMemo(
() => ({
width: `${((game.unlockedAchievementCount ?? 0) / (game.achievementCount ?? 1)) * 100}%`,
width: `${(unlockedAchievementsCount / (game.achievementCount ?? 1)) * 100}%`,
}),
[game.unlockedAchievementCount, game.achievementCount]
[unlockedAchievementsCount, game.achievementCount]
);
const logoImage = game.customLogoImageUrl ?? game.logoImageUrl;
@@ -116,14 +132,12 @@ export const LibraryGameCardLarge = memo(function LibraryGameCardLarge({
className="library-game-card-large__achievement-trophy"
/>
<span className="library-game-card-large__achievement-count">
{game.unlockedAchievementCount ?? 0} /{" "}
{game.achievementCount ?? 0}
{unlockedAchievementsCount} / {game.achievementCount ?? 0}
</span>
</div>
<span className="library-game-card-large__achievement-percentage">
{Math.round(
((game.unlockedAchievementCount ?? 0) /
(game.achievementCount ?? 1)) *
(unlockedAchievementsCount / (game.achievementCount ?? 1)) *
100
)}
%

View File

@@ -14,10 +14,6 @@ import "./library.scss";
export default function Library() {
const { library, updateLibrary } = useLibrary();
type ElectronAPI = {
refreshLibraryAssets?: () => Promise<unknown>;
onLibraryBatchComplete?: (cb: () => void) => () => void;
};
const [viewMode, setViewMode] = useState<ViewMode>(() => {
const savedViewMode = localStorage.getItem("library-view-mode");
@@ -41,22 +37,15 @@ export default function Library() {
useEffect(() => {
dispatch(setHeaderTitle(t("library")));
const electron = (globalThis as unknown as { electron?: ElectronAPI })
.electron;
let unsubscribe: () => void = () => undefined;
if (electron?.refreshLibraryAssets) {
electron
.refreshLibraryAssets()
.then(() => updateLibrary())
.catch(() => updateLibrary());
if (electron.onLibraryBatchComplete) {
unsubscribe = electron.onLibraryBatchComplete(() => {
updateLibrary();
});
}
} else {
const unsubscribe = window.electron.onLibraryBatchComplete(() => {
updateLibrary();
}
});
window.electron
.refreshLibraryAssets()
.then(() => updateLibrary())
.catch(() => updateLibrary());
return () => {
unsubscribe();
@@ -87,7 +76,13 @@ export default function Library() {
switch (filterBy) {
case "recently_played":
filtered = library.filter((game) => game.lastTimePlayed !== null);
filtered = library
.filter((game) => game.lastTimePlayed !== null)
.sort(
(a: any, b: any) =>
new Date(b.lastTimePlayed).getTime() -
new Date(a.lastTimePlayed).getTime()
);
break;
case "favorites":
filtered = library.filter((game) => game.favorite);

View File

@@ -8,6 +8,7 @@ import { useState } from "react";
import { DeleteThemeModal } from "../modals/delete-theme-modal";
import { injectCustomCss, removeCustomCss } from "@renderer/helpers";
import { THEME_WEB_STORE_URL } from "@renderer/constants";
import { levelDBService } from "@renderer/services/leveldb.service";
interface ThemeCardProps {
theme: Theme;
@@ -22,11 +23,18 @@ export const ThemeCard = ({ theme, onListUpdated }: ThemeCardProps) => {
const handleSetTheme = async () => {
try {
const currentTheme = await window.electron.getCustomThemeById(theme.id);
const currentTheme = (await levelDBService.get(
theme.id,
"themes"
)) as Theme | null;
if (!currentTheme) return;
const activeTheme = await window.electron.getActiveCustomTheme();
const allThemes = (await levelDBService.values("themes")) as {
id: string;
isActive?: boolean;
}[];
const activeTheme = allThemes.find((t) => t.isActive);
if (activeTheme) {
removeCustomCss();

View File

@@ -10,6 +10,7 @@ import * as yup from "yup";
import { yupResolver } from "@hookform/resolvers/yup";
import { useCallback } from "react";
import { generateUUID } from "@renderer/helpers";
import { levelDBService } from "@renderer/services/leveldb.service";
import "./modals.scss";
@@ -90,7 +91,7 @@ export function AddThemeModal({
updatedAt: new Date(),
};
await window.electron.addCustomTheme(theme);
await levelDBService.put(theme.id, theme, "themes");
onThemeAdded();
onClose();
reset();

View File

@@ -3,6 +3,7 @@ import { Modal } from "@renderer/components/modal/modal";
import { useTranslation } from "react-i18next";
import "./modals.scss";
import { removeCustomCss } from "@renderer/helpers";
import { levelDBService } from "@renderer/services/leveldb.service";
interface DeleteAllThemesModalProps {
visible: boolean;
@@ -18,13 +19,16 @@ export const DeleteAllThemesModal = ({
const { t } = useTranslation("settings");
const handleDeleteAllThemes = async () => {
const activeTheme = await window.electron.getActiveCustomTheme();
const allThemes = (await levelDBService.values("themes")) as {
isActive?: boolean;
}[];
const activeTheme = allThemes.find((theme) => theme.isActive);
if (activeTheme) {
removeCustomCss();
}
await window.electron.deleteAllCustomThemes();
await levelDBService.clear("themes");
await window.electron.closeEditorWindow();
onClose();
onThemesDeleted();

View File

@@ -3,6 +3,7 @@ import { Modal } from "@renderer/components/modal/modal";
import { useTranslation } from "react-i18next";
import "./modals.scss";
import { removeCustomCss } from "@renderer/helpers";
import { levelDBService } from "@renderer/services/leveldb.service";
interface DeleteThemeModalProps {
visible: boolean;
@@ -28,7 +29,7 @@ export const DeleteThemeModal = ({
removeCustomCss();
}
await window.electron.deleteCustomTheme(themeId);
await levelDBService.del(themeId, "themes");
await window.electron.closeEditorWindow(themeId);
onThemeDeleted();
};

View File

@@ -11,6 +11,7 @@ import {
import { useToast } from "@renderer/hooks";
import { THEME_WEB_STORE_URL } from "@renderer/constants";
import { logger } from "@renderer/logger";
import { levelDBService } from "@renderer/services/leveldb.service";
interface ImportThemeModalProps {
visible: boolean;
@@ -45,9 +46,12 @@ export const ImportThemeModal = ({
};
try {
await window.electron.addCustomTheme(theme);
await levelDBService.put(theme.id, theme, "themes");
const currentTheme = await window.electron.getCustomThemeById(theme.id);
const currentTheme = (await levelDBService.get(
theme.id,
"themes"
)) as Theme | null;
if (!currentTheme) return;
@@ -61,7 +65,11 @@ export const ImportThemeModal = ({
logger.error("Failed to import theme sound", soundError);
}
const activeTheme = await window.electron.getActiveCustomTheme();
const allThemes = (await levelDBService.values("themes")) as {
id: string;
isActive?: boolean;
}[];
const activeTheme = allThemes.find((t) => t.isActive);
if (activeTheme) {
removeCustomCss();

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