diff --git a/.cursorrules b/.cursorrules index 5015ab7e..fedb8a3a 100644 --- a/.cursorrules +++ b/.cursorrules @@ -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 `` 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 `` 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`** for array types +- Prefer: `string[]`, `number[]`, `MyType[]` +- Avoid: `Array`, `Array`, `Array` +- This applies to all type annotations, type assertions, and generic type parameters + ## Comments - Keep comments concise and purposeful; avoid verbose explanations. diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml deleted file mode 100644 index e9a91e0c..00000000 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ /dev/null @@ -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 diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml deleted file mode 100644 index 295cee45..00000000 --- a/.github/ISSUE_TEMPLATE/feature_request.yml +++ /dev/null @@ -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 diff --git a/.github/pull-request-template.md b/.github/pull-request-template.md index 3653dd16..22223374 100644 --- a/.github/pull-request-template.md +++ b/.github/pull-request-template.md @@ -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:** - -- diff --git a/.github/workflows/update-aur.yml b/.github/workflows/update-aur.yml index fa12b500..22fcc49a 100644 --- a/.github/workflows/update-aur.yml +++ b/.github/workflows/update-aur.yml @@ -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" diff --git a/README.md b/README.md index 1cdc0f72..c086cb2e 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@
-[](https://help.hydralauncher.gg) +[](https://help.hydralauncher.gg)

Hydra Launcher

diff --git a/package.json b/package.json index e2fec5ee..da6918b5 100644 --- a/package.json +++ b/package.json @@ -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,6 +63,7 @@ "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", @@ -84,7 +85,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", diff --git a/src/locales/en/translation.json b/src/locales/en/translation.json index fc786be8..15f8c3a9 100755 --- a/src/locales/en/translation.json +++ b/src/locales/en/translation.json @@ -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", diff --git a/src/locales/es/translation.json b/src/locales/es/translation.json index 5a65d3cf..12dae377 100644 --- a/src/locales/es/translation.json +++ b/src/locales/es/translation.json @@ -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": { diff --git a/src/locales/pt-BR/translation.json b/src/locales/pt-BR/translation.json index 73d5e8fb..6702c310 100755 --- a/src/locales/pt-BR/translation.json +++ b/src/locales/pt-BR/translation.json @@ -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." }, diff --git a/src/locales/pt-PT/translation.json b/src/locales/pt-PT/translation.json index c8e4586d..e48e1458 100644 --- a/src/locales/pt-PT/translation.json +++ b/src/locales/pt-PT/translation.json @@ -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." }, diff --git a/src/locales/ru/translation.json b/src/locales/ru/translation.json index b831ff2e..1cf7ae2f 100644 --- a/src/locales/ru/translation.json +++ b/src/locales/ru/translation.json @@ -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": "Настройки", diff --git a/src/main/events/auth/index.ts b/src/main/events/auth/index.ts new file mode 100644 index 00000000..e94e9bc5 --- /dev/null +++ b/src/main/events/auth/index.ts @@ -0,0 +1,3 @@ +import "./get-session-hash"; +import "./open-auth-window"; +import "./sign-out"; diff --git a/src/main/events/autoupdater/index.ts b/src/main/events/autoupdater/index.ts new file mode 100644 index 00000000..f6b70367 --- /dev/null +++ b/src/main/events/autoupdater/index.ts @@ -0,0 +1,2 @@ +import "./check-for-updates"; +import "./restart-and-install-update"; diff --git a/src/main/events/catalogue/index.ts b/src/main/events/catalogue/index.ts new file mode 100644 index 00000000..383ba34c --- /dev/null +++ b/src/main/events/catalogue/index.ts @@ -0,0 +1,4 @@ +import "./get-game-assets"; +import "./get-game-shop-details"; +import "./get-game-stats"; +import "./get-random-game"; diff --git a/src/main/events/cloud-save/index.ts b/src/main/events/cloud-save/index.ts new file mode 100644 index 00000000..92e9f528 --- /dev/null +++ b/src/main/events/cloud-save/index.ts @@ -0,0 +1,4 @@ +import "./download-game-artifact"; +import "./get-game-backup-preview"; +import "./select-game-backup-path"; +import "./upload-save-game"; diff --git a/src/main/events/download-sources/index.ts b/src/main/events/download-sources/index.ts new file mode 100644 index 00000000..325d5570 --- /dev/null +++ b/src/main/events/download-sources/index.ts @@ -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"; diff --git a/src/main/events/hardware/index.ts b/src/main/events/hardware/index.ts new file mode 100644 index 00000000..76823f51 --- /dev/null +++ b/src/main/events/hardware/index.ts @@ -0,0 +1,2 @@ +import "./check-folder-write-permission"; +import "./get-disk-free-space"; diff --git a/src/main/events/index.ts b/src/main/events/index.ts index 2720d3ce..8efadf64 100644 --- a/src/main/events/index.ts +++ b/src/main/events/index.ts @@ -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"); diff --git a/src/main/events/leveldb/helpers.ts b/src/main/events/leveldb/helpers.ts new file mode 100644 index 00000000..e171e65a --- /dev/null +++ b/src/main/events/leveldb/helpers.ts @@ -0,0 +1,27 @@ +import { db } from "@main/level"; + +const sublevelCache = new Map< + string, + ReturnType> +>(); + +/** + * 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> => { + if (sublevelCache.has(sublevelName)) { + return sublevelCache.get(sublevelName)!; + } + + // All sublevels use "json" encoding - this cannot be changed per sublevel + const sublevel = db.sublevel(sublevelName, { + valueEncoding: "json", + }); + sublevelCache.set(sublevelName, sublevel); + return sublevel; +}; diff --git a/src/main/events/leveldb/index.ts b/src/main/events/leveldb/index.ts new file mode 100644 index 00000000..6007bd33 --- /dev/null +++ b/src/main/events/leveldb/index.ts @@ -0,0 +1,6 @@ +import "./leveldb-get"; +import "./leveldb-put"; +import "./leveldb-del"; +import "./leveldb-clear"; +import "./leveldb-values"; +import "./leveldb-iterator"; diff --git a/src/main/events/leveldb/leveldb-clear.ts b/src/main/events/leveldb/leveldb-clear.ts new file mode 100644 index 00000000..cbed1db0 --- /dev/null +++ b/src/main/events/leveldb/leveldb-clear.ts @@ -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); diff --git a/src/main/events/leveldb/leveldb-del.ts b/src/main/events/leveldb/leveldb-del.ts new file mode 100644 index 00000000..5bcded1d --- /dev/null +++ b/src/main/events/leveldb/leveldb-del.ts @@ -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); diff --git a/src/main/events/leveldb/leveldb-get.ts b/src/main/events/leveldb/leveldb-get.ts new file mode 100644 index 00000000..059f1b30 --- /dev/null +++ b/src/main/events/leveldb/leveldb-get.ts @@ -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(key, { valueEncoding }); + } catch (error) { + if (error instanceof Error && error.name === "NotFoundError") { + return null; + } + logger.error("Error in leveldbGet", error); + throw error; + } +}; + +registerEvent("leveldbGet", leveldbGet); diff --git a/src/main/events/leveldb/leveldb-iterator.ts b/src/main/events/leveldb/leveldb-iterator.ts new file mode 100644 index 00000000..a1960c31 --- /dev/null +++ b/src/main/events/leveldb/leveldb-iterator.ts @@ -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); diff --git a/src/main/events/leveldb/leveldb-put.ts b/src/main/events/leveldb/leveldb-put.ts new file mode 100644 index 00000000..9c416722 --- /dev/null +++ b/src/main/events/leveldb/leveldb-put.ts @@ -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(key, value, { valueEncoding }); + } + } catch (error) { + logger.error("Error in leveldbPut", error); + throw error; + } +}; + +registerEvent("leveldbPut", leveldbPut); diff --git a/src/main/events/leveldb/leveldb-values.ts b/src/main/events/leveldb/leveldb-values.ts new file mode 100644 index 00000000..0e2c3c0f --- /dev/null +++ b/src/main/events/leveldb/leveldb-values.ts @@ -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); diff --git a/src/main/events/library/index.ts b/src/main/events/library/index.ts new file mode 100644 index 00000000..d9d628d0 --- /dev/null +++ b/src/main/events/library/index.ts @@ -0,0 +1,32 @@ +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-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"; diff --git a/src/main/events/misc/index.ts b/src/main/events/misc/index.ts new file mode 100644 index 00000000..354e6687 --- /dev/null +++ b/src/main/events/misc/index.ts @@ -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"; diff --git a/src/main/events/notifications/index.ts b/src/main/events/notifications/index.ts new file mode 100644 index 00000000..c6e681e8 --- /dev/null +++ b/src/main/events/notifications/index.ts @@ -0,0 +1,3 @@ +import "./publish-new-repacks-notification"; +import "./show-achievement-test-notification"; +import "./update-achievement-notification-window"; diff --git a/src/main/events/profile/index.ts b/src/main/events/profile/index.ts new file mode 100644 index 00000000..1548249f --- /dev/null +++ b/src/main/events/profile/index.ts @@ -0,0 +1,4 @@ +import "./get-me"; +import "./process-profile-image"; +import "./sync-friend-requests"; +import "./update-profile"; diff --git a/src/main/events/themes/index.ts b/src/main/events/themes/index.ts new file mode 100644 index 00000000..5f4d4a02 --- /dev/null +++ b/src/main/events/themes/index.ts @@ -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"; diff --git a/src/main/events/torrenting/index.ts b/src/main/events/torrenting/index.ts new file mode 100644 index 00000000..408ecf17 --- /dev/null +++ b/src/main/events/torrenting/index.ts @@ -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"; diff --git a/src/main/events/torrenting/resume-game-download.ts b/src/main/events/torrenting/resume-game-download.ts index 48bb1c12..4525e2df 100644 --- a/src/main/events/torrenting/resume-game-download.ts +++ b/src/main/events/torrenting/resume-game-download.ts @@ -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()) { diff --git a/src/main/events/user-preferences/index.ts b/src/main/events/user-preferences/index.ts new file mode 100644 index 00000000..aab898e6 --- /dev/null +++ b/src/main/events/user-preferences/index.ts @@ -0,0 +1,5 @@ +import "./authenticate-real-debrid"; +import "./authenticate-torbox"; +import "./auto-launch"; +import "./get-user-preferences"; +import "./update-user-preferences"; diff --git a/src/main/events/user/index.ts b/src/main/events/user/index.ts new file mode 100644 index 00000000..cf63116f --- /dev/null +++ b/src/main/events/user/index.ts @@ -0,0 +1,3 @@ +import "./get-auth"; +import "./get-compared-unlocked-achievements"; +import "./get-unlocked-achievements"; diff --git a/src/main/level/sublevels/downloadSourcesCheckTimestamp.ts b/src/main/level/sublevels/downloadSourcesCheckTimestamp.ts index 4b60b962..36449b4d 100644 --- a/src/main/level/sublevels/downloadSourcesCheckTimestamp.ts +++ b/src/main/level/sublevels/downloadSourcesCheckTimestamp.ts @@ -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 => { 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 => { const utcTimestamp = new Date(timestamp).toISOString(); - await db.put(levelKeys.downloadSourcesSinceValue, utcTimestamp); + await db.put(levelKeys.downloadSourcesSinceValue, utcTimestamp, { + valueEncoding: "utf8", + }); }; diff --git a/src/main/main.ts b/src/main/main.ts index 147ed7dd..9f0ce47c 100644 --- a/src/main/main.ts +++ b/src/main/main.ts @@ -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"; @@ -70,7 +70,7 @@ export const loadState = async () => { .values() .all() .then((games) => { - return sortBy(games, "timestamp", "DESC"); + return orderBy(games, "timestamp", "desc"); }); downloads.forEach((download) => { diff --git a/src/main/services/download/download-manager.ts b/src/main/services/download/download-manager.ts index 4dcebbb0..1a79f8f0 100644 --- a/src/main/services/download/download-manager.ts +++ b/src/main/services/download/download-manager.ts @@ -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"; @@ -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" ); }); diff --git a/src/main/services/hydra-api.ts b/src/main/services/hydra-api.ts index a5a78e4a..fa712105 100644 --- a/src/main/services/hydra-api.ts +++ b/src/main/services/hydra-api.ts @@ -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" } ); diff --git a/src/main/services/system-path.ts b/src/main/services/system-path.ts index 32b34e11..0b42b0aa 100644 --- a/src/main/services/system-path.ts +++ b/src/main/services/system-path.ts @@ -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 { diff --git a/src/main/services/window-manager.ts b/src/main/services/window-manager.ts index b11b4a9b..04c77619 100644 --- a/src/main/services/window-manager.ts +++ b/src/main/services/window-manager.ts @@ -138,7 +138,8 @@ export class WindowManager { (details, callback) => { if ( details.webContentsId !== this.mainWindow?.webContents.id || - details.url.includes("chatwoot") + details.url.includes("chatwoot") || + details.url.includes("workwonders") ) { return callback(details); } @@ -159,7 +160,8 @@ export class WindowManager { if ( details.webContentsId !== this.mainWindow?.webContents.id || details.url.includes("featurebase") || - details.url.includes("chatwoot") + details.url.includes("chatwoot") || + details.url.includes("workwonders") ) { return callback(details); } diff --git a/src/preload/index.ts b/src/preload/index.ts index a2965532..f7c062cb 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -619,4 +619,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), + }, }); diff --git a/src/renderer/index.html b/src/renderer/index.html index 42166e56..6284effc 100644 --- a/src/renderer/index.html +++ b/src/renderer/index.html @@ -6,7 +6,7 @@ Hydra Launcher diff --git a/src/renderer/src/app.tsx b/src/renderer/src/app.tsx index 391e9c03..9badd12e 100644 --- a/src/renderer/src/app.tsx +++ b/src/renderer/src/app.tsx @@ -31,6 +31,8 @@ import { getAchievementSoundUrl, getAchievementSoundVolume, } from "./helpers"; +import { levelDBService } from "./services/leveldb.service"; +import type { UserPreferences } from "@types"; import "./app.scss"; export interface AppProps { @@ -77,11 +79,12 @@ export function App() { const { showSuccessToast } = useToast(); 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(() => { @@ -204,7 +207,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 { diff --git a/src/renderer/src/components/bottom-panel/bottom-panel.tsx b/src/renderer/src/components/bottom-panel/bottom-panel.tsx index 2c32c5da..186fcb4f 100644 --- a/src/renderer/src/components/bottom-panel/bottom-panel.tsx +++ b/src/renderer/src/components/bottom-panel/bottom-panel.tsx @@ -122,10 +122,10 @@ export function BottomPanel() { +
+
    + {historyItems.map((item, index) => ( +
  • + + +
  • + ))} +
+ + )} + + {hasSuggestions && ( +
+
+ + {t("suggestions")} + +
+
    + {suggestions.map((item, index) => ( +
  • + +
  • + ))} +
+
+ )} + + {isLoadingSuggestions && !hasSuggestions && !hasHistory && ( +
{t("loading")}
+ )} + + {!isLoadingSuggestions && + !hasHistory && + !hasSuggestions && + totalItems === 0 && ( +
{t("no_results")}
+ )} + + ); + + return createPortal(dropdownContent, document.body); +} diff --git a/src/renderer/src/context/game-details/game-details.context.tsx b/src/renderer/src/context/game-details/game-details.context.tsx index bc1a6351..29feabf5 100644 --- a/src/renderer/src/context/game-details/game-details.context.tsx +++ b/src/renderer/src/context/game-details/game-details.context.tsx @@ -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, diff --git a/src/renderer/src/context/settings/settings.context.tsx b/src/renderer/src/context/settings/settings.context.tsx index 1160ca3e..338c4e45 100644 --- a/src/renderer/src/context/settings/settings.context.tsx +++ b/src/renderer/src/context/settings/settings.context.tsx @@ -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) => { 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 ( diff --git a/src/renderer/src/declaration.d.ts b/src/renderer/src/declaration.d.ts index e35ed57b..56205b2f 100644 --- a/src/renderer/src/declaration.d.ts +++ b/src/renderer/src/declaration.d.ts @@ -438,6 +438,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; + put: ( + key: string, + value: unknown, + sublevelName?: string | null, + valueEncoding?: "json" | "utf8" + ) => Promise; + del: (key: string, sublevelName?: string | null) => Promise; + clear: (sublevelName: string) => Promise; + values: (sublevelName: string) => Promise; + iterator: (sublevelName: string) => Promise<[string, unknown][]>; + }; } interface Window { diff --git a/src/renderer/src/helpers.ts b/src/renderer/src/helpers.ts index e16aa7a4..0b057754 100644 --- a/src/renderer/src/helpers.ts +++ b/src/renderer/src/helpers.ts @@ -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 => { .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 => { export const getAchievementSoundVolume = async (): Promise => { 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}`; +}; diff --git a/src/renderer/src/hooks/index.ts b/src/renderer/src/hooks/index.ts index 4d34f219..a1666ede 100644 --- a/src/renderer/src/hooks/index.ts +++ b/src/renderer/src/hooks/index.ts @@ -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"; diff --git a/src/renderer/src/hooks/use-catalogue.ts b/src/renderer/src/hooks/use-catalogue.ts index 675f5013..ca2aaa4a 100644 --- a/src/renderer/src/hooks/use-catalogue.ts +++ b/src/renderer/src/hooks/use-catalogue.ts @@ -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)); }); }, []); diff --git a/src/renderer/src/hooks/use-hls-video.ts b/src/renderer/src/hooks/use-hls-video.ts new file mode 100644 index 00000000..eea4065d --- /dev/null +++ b/src/renderer/src/hooks/use-hls-video.ts @@ -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, + { videoSrc, videoType, autoplay, muted, loop }: UseHlsVideoOptions +) { + const hlsRef = useRef(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; +} diff --git a/src/renderer/src/hooks/use-search-history.ts b/src/renderer/src/hooks/use-search-history.ts new file mode 100644 index 00000000..e5ce3efa --- /dev/null +++ b/src/renderer/src/hooks/use-search-history.ts @@ -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([]); + 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, + }; +} diff --git a/src/renderer/src/hooks/use-search-suggestions.ts b/src/renderer/src/hooks/use-search-suggestions.ts new file mode 100644 index 00000000..b8986775 --- /dev/null +++ b/src/renderer/src/hooks/use-search-suggestions.ts @@ -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([]); + const [isLoading, setIsLoading] = useState(false); + const library = useAppSelector((state) => state.library.value); + const abortControllerRef = useRef(null); + const cacheRef = useRef>(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 }; +} diff --git a/src/renderer/src/main.tsx b/src/renderer/src/main.tsx index 84c7f815..92220a6e 100644 --- a/src/renderer/src/main.tsx +++ b/src/renderer/src/main.tsx @@ -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); diff --git a/src/renderer/src/pages/achievements/notification/achievement-notification.tsx b/src/renderer/src/pages/achievements/notification/achievement-notification.tsx index 38b2443b..a362c545 100644 --- a/src/renderer/src/pages/achievements/notification/achievement-notification.tsx +++ b/src/renderer/src/pages/achievements/notification/achievement-notification.tsx @@ -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 { diff --git a/src/renderer/src/pages/downloads/download-group.scss b/src/renderer/src/pages/downloads/download-group.scss index 7602307b..0b9deea3 100644 --- a/src/renderer/src/pages/downloads/download-group.scss +++ b/src/renderer/src/pages/downloads/download-group.scss @@ -4,158 +4,512 @@ 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: opacity 0.2s ease; + outline: none; + + &:hover { + opacity: 0.8; + } + + &:focus, + &:focus-visible { + outline: none; + } + } + + 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%; - border: none; - padding: 8px; + &__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; + + 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 { + 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; } } diff --git a/src/renderer/src/pages/downloads/download-group.tsx b/src/renderer/src/pages/downloads/download-group.tsx index 06e9face..bcecbc7c 100644 --- a/src/renderer/src/pages/downloads/download-group.tsx +++ b/src/renderer/src/pages/downloads/download-group.tsx @@ -1,37 +1,424 @@ -import { useNavigate } from "react-router-dom"; -import cn from "classnames"; - import type { GameShop, LibraryGame, SeedingStatus } from "@types"; import { Badge, Button } from "@renderer/components"; import { - buildGameDetailsPath, formatDownloadProgress, + buildGameDetailsPath, } from "@renderer/helpers"; -import { Downloader, formatBytes } from "@shared"; +import { Downloader, formatBytes, formatBytesToMbps } from "@shared"; +import { addMilliseconds } from "date-fns"; import { DOWNLOADER_NAME } from "@renderer/constants"; -import { useAppSelector, useDownload, useLibrary } from "@renderer/hooks"; +import { + useAppSelector, + useDownload, + useLibrary, + useDate, +} from "@renderer/hooks"; import "./download-group.scss"; import { useTranslation } from "react-i18next"; -import { useCallback, useMemo } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useNavigate } from "react-router-dom"; +import { AnimatePresence, motion } from "framer-motion"; import { DropdownMenu, DropdownMenuItem, } from "@renderer/components/dropdown-menu/dropdown-menu"; import { + ClockIcon, ColumnsIcon, DownloadIcon, FileDirectoryIcon, LinkIcon, PlayIcon, - QuestionIcon, ThreeBarsIcon, TrashIcon, UnlinkIcon, XCircleIcon, + GraphIcon, } from "@primer/octicons-react"; +import { average } from "color.js"; + +interface AnimatedPercentageProps { + value: number; +} + +function AnimatedPercentage({ value }: Readonly) { + const percentageText = formatDownloadProgress(value); + const prevTextRef = useRef(percentageText); + const chars = percentageText.split(""); + const prevChars = prevTextRef.current.split(""); + + useEffect(() => { + prevTextRef.current = percentageText; + }, [percentageText]); + + return ( + <> + {chars.map((char, index) => { + const prevChar = prevChars[index]; + const charChanged = prevChar !== char; + + return ( + + + {char} + + + ); + })} + + ); +} + +interface SpeedChartProps { + speeds: number[]; + peakSpeed: number; + color?: string; +} + +function SpeedChart({ + speeds, + peakSpeed, + color = "rgba(255, 255, 255, 1)", +}: Readonly) { + const canvasRef = useRef(null); + + useEffect(() => { + const canvas = canvasRef.current; + if (!canvas) return; + + const ctx = canvas.getContext("2d"); + if (!ctx) return; + + let animationFrameId: number; + let resizeObserver: ResizeObserver | null = null; + + const draw = () => { + const clientWidth = canvas.clientWidth; + const dpr = window.devicePixelRatio || 1; + + canvas.width = clientWidth * dpr; + canvas.height = 100 * dpr; + ctx.scale(dpr, dpr); + + const width = clientWidth; + const height = 100; + const barWidth = 4; + const barGap = 10; + const barSpacing = barWidth + barGap; + + // Calculate how many bars can fit in the available width + const totalBars = Math.max(1, Math.floor((width + barGap) / barSpacing)); + const maxHeight = peakSpeed || Math.max(...speeds, 1); + + ctx.clearRect(0, 0, width, height); + + let r = 255, + g = 255, + b = 255; + if (color.startsWith("#")) { + const hex = color.replace("#", ""); + r = Number.parseInt(hex.substring(0, 2), 16); + g = Number.parseInt(hex.substring(2, 4), 16); + b = Number.parseInt(hex.substring(4, 6), 16); + } else if (color.startsWith("rgb")) { + const matches = color.match(/\d+/g); + if (matches && matches.length >= 3) { + r = Number.parseInt(matches[0]); + g = Number.parseInt(matches[1]); + b = Number.parseInt(matches[2]); + } + } + const displaySpeeds = speeds.slice(-totalBars); + + for (let i = 0; i < totalBars; i++) { + const x = i * barSpacing; + ctx.fillStyle = "rgba(255, 255, 255, 0.08)"; + ctx.beginPath(); + ctx.roundRect(x, 0, barWidth, height, 3); + ctx.fill(); + + if (i < displaySpeeds.length) { + const speed = displaySpeeds[i] || 0; + const filledHeight = (speed / maxHeight) * height; + + if (filledHeight > 0) { + const gradient = ctx.createLinearGradient( + 0, + height - filledHeight, + 0, + height + ); + + gradient.addColorStop(0, `rgba(${r}, ${g}, ${b}, 1)`); + gradient.addColorStop(1, `rgba(${r}, ${g}, ${b}, 0.7)`); + + ctx.fillStyle = gradient; + ctx.beginPath(); + ctx.roundRect(x, height - filledHeight, barWidth, filledHeight, 3); + ctx.fill(); + } + } + } + animationFrameId = requestAnimationFrame(draw); + }; + + animationFrameId = requestAnimationFrame(draw); + + // Handle resize - trigger redraw when canvas size changes + resizeObserver = new ResizeObserver(() => { + // Cancel any pending animation frame to force immediate redraw + if (animationFrameId) { + cancelAnimationFrame(animationFrameId); + } + // Trigger a redraw that will recalculate bars based on new width + draw(); + }); + resizeObserver.observe(canvas); + + return () => { + cancelAnimationFrame(animationFrameId); + if (resizeObserver) { + resizeObserver.disconnect(); + } + }; + }, [speeds, peakSpeed, color]); + + return ( + + ); +} + +interface HeroDownloadViewProps { + game: LibraryGame; + isGameDownloading: boolean; + downloadSpeed: number; + finalDownloadSize: string; + peakSpeed: number; + currentProgress: number; + dominantColor: string; + lastPacket: ReturnType["lastPacket"]; + speedHistory: number[]; + formatSpeed: (speed: number) => string; + calculateETA: () => string; + pauseDownload: (shop: GameShop, objectId: string) => void; + resumeDownload: (shop: GameShop, objectId: string) => void; + cancelDownload: (shop: GameShop, objectId: string) => void; + t: (key: string) => string; +} + +function HeroDownloadView({ + game, + isGameDownloading, + downloadSpeed, + finalDownloadSize, + peakSpeed, + currentProgress, + dominantColor, + lastPacket, + speedHistory, + formatSpeed, + calculateETA, + pauseDownload, + resumeDownload, + cancelDownload, + t, +}: Readonly) { + const navigate = useNavigate(); + + const handleLogoClick = useCallback(() => { + navigate(buildGameDetailsPath(game)); + }, [navigate, game]); + + return ( +
+
+ {game.title} +
+
+ +
+
+
+ {game.logoImageUrl ? ( + + ) : ( + + )} +
+
+ +
+
+
+
+ {lastPacket?.isCheckingFiles ? ( + + {t("checking_files")} + + ) : ( + + + {isGameDownloading && lastPacket + ? `${formatBytes(lastPacket.download.bytesDownloaded)} / ${finalDownloadSize}` + : `0 B / ${finalDownloadSize}`} + + )} + +
+
+ {!lastPacket?.isCheckingFiles && ( + + {isGameDownloading && + lastPacket?.timeRemaining && + lastPacket.timeRemaining > 0 && ( + <> + + {calculateETA()} + + )} + + )} + + + +
+
+
+
+
+
+ {isGameDownloading ? ( + + ) : ( + + )} + +
+
+
+ +
+
+
+ + + +
+ + {t("network")}: + + + {isGameDownloading ? formatSpeed(downloadSpeed) : "0 B/s"} + +
+
+ +
+ + + +
+ {t("peak")}: + + {peakSpeed > 0 ? formatSpeed(peakSpeed) : "0 B/s"} + +
+
+ + {game.download?.downloader === Downloader.Torrent && + isGameDownloading && + lastPacket && + (lastPacket.numSeeds > 0 || lastPacket.numPeers > 0) && ( +
+
+ + Seeds:{" "} + + {lastPacket.numSeeds} + + , Peers:{" "} + + {lastPacket.numPeers} + + +
+
+ )} + + {game.download?.downloader && ( +
+
+ {DOWNLOADER_NAME[game.download.downloader]} +
+
+ )} +
+ +
+ +
+
+
+
+ ); +} export interface DownloadGroupProps { library: LibraryGame[]; @@ -48,8 +435,6 @@ export function DownloadGroup({ openGameInstaller, seedingStatus, }: Readonly) { - const navigate = useNavigate(); - const { t } = useTranslation("downloads"); const userPreferences = useAppSelector( @@ -60,20 +445,215 @@ export function DownloadGroup({ const { lastPacket, - progress, - pauseDownload, - resumeDownload, + pauseDownload: pauseDownloadOriginal, + resumeDownload: resumeDownloadOriginal, cancelDownload, isGameDeleting, pauseSeeding, resumeSeeding, } = useDownload(); + // Wrap resumeDownload with optimistic update + const resumeDownload = useCallback( + async (shop: GameShop, objectId: string) => { + const gameId = `${shop}:${objectId}`; + + // Optimistically mark as downloading + setOptimisticallyResumed((prev) => ({ ...prev, [gameId]: true })); + + try { + await resumeDownloadOriginal(shop, objectId); + } catch (error) { + // If resume fails, remove optimistic state + setOptimisticallyResumed((prev) => { + const next = { ...prev }; + delete next[gameId]; + return next; + }); + throw error; + } + }, + [resumeDownloadOriginal] + ); + + // Wrap pauseDownload to clear optimistic state + const pauseDownload = useCallback( + async (shop: GameShop, objectId: string) => { + const gameId = `${shop}:${objectId}`; + + // Clear optimistic state when pausing + setOptimisticallyResumed((prev) => { + const next = { ...prev }; + delete next[gameId]; + return next; + }); + + await pauseDownloadOriginal(shop, objectId); + }, + [pauseDownloadOriginal] + ); + + const { formatDistance } = useDate(); + + const [peakSpeeds, setPeakSpeeds] = useState>({}); + const speedHistoryRef = useRef>({}); + const [dominantColors, setDominantColors] = useState>( + {} + ); + const [optimisticallyResumed, setOptimisticallyResumed] = useState< + Record + >({}); + + const extractDominantColor = useCallback( + async (imageUrl: string, gameId: string) => { + if (dominantColors[gameId]) return; + + try { + const color = await average(imageUrl, { amount: 1, format: "hex" }); + const colorString = + typeof color === "string" ? color : color.toString(); + setDominantColors((prev) => ({ ...prev, [gameId]: colorString })); + } catch (error) { + console.error("Failed to extract dominant color:", error); + } + }, + [dominantColors] + ); + + // Clear optimistic state when actual download starts or library updates + useEffect(() => { + if (lastPacket?.gameId) { + const gameId = lastPacket.gameId; + + // Clear optimistic state when actual download starts + setOptimisticallyResumed((prev) => { + const next = { ...prev }; + delete next[gameId]; + return next; + }); + } + }, [lastPacket?.gameId]); + + // Clear optimistic state for games that are no longer active after library update + useEffect(() => { + setOptimisticallyResumed((prev) => { + const next = { ...prev }; + let changed = false; + + for (const gameId in next) { + if (next[gameId]) { + const game = library.find((g) => g.id === gameId); + // Clear if game doesn't exist or download status is not active + if ( + !game || + game.download?.status !== "active" || + lastPacket?.gameId === gameId + ) { + delete next[gameId]; + changed = true; + } + } + } + + return changed ? next : prev; + }); + }, [library, lastPacket?.gameId]); + + useEffect(() => { + if (lastPacket?.gameId && lastPacket.downloadSpeed !== undefined) { + const gameId = lastPacket.gameId; + + const currentPeak = peakSpeeds[gameId] || 0; + if (lastPacket.downloadSpeed > currentPeak) { + setPeakSpeeds((prev) => ({ + ...prev, + [gameId]: lastPacket.downloadSpeed, + })); + } + + if (!speedHistoryRef.current[gameId]) { + speedHistoryRef.current[gameId] = []; + } + + speedHistoryRef.current[gameId].push(lastPacket.downloadSpeed); + + if (speedHistoryRef.current[gameId].length > 120) { + speedHistoryRef.current[gameId].shift(); + } + } + }, [lastPacket?.gameId, lastPacket?.downloadSpeed, peakSpeeds]); + + useEffect(() => { + for (const game of library) { + if ( + game.download && + game.download.progress < 0.01 && + game.download.status !== "paused" + ) { + // Fresh download - clear any old data + if (speedHistoryRef.current[game.id]?.length > 0) { + speedHistoryRef.current[game.id] = []; + setPeakSpeeds((prev) => ({ ...prev, [game.id]: 0 })); + } + } + } + }, [library]); + + useEffect(() => { + const timeouts: NodeJS.Timeout[] = []; + + for (const game of library) { + if ( + game.download?.progress === 1 && + speedHistoryRef.current[game.id]?.length > 0 + ) { + const timeout = setTimeout(() => { + speedHistoryRef.current[game.id] = []; + setPeakSpeeds((prev) => ({ ...prev, [game.id]: 0 })); + }, 10_000); + timeouts.push(timeout); + } + } + + return () => { + for (const timeout of timeouts) { + clearTimeout(timeout); + } + }; + }, [library]); + + useEffect(() => { + if (library.length > 0 && title === t("download_in_progress")) { + const game = library[0]; + const heroImageUrl = + game.libraryHeroImageUrl || game.libraryImageUrl || ""; + if (heroImageUrl && game.id) { + extractDominantColor(heroImageUrl, game.id); + } + } + }, [library, title, t, extractDominantColor]); + + const isGameSeeding = (game: LibraryGame) => { + const entry = seedingStatus.find((s) => s.gameId === game.id); + if (entry?.status) return entry.status === "seeding"; + return game.download?.status === "seeding"; + }; + + const isGameDownloadingMap = useMemo(() => { + const map: Record = {}; + for (const game of library) { + map[game.id] = + lastPacket?.gameId === game.id || + optimisticallyResumed[game.id] === true; + } + return map; + }, [library, lastPacket?.gameId, optimisticallyResumed]); + const getFinalDownloadSize = (game: LibraryGame) => { const download = game.download!; - const isGameDownloading = lastPacket?.gameId === game.id; + const isGameDownloading = isGameDownloadingMap[game.id]; - if (download.fileSize) return formatBytes(download.fileSize); + if (download.fileSize != null) return formatBytes(download.fileSize); if (lastPacket?.download.fileSize && isGameDownloading) return formatBytes(lastPacket.download.fileSize); @@ -81,15 +661,27 @@ export function DownloadGroup({ return "N/A"; }; - const seedingMap = useMemo(() => { - const map = new Map(); + const formatSpeed = (speed: number): string => { + return userPreferences?.showDownloadSpeedInMegabytes + ? `${formatBytes(speed)}/s` + : formatBytesToMbps(speed); + }; - seedingStatus.forEach((seed) => { - map.set(seed.gameId, seed); - }); + const calculateETA = () => { + if ( + !lastPacket || + lastPacket.timeRemaining < 0 || + !Number.isFinite(lastPacket.timeRemaining) + ) { + return ""; + } - return map; - }, [seedingStatus]); + return formatDistance( + addMilliseconds(new Date(), lastPacket.timeRemaining), + new Date(), + { addSuffix: true } + ); + }; const extractGameDownload = useCallback( async (shop: GameShop, objectId: string) => { @@ -99,110 +691,14 @@ export function DownloadGroup({ [updateLibrary] ); - const getGameInfo = (game: LibraryGame) => { - const download = game.download!; - - const isGameDownloading = lastPacket?.gameId === game.id; - const finalDownloadSize = getFinalDownloadSize(game); - const seedingStatus = seedingMap.get(game.id); - - if (download.extracting) { - return

{t("extracting")}

; - } - - if (isGameDeleting(game.id)) { - return

{t("deleting")}

; - } - - if (isGameDownloading) { - if (lastPacket?.isDownloadingMetadata) { - return

{t("downloading_metadata")}

; - } - - if (lastPacket?.isCheckingFiles) { - return ( - <> -

{progress}

-

{t("checking_files")}

- - ); - } - - return ( - <> -

{progress}

- -

- {formatBytes(lastPacket.download.bytesDownloaded)} /{" "} - {finalDownloadSize} -

- - {download.downloader === Downloader.Torrent && ( - - {lastPacket?.numPeers} peers / {lastPacket?.numSeeds} seeds - - - )} - - ); - } - - if (download.progress === 1) { - const uploadSpeed = formatBytes(seedingStatus?.uploadSpeed ?? 0); - - return download.status === "seeding" && - download.downloader === Downloader.Torrent ? ( - <> -

- {t("seeding")} - - -

- {uploadSpeed &&

{uploadSpeed}/s

} - - ) : ( -

{t("completed")}

- ); - } - - if (download.status === "paused") { - return ( - <> -

{formatDownloadProgress(download.progress)}

-

{t(download.queued ? "queued" : "paused")}

- - ); - } - - if (download.status === "active") { - return ( - <> -

{formatDownloadProgress(download.progress)}

- -

- {formatBytes(download.bytesDownloaded)} / {finalDownloadSize} -

- - ); - } - - return

{t(download.status as string)}

; - }; - const getGameActions = (game: LibraryGame): DropdownMenuItem[] => { const download = lastPacket?.download; - const isGameDownloading = lastPacket?.gameId === game.id; + const isGameDownloading = isGameDownloadingMap[game.id]; const deleting = isGameDeleting(game.id); if (game.download?.progress === 1) { - return [ + const actions = [ { label: t("install"), disabled: deleting, @@ -224,7 +720,7 @@ export function DownloadGroup({ disabled: deleting, icon: , show: - game.download?.status === "seeding" && + isGameSeeding(game) && game.download?.downloader === Downloader.Torrent, onClick: () => { pauseSeeding(game.shop, game.objectId); @@ -235,7 +731,7 @@ export function DownloadGroup({ disabled: deleting, icon: , show: - game.download?.status !== "seeding" && + !isGameSeeding(game) && game.download?.downloader === Downloader.Torrent, onClick: () => { resumeSeeding(game.shop, game.objectId); @@ -250,6 +746,7 @@ export function DownloadGroup({ }, }, ]; + return actions.filter((action) => action.show !== false); } if (isGameDownloading) { @@ -296,80 +793,157 @@ export function DownloadGroup({ ]; }; + const downloadInfo = useMemo( + () => + library.map((game) => ({ + game, + size: getFinalDownloadSize(game), + progress: game.download?.progress || 0, + isSeeding: isGameSeeding(game), + })), + [ + library, + lastPacket?.gameId, + lastPacket?.download.fileSize, + isGameDownloadingMap, + seedingStatus, + ] + ); + if (!library.length) return null; + const isDownloadingGroup = title === t("download_in_progress"); + const isQueuedGroup = title === t("queued_downloads"); + const isCompletedGroup = title === t("downloads_completed"); + + if (isDownloadingGroup && library.length > 0) { + const game = library[0]; + const isGameDownloading = isGameDownloadingMap[game.id]; + const downloadSpeed = isGameDownloading + ? (lastPacket?.downloadSpeed ?? 0) + : 0; + const finalDownloadSize = getFinalDownloadSize(game); + const peakSpeed = peakSpeeds[game.id] || 0; + const currentProgress = + isGameDownloading && lastPacket + ? lastPacket.progress + : game.download?.progress || 0; + + const dominantColor = dominantColors[game.id] || "#fff"; + + return ( + + ); + } + return ( -
+
-

{title}

-
-

{library.length}

+
+

{title}

+

{library.length}

+
-
    - {library.map((game) => { +
      + {downloadInfo.map(({ game, size, progress, isSeeding: seeding }) => { return ( -
    • -
      -
      - {game.title} +
    • +
      + {game.title} +
      -
      +
      +

      {game.title}

      +
      +
      {DOWNLOADER_NAME[game.download!.downloader]}
      -
      -
      -
      -
      -
      - +
      + {game.download?.extracting ? ( + + {t("extracting")} + + ) : ( + + + {size} + + )} + {game.download?.progress === 1 && seeding && ( + + {t("seeding")} + + )}
      - - {getGameInfo(game)}
      - - {getGameActions(game) !== null && ( - - - - )}
      - {game.download?.downloader === Downloader.Hydra && ( -
      + {isQueuedGroup && ( +
      + + {formatDownloadProgress(progress)} + +
      +
      +
      +
      )} + +
      + {game.download?.progress === 1 && ( + + )} + {isQueuedGroup && game.download?.progress !== 1 && ( + + )} + + + +
    • ); })} diff --git a/src/renderer/src/pages/downloads/downloads.scss b/src/renderer/src/pages/downloads/downloads.scss index 8290a66e..abada8d7 100644 --- a/src/renderer/src/pages/downloads/downloads.scss +++ b/src/renderer/src/pages/downloads/downloads.scss @@ -3,7 +3,6 @@ .downloads { &__container { display: flex; - padding: calc(globals.$spacing-unit * 3); flex-direction: column; width: 100%; } diff --git a/src/renderer/src/pages/game-details/gallery-slider/gallery-slider.tsx b/src/renderer/src/pages/game-details/gallery-slider/gallery-slider.tsx index c9658636..e19cbf26 100644 --- a/src/renderer/src/pages/game-details/gallery-slider/gallery-slider.tsx +++ b/src/renderer/src/pages/game-details/gallery-slider/gallery-slider.tsx @@ -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) => (
      {item.type === "video" ? ( - + /> ) : ( (null); + const isHls = videoType === "application/x-mpegURL"; + + useHlsVideo(videoRef, { + videoSrc, + videoType, + autoplay, + muted, + loop, + }); + + if (isHls) { + return ( + + ); + } + + return ( + + ); +} diff --git a/src/renderer/src/pages/game-details/modals/game-options-modal.tsx b/src/renderer/src/pages/game-details/modals/game-options-modal.tsx index e658fbb8..387c2356 100644 --- a/src/renderer/src/pages/game-details/modals/game-options-modal.tsx +++ b/src/renderer/src/pages/game-details/modals/game-options-modal.tsx @@ -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(); }; diff --git a/src/renderer/src/pages/game-details/modals/repacks-modal.tsx b/src/renderer/src/pages/game-details/modals/repacks-modal.tsx index 3754ef83..683ce53a 100644 --- a/src/renderer/src/pages/game-details/modals/repacks-modal.tsx +++ b/src/renderer/src/pages/game-details/modals/repacks-modal.tsx @@ -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"; @@ -28,6 +28,8 @@ import { useAppSelector, } 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 { @@ -106,8 +108,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(); @@ -117,10 +122,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 && userPreferences?.enableNewDownloadOptionsBadges !== false) { @@ -136,7 +150,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 })); @@ -214,9 +241,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); diff --git a/src/renderer/src/pages/home/home.tsx b/src/renderer/src/pages/home/home.tsx index b8f632a6..91c9b2ff 100644 --- a/src/renderer/src/pages/home/home.tsx +++ b/src/renderer/src/pages/home/home.tsx @@ -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, diff --git a/src/renderer/src/pages/library/library.tsx b/src/renderer/src/pages/library/library.tsx index 0efe8fb2..48d7e2f7 100644 --- a/src/renderer/src/pages/library/library.tsx +++ b/src/renderer/src/pages/library/library.tsx @@ -76,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); diff --git a/src/renderer/src/pages/settings/aparence/components/theme-card.tsx b/src/renderer/src/pages/settings/aparence/components/theme-card.tsx index 68eed4c3..eb0ca287 100644 --- a/src/renderer/src/pages/settings/aparence/components/theme-card.tsx +++ b/src/renderer/src/pages/settings/aparence/components/theme-card.tsx @@ -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(); diff --git a/src/renderer/src/pages/settings/aparence/modals/add-theme-modal.tsx b/src/renderer/src/pages/settings/aparence/modals/add-theme-modal.tsx index 522d8546..c8a2c80d 100644 --- a/src/renderer/src/pages/settings/aparence/modals/add-theme-modal.tsx +++ b/src/renderer/src/pages/settings/aparence/modals/add-theme-modal.tsx @@ -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(); diff --git a/src/renderer/src/pages/settings/aparence/modals/delete-all-themes-modal.tsx b/src/renderer/src/pages/settings/aparence/modals/delete-all-themes-modal.tsx index 9439d273..fa21bc2c 100644 --- a/src/renderer/src/pages/settings/aparence/modals/delete-all-themes-modal.tsx +++ b/src/renderer/src/pages/settings/aparence/modals/delete-all-themes-modal.tsx @@ -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(); diff --git a/src/renderer/src/pages/settings/aparence/modals/delete-theme-modal.tsx b/src/renderer/src/pages/settings/aparence/modals/delete-theme-modal.tsx index c1a5a1e0..d2158f6f 100644 --- a/src/renderer/src/pages/settings/aparence/modals/delete-theme-modal.tsx +++ b/src/renderer/src/pages/settings/aparence/modals/delete-theme-modal.tsx @@ -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(); }; diff --git a/src/renderer/src/pages/settings/aparence/modals/import-theme-modal.tsx b/src/renderer/src/pages/settings/aparence/modals/import-theme-modal.tsx index 93baf1cd..e729ae29 100644 --- a/src/renderer/src/pages/settings/aparence/modals/import-theme-modal.tsx +++ b/src/renderer/src/pages/settings/aparence/modals/import-theme-modal.tsx @@ -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(); diff --git a/src/renderer/src/pages/settings/aparence/settings-appearance.tsx b/src/renderer/src/pages/settings/aparence/settings-appearance.tsx index 413e52e9..24247396 100644 --- a/src/renderer/src/pages/settings/aparence/settings-appearance.tsx +++ b/src/renderer/src/pages/settings/aparence/settings-appearance.tsx @@ -5,6 +5,7 @@ import type { Theme } from "@types"; import { ImportThemeModal } from "./modals/import-theme-modal"; import { settingsContext } from "@renderer/context"; import { useNavigate } from "react-router-dom"; +import { levelDBService } from "@renderer/services/leveldb.service"; interface SettingsAppearanceProps { appearance: { @@ -31,7 +32,7 @@ export function SettingsAppearance({ const navigate = useNavigate(); const loadThemes = useCallback(async () => { - const themesList = await window.electron.getAllCustomThemes(); + const themesList = (await levelDBService.values("themes")) as Theme[]; setThemes(themesList); }, []); diff --git a/src/renderer/src/pages/settings/settings-download-sources.tsx b/src/renderer/src/pages/settings/settings-download-sources.tsx index 675919e3..f597838e 100644 --- a/src/renderer/src/pages/settings/settings-download-sources.tsx +++ b/src/renderer/src/pages/settings/settings-download-sources.tsx @@ -21,6 +21,8 @@ import { DownloadSourceStatus } from "@shared"; import { settingsContext } from "@renderer/context"; import { useNavigate } from "react-router-dom"; import { setFilters, clearFilters } from "@renderer/features"; +import { levelDBService } from "@renderer/services/leveldb.service"; +import { orderBy } from "lodash-es"; import "./settings-download-sources.scss"; import { logger } from "@renderer/logger"; @@ -52,8 +54,11 @@ export function SettingsDownloadSources() { 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(); @@ -73,8 +78,11 @@ export function SettingsDownloadSources() { const intervalId = setInterval(async () => { try { await window.electron.syncDownloadSources(); - const sources = await window.electron.getDownloadSources(); - setDownloadSources(sources); + const sources = (await levelDBService.values( + "downloadSources" + )) as DownloadSource[]; + const sorted = orderBy(sources, "createdAt", "desc"); + setDownloadSources(sorted); } catch (error) { logger.error("Failed to fetch download sources:", error); } @@ -88,8 +96,11 @@ export function SettingsDownloadSources() { try { await window.electron.removeDownloadSource(false, downloadSource.id); - const sources = await window.electron.getDownloadSources(); - setDownloadSources(sources); + const sources = (await levelDBService.values( + "downloadSources" + )) as DownloadSource[]; + const sorted = orderBy(sources, "createdAt", "desc"); + setDownloadSources(sorted); showSuccessToast(t("removed_download_source")); } catch (error) { logger.error("Failed to remove download source:", error); @@ -103,8 +114,11 @@ export function SettingsDownloadSources() { try { await window.electron.removeDownloadSource(true); - const sources = await window.electron.getDownloadSources(); - setDownloadSources(sources); + const sources = (await levelDBService.values( + "downloadSources" + )) as DownloadSource[]; + const sorted = orderBy(sources, "createdAt", "desc"); + setDownloadSources(sorted); showSuccessToast(t("removed_all_download_sources")); } catch (error) { logger.error("Failed to remove all download sources:", error); @@ -116,8 +130,11 @@ export function SettingsDownloadSources() { const handleAddDownloadSource = async () => { try { - const sources = await window.electron.getDownloadSources(); - setDownloadSources(sources); + const sources = (await levelDBService.values( + "downloadSources" + )) as DownloadSource[]; + const sorted = orderBy(sources, "createdAt", "desc"); + setDownloadSources(sorted); } catch (error) { logger.error("Failed to refresh download sources:", error); } @@ -127,8 +144,11 @@ export function SettingsDownloadSources() { setIsSyncingDownloadSources(true); try { await window.electron.syncDownloadSources(); - const sources = await window.electron.getDownloadSources(); - setDownloadSources(sources); + const sources = (await levelDBService.values( + "downloadSources" + )) as DownloadSource[]; + const sorted = orderBy(sources, "createdAt", "desc"); + setDownloadSources(sorted); showSuccessToast(t("download_sources_synced_successfully")); } finally { diff --git a/src/renderer/src/pages/theme-editor/theme-editor.tsx b/src/renderer/src/pages/theme-editor/theme-editor.tsx index 3f0be9cf..41dc7a7f 100644 --- a/src/renderer/src/pages/theme-editor/theme-editor.tsx +++ b/src/renderer/src/pages/theme-editor/theme-editor.tsx @@ -16,6 +16,7 @@ import { injectCustomCss, getAchievementSoundVolume } from "@renderer/helpers"; import { AchievementNotificationItem } from "@renderer/components/achievements/notification/achievement-notification"; import { generateAchievementCustomNotificationTest } from "@shared"; import { CollapsedMenu } from "@renderer/components/collapsed-menu/collapsed-menu"; +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"; @@ -64,15 +65,16 @@ export default function ThemeEditor() { useEffect(() => { if (themeId) { - window.electron.getCustomThemeById(themeId).then((loadedTheme) => { - if (loadedTheme) { - setTheme(loadedTheme); - setCode(loadedTheme.code); - if (loadedTheme.originalSoundPath) { - setSoundPath(loadedTheme.originalSoundPath); + levelDBService.get(themeId, "themes").then((loadedTheme) => { + const theme = loadedTheme as Theme | null; + if (theme) { + setTheme(theme); + setCode(theme.code); + if (theme.originalSoundPath) { + setSoundPath(theme.originalSoundPath); } if (shadowRootRef) { - injectCustomCss(loadedTheme.code, shadowRootRef); + injectCustomCss(theme.code, shadowRootRef); } } }); @@ -132,7 +134,10 @@ export default function ThemeEditor() { if (filePaths && filePaths.length > 0) { const originalPath = filePaths[0]; await window.electron.copyThemeAchievementSound(theme.id, originalPath); - const updatedTheme = await window.electron.getCustomThemeById(theme.id); + const updatedTheme = (await levelDBService.get( + theme.id, + "themes" + )) as Theme | null; if (updatedTheme) { setTheme(updatedTheme); if (updatedTheme.originalSoundPath) { @@ -146,7 +151,10 @@ export default function ThemeEditor() { if (!theme) return; await window.electron.removeThemeAchievementSound(theme.id); - const updatedTheme = await window.electron.getCustomThemeById(theme.id); + const updatedTheme = (await levelDBService.get( + theme.id, + "themes" + )) as Theme | null; if (updatedTheme) { setTheme(updatedTheme); } diff --git a/src/renderer/src/services/leveldb.service.ts b/src/renderer/src/services/leveldb.service.ts new file mode 100644 index 00000000..68e5e9f1 --- /dev/null +++ b/src/renderer/src/services/leveldb.service.ts @@ -0,0 +1,36 @@ +class LevelDBService { + get( + key: string, + sublevelName?: string | null, + valueEncoding?: "json" | "utf8" + ): Promise { + return window.electron.leveldb.get(key, sublevelName, valueEncoding); + } + + put( + key: string, + value: unknown, + sublevelName?: string | null, + valueEncoding?: "json" | "utf8" + ): Promise { + return window.electron.leveldb.put(key, value, sublevelName, valueEncoding); + } + + del(key: string, sublevelName?: string | null): Promise { + return window.electron.leveldb.del(key, sublevelName); + } + + clear(sublevelName: string): Promise { + return window.electron.leveldb.clear(sublevelName); + } + + values(sublevelName: string): Promise { + return window.electron.leveldb.values(sublevelName); + } + + iterator(sublevelName: string): Promise<[string, unknown][]> { + return window.electron.leveldb.iterator(sublevelName); + } +} + +export const levelDBService = new LevelDBService(); diff --git a/src/types/level.types.ts b/src/types/level.types.ts index 98ae0eb2..c8a00f65 100644 --- a/src/types/level.types.ts +++ b/src/types/level.types.ts @@ -20,6 +20,8 @@ export interface Auth { accessToken: string; refreshToken: string; tokenExpirationTimestamp: number; + featurebaseJwt: string; + workwondersJwt: string; } export interface User { diff --git a/src/types/steam.types.ts b/src/types/steam.types.ts index 4dcf460a..54164e3c 100644 --- a/src/types/steam.types.ts +++ b/src/types/steam.types.ts @@ -14,10 +14,13 @@ export interface SteamVideoSource { "480": string; } -export interface SteamMovies { +export interface SteamMovie { id: number; - mp4: SteamVideoSource; - webm: SteamVideoSource; + dash_av1?: string; + dash_h264?: string; + hls_h264?: string; + mp4?: SteamVideoSource; + webm?: SteamVideoSource; thumbnail: string; name: string; highlight: boolean; @@ -31,7 +34,7 @@ export interface SteamAppDetails { short_description: string; publishers: string[]; genres: SteamGenre[]; - movies?: SteamMovies[]; + movies?: SteamMovie[]; supported_languages: string; screenshots?: SteamScreenshot[]; pc_requirements: { diff --git a/yarn.lock b/yarn.lock index da346e42..416f4a21 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5690,6 +5690,11 @@ hasown@^2.0.2: dependencies: function-bind "^1.1.2" +hls.js@^1.5.12: + version "1.6.15" + resolved "https://registry.yarnpkg.com/hls.js/-/hls.js-1.6.15.tgz#9ce13080d143a9bc9b903fb43f081e335b8321e5" + integrity sha512-E3a5VwgXimGHwpRGV+WxRTKeSp2DW5DI5MWv34ulL3t5UNmyJWCQ1KmLEHbYzcfThfXG8amBL+fCYPneGHC4VA== + hoist-non-react-statics@^3.3.2: version "3.3.2" resolved "https://registry.yarnpkg.com/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz#ece0acaf71d62c2969c2ec59feff42a4b1a85b45" @@ -6205,9 +6210,9 @@ jiti@^2.6.1: integrity sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ== js-yaml@^4.1.0: - version "4.1.0" - resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-4.1.0.tgz#c1fb65f8f5017901cdd2c951864ba18458a10602" - integrity sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA== + version "4.1.1" + resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-4.1.1.tgz#854c292467705b699476e1a2decc0c8a3458806b" + integrity sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA== dependencies: argparse "^2.0.1" @@ -6325,7 +6330,7 @@ jsonwebtoken@^9.0.2: object.assign "^4.1.4" object.values "^1.1.6" -jwa@^1.4.1: +jwa@^1.4.2: version "1.4.2" resolved "https://registry.yarnpkg.com/jwa/-/jwa-1.4.2.tgz#16011ac6db48de7b102777e57897901520eec7b9" integrity sha512-eeH5JO+21J78qMvTIDdBXidBd6nG2kZjg5Ohz/1fpa28Z4CcsWUzJ1ZZyFq/3z3N17aZy+ZuBoHljASbL1WfOw== @@ -6335,11 +6340,11 @@ jwa@^1.4.1: safe-buffer "^5.0.1" jws@^3.2.2: - version "3.2.2" - resolved "https://registry.yarnpkg.com/jws/-/jws-3.2.2.tgz#001099f3639468c9414000e99995fa52fb478304" - integrity sha512-YHlZCB6lMTllWDtSPHz/ZXTsi8S00usEV6v1tjq8tOUZzw7DpSDWVXjXDre6ed1w/pd495ODpHZYSdkRTsa0HA== + version "3.2.3" + resolved "https://registry.yarnpkg.com/jws/-/jws-3.2.3.tgz#5ac0690b460900a27265de24520526853c0b8ca1" + integrity sha512-byiJ0FLRdLdSVSReO/U4E7RoEyOCKnEnEPMjq3HxWtvzLsV08/i5RQKsFVNkCldrCaPr2vDNAOMsfs8T/Hze7g== dependencies: - jwa "^1.4.1" + jwa "^1.4.2" safe-buffer "^5.0.1" keyv@^4.0.0, keyv@^4.5.3: @@ -8518,10 +8523,10 @@ tar@^6.0.5, tar@^6.1.11, tar@^6.1.12, tar@^6.2.1: mkdirp "^1.0.3" yallist "^4.0.0" -tar@^7.4.3: - version "7.5.1" - resolved "https://registry.yarnpkg.com/tar/-/tar-7.5.1.tgz#750a8bd63b7c44c1848e7bf982260a083cf747c9" - integrity sha512-nlGpxf+hv0v7GkWBK2V9spgactGOp0qvfWRxUMjqHyzrt3SgwE48DIv/FhqPHJYLHpgW1opq3nERbz5Anq7n1g== +tar@^7.5.2: + version "7.5.2" + resolved "https://registry.yarnpkg.com/tar/-/tar-7.5.2.tgz#115c061495ec51ff3c6745ff8f6d0871c5b1dedc" + integrity sha512-7NyxrTE4Anh8km8iEy7o0QYPs+0JKBTj5ZaqHg6B39erLg0qYXN3BijtShwbsNSvQ+LN75+KV+C4QR/f6Gwnpg== dependencies: "@isaacs/fs-minipass" "^4.0.0" chownr "^3.0.0"