Compare commits

...

68 Commits

Author SHA1 Message Date
Chubby Granny Chaser
47bfc1648f Merge branch 'main' into fix/fixing-level-events 2025-12-10 17:34:01 +00:00
Chubby Granny Chaser
a69a6ec510 Merge pull request #1889 from Lianela/main
feat: new strings
2025-12-10 17:15:45 +00:00
Chubby Granny Chaser
fada6507c3 Merge branch 'main' into main 2025-12-10 17:15:21 +00:00
Chubby Granny Chaser
0479f1347b Merge pull request #1887 from hydralauncher/dependabot/npm_and_yarn/npm_and_yarn-a3f223628e
chore(deps): bump jws from 3.2.2 to 3.2.3 in the npm_and_yarn group across 1 directory
2025-12-10 17:14:44 +00:00
dependabot[bot]
f44d5c8b49 chore(deps): bump jws in the npm_and_yarn group across 1 directory
Bumps the npm_and_yarn group with 1 update in the / directory: [jws](https://github.com/brianloveswords/node-jws).


Updates `jws` from 3.2.2 to 3.2.3
- [Release notes](https://github.com/brianloveswords/node-jws/releases)
- [Changelog](https://github.com/auth0/node-jws/blob/master/CHANGELOG.md)
- [Commits](https://github.com/brianloveswords/node-jws/compare/v3.2.2...v3.2.3)

---
updated-dependencies:
- dependency-name: jws
  dependency-version: 3.2.3
  dependency-type: indirect
  dependency-group: npm_and_yarn
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-12-08 01:04:55 +00:00
Zamitto
c36109c092 chore: bump version
Some checks failed
Build / build (ubuntu-latest) (push) Has been cancelled
Build / build (windows-2022) (push) Has been cancelled
2025-12-07 22:03:02 -03:00
Zamitto
b59fb7dc36 feat: support workwonders 2025-12-07 20:38:53 -03:00
Kyatto
214a7af408 Fix JSON formatting in translation file 2025-12-07 13:14:50 -06:00
Kyatto
14679fc31e Add new translation strings in Spanish 2025-12-07 13:05:59 -06:00
Chubby Granny Chaser
b5445b3dfa chore: updating lock 2025-12-01 06:51:08 +00:00
Chubby Granny Chaser
1ccf70af12 chore: updating lock 2025-11-30 23:57:13 +00:00
Chubby Granny Chaser
bb45b95820 chore: updating lock 2025-11-30 23:39:47 +00:00
Chubby Granny Chaser
361c158a44 fix: fixing level events 2025-11-30 23:17:56 +00:00
Chubby Granny Chaser
1f5e84b32c fix: fixing level events 2025-11-30 23:17:09 +00:00
Chubby Granny Chaser
e49d885b30 chore: update package.json to use yarn commands for type checking and building
Some checks failed
Build Renderer / build (push) Has been cancelled
Release / build (ubuntu-latest) (push) Has been cancelled
Release / build (windows-2022) (push) Has been cancelled
2025-11-30 15:21:50 +00:00
Chubby Granny Chaser
cb01301a0d feat: add new translation keys for network statistics in multiple languages 2025-11-30 15:07:32 +00:00
Chubby Granny Chaser
e872b2ea8a chore: bump version to 3.7.5
Some checks failed
Build / build (ubuntu-latest) (push) Has been cancelled
Build / build (windows-2022) (push) Has been cancelled
2025-11-30 06:26:43 +00:00
Chubby Granny Chaser
dd7c84b433 Merge pull request #1881 from hydralauncher/fix/downloads-ui
fix: auto-resuming download isnt working after restart
2025-11-30 06:26:08 +00:00
Chubby Granny Chaser
1546da29cf Merge branch 'fix/downloads-ui' of https://github.com/hydralauncher/hydra into fix/downloads-ui 2025-11-30 06:25:39 +00:00
Chubby Granny Chaser
a89b0bb2a8 style: refactor download group component to optimize download state management and improve UI responsiveness 2025-11-30 06:25:17 +00:00
Moyasee
9bdb216e0f fix: deleted comment 2025-11-30 08:23:49 +02:00
Moyasee
9779aed8c1 fix: auto-resuming download isnt working after restart 2025-11-30 08:05:45 +02:00
Chubby Granny Chaser
058a148c7f style: add button styling and refactor logo click handling in download group for improved accessibility and user experience 2025-11-30 05:44:18 +00:00
Chubby Granny Chaser
16e3d52508 style: enhance download group styling for improved layout, responsiveness, and user interaction 2025-11-30 05:39:01 +00:00
Chubby Granny Chaser
7e0002cf95 style: format imports in download-group.tsx for improved readability 2025-11-30 05:14:48 +00:00
Chubby Granny Chaser
bf8b3ca836 style: update download group layout and styling for improved responsiveness 2025-11-30 05:14:26 +00:00
Moyasee
77e376e742 fix: peak spead not working 2025-11-30 07:13:12 +02:00
Chubby Granny Chaser
bd28b202c4 Merge branch 'fix/downloads-ui' of https://github.com/hydralauncher/hydra 2025-11-30 05:06:59 +00:00
Moyasee
153b954e78 fix: progress bar, context menu, repacks modal, responsiveness and styling fix 2025-11-30 07:05:19 +02:00
Chubby Granny Chaser
a9e63730be Merge pull request #1880 from hydralauncher/fix/fixing-hls-videos
Some checks failed
Build / build (ubuntu-latest) (push) Has been cancelled
Build / build (windows-2022) (push) Has been cancelled
Fix/fixing hls videos
2025-11-30 03:45:10 +00:00
Chubby Granny Chaser
316480930d Merge branch 'main' into fix/fixing-hls-videos 2025-11-30 03:45:00 +00:00
Chubby Granny Chaser
0b5c9acaaa Merge pull request #1861 from iam-sahil/Downloads-UI
feat: enhance download page UI with improved layout and styling for the download cards.
2025-11-30 03:44:33 +00:00
Chubby Granny Chaser
814a2da05c Merge branch 'main' into Downloads-UI 2025-11-30 03:44:10 +00:00
Chubby Granny Chaser
0ad1ebd6a2 fix: fixing hls videos 2025-11-30 03:43:22 +00:00
Chubby Granny Chaser
e9de8264e2 fix: fixing hls videos 2025-11-30 03:41:41 +00:00
Chubby Granny Chaser
b135087ffe fix: fixing hls videos 2025-11-30 03:38:23 +00:00
Chubby Granny Chaser
b4a1af78a6 Merge pull request #1877 from egionCode/main
adding sorting for recently played based on last time the game was op…
2025-11-30 03:21:08 +00:00
Chubby Granny Chaser
ede5bb0c23 Merge branch 'main' into main 2025-11-30 03:20:03 +00:00
Chubby Granny Chaser
9a27875cd8 Merge pull request #1866 from hydralauncher/feat/search-autosuggest
Feat: search history and auto-suggest
2025-11-30 03:19:57 +00:00
Chubby Granny Chaser
cf20a942ae Merge branch 'main' into main 2025-11-30 03:17:07 +00:00
Chubby Granny Chaser
256d829a60 feat: adding translations 2025-11-30 03:15:27 +00:00
Chubby Granny Chaser
8cb18578e0 Merge branch 'main' into feat/search-autosuggest 2025-11-30 03:06:00 +00:00
Chubby Granny Chaser
62950297e0 Merge pull request #1874 from hydralauncher/dependabot/npm_and_yarn/npm_and_yarn-2e94d63b2a
chore(deps): bump tar from 7.5.1 to 7.5.2 in the npm_and_yarn group across 1 directory
2025-11-30 03:05:46 +00:00
Chubby Granny Chaser
3eecc42430 Merge branch 'main' into dependabot/npm_and_yarn/npm_and_yarn-2e94d63b2a 2025-11-30 03:04:37 +00:00
Chubby Granny Chaser
f6edb45628 Merge pull request #1875 from hydralauncher/dependabot/npm_and_yarn/npm_and_yarn-3c67cbb9cd
chore(deps): bump js-yaml from 4.1.0 to 4.1.1 in the npm_and_yarn group across 1 directory
2025-11-30 03:04:30 +00:00
Chubby Granny Chaser
de8797bea6 Merge branch 'main' into dependabot/npm_and_yarn/npm_and_yarn-3c67cbb9cd 2025-11-30 03:04:23 +00:00
Chubby Granny Chaser
828f82f647 Merge pull request #1879 from hydralauncher/feat/adding-level-generic-interface
Feat/adding level generic interface
2025-11-30 03:04:12 +00:00
Moyasee
bb22d9c4dd ci: migration of search history from localStorage to LevelDB and highlighting fix 2025-11-29 05:30:10 +02:00
Moyasee
559bb45acc Merge branch 'feat/adding-level-generic-interface' of https://github.com/hydralauncher/hydra into feat/search-autosuggest 2025-11-29 05:10:54 +02:00
Chubby Granny Chaser
8e3a932aa4 fix: fixing code quality issues 2025-11-29 02:40:52 +00:00
Chubby Granny Chaser
1fc87f93b7 fix: fixing code quality issues 2025-11-29 02:39:21 +00:00
Chubby Granny Chaser
f28c867479 feat: adding level generic interface 2025-11-29 02:25:29 +00:00
Chubby Granny Chaser
928acc2765 feat: adding level generic interface 2025-11-29 02:22:07 +00:00
Chubby Granny Chaser
140718764d feat: adding level generic interface 2025-11-29 02:19:41 +00:00
Chubby Granny Chaser
f41128c4c8 feat: adding level generic interface 2025-11-29 02:19:21 +00:00
Victor
e176e624be adding sorting for recently played based on last time the game was opened 2025-11-27 11:23:50 -03:00
Chubby Granny Chaser
59b3fb5317 Merge branch 'release/v3.7.4' of https://github.com/hydralauncher/hydra 2025-11-26 15:38:23 +00:00
dependabot[bot]
4e92e794be chore(deps): bump js-yaml in the npm_and_yarn group across 1 directory
Bumps the npm_and_yarn group with 1 update in the / directory: [js-yaml](https://github.com/nodeca/js-yaml).


Updates `js-yaml` from 4.1.0 to 4.1.1
- [Changelog](https://github.com/nodeca/js-yaml/blob/master/CHANGELOG.md)
- [Commits](https://github.com/nodeca/js-yaml/compare/4.1.0...4.1.1)

---
updated-dependencies:
- dependency-name: js-yaml
  dependency-version: 4.1.1
  dependency-type: indirect
  dependency-group: npm_and_yarn
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-11-25 04:55:31 +00:00
dependabot[bot]
de0dbcac35 chore(deps): bump tar in the npm_and_yarn group across 1 directory
Bumps the npm_and_yarn group with 1 update in the / directory: [tar](https://github.com/isaacs/node-tar).


Updates `tar` from 7.5.1 to 7.5.2
- [Release notes](https://github.com/isaacs/node-tar/releases)
- [Changelog](https://github.com/isaacs/node-tar/blob/main/CHANGELOG.md)
- [Commits](https://github.com/isaacs/node-tar/compare/v7.5.1...v7.5.2)

---
updated-dependencies:
- dependency-name: tar
  dependency-version: 7.5.2
  dependency-type: direct:production
  dependency-group: npm_and_yarn
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-11-25 04:55:16 +00:00
Zamitto
5e86ad4d7e Merge pull request #1872 from epcgrs/main
Some checks failed
Build / build (ubuntu-latest) (push) Has been cancelled
Build / build (windows-2022) (push) Has been cancelled
fix: Update icon image broken in README.md
2025-11-23 13:30:55 -03:00
Machine Zero
e2fb59ed8d fix: Update icon image broken in README.md
Fix the image with broken link in readme.md
2025-11-22 19:34:55 -03:00
Moyasee
07d5a5b3f3 Merge branch 'feat/search-autosuggest' of https://github.com/hydralauncher/hydra into feat/search-autosuggest 2025-11-22 07:31:15 +02:00
Moyasee
a1117c8269 feat: improving suggestion dropdown design 2025-11-22 07:26:48 +02:00
ctrlcat0x
5bffaf17fa fix: adjust padding for completed downloads and improve conditional rendering in download actions 2025-11-15 13:47:50 +05:30
ctrlcat0x
cc38be4383 Fixed linter and sonarcloud errors, refactored some functions and fixed UI padding issues with certain themes. 2025-11-15 11:31:39 +05:30
ctrlcat0x
0b70a28c08 feat: enhance download group UI with speed chart improvements and gradient progress bar 2025-11-15 01:16:23 +05:30
ctrlcat0x
3ff50a9932 feat: update download group UI with hero section and speed chart integration 2025-11-15 00:44:54 +05:30
ctrlcat0x
83fbf20383 feat: enhance download page UI with improved layout and styling for cards 2025-11-14 20:02:10 +05:30
97 changed files with 2498 additions and 1016 deletions

View File

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

View File

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

View File

@@ -1,6 +1,6 @@
{ {
"name": "hydralauncher", "name": "hydralauncher",
"version": "3.7.4", "version": "3.7.6",
"description": "Hydra", "description": "Hydra",
"main": "./out/main/index.js", "main": "./out/main/index.js",
"author": "Los Broxas", "author": "Los Broxas",
@@ -19,12 +19,12 @@
"lint": "eslint . --ext .js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix", "lint": "eslint . --ext .js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix",
"typecheck:node": "tsc --noEmit -p tsconfig.node.json --composite false", "typecheck:node": "tsc --noEmit -p tsconfig.node.json --composite false",
"typecheck:web": "tsc --noEmit -p tsconfig.web.json --composite false", "typecheck:web": "tsc --noEmit -p tsconfig.web.json --composite false",
"typecheck": "npm run typecheck:node && npm run typecheck:web", "typecheck": "yarn run typecheck:node && yarn run typecheck:web",
"start": "electron-vite preview", "start": "electron-vite preview",
"dev": "electron-vite dev", "dev": "electron-vite dev",
"build": "npm run typecheck && electron-vite build", "build": "yarn run typecheck && electron-vite build",
"postinstall": "electron-builder install-app-deps && node ./scripts/postinstall.cjs", "postinstall": "electron-builder install-app-deps && node ./scripts/postinstall.cjs",
"build:unpack": "npm run build && electron-builder --dir", "build:unpack": "yarn run build && electron-builder --dir",
"build:win": "electron-vite build && electron-builder --win", "build:win": "electron-vite build && electron-builder --win",
"build:mac": "electron-vite build && electron-builder --mac", "build:mac": "electron-vite build && electron-builder --mac",
"build:linux": "electron-vite build && electron-builder --linux", "build:linux": "electron-vite build && electron-builder --linux",
@@ -63,6 +63,7 @@
"embla-carousel-react": "^8.6.0", "embla-carousel-react": "^8.6.0",
"file-type": "^20.5.0", "file-type": "^20.5.0",
"framer-motion": "^12.15.0", "framer-motion": "^12.15.0",
"hls.js": "^1.5.12",
"i18next": "^23.11.2", "i18next": "^23.11.2",
"i18next-browser-languagedetector": "^7.2.1", "i18next-browser-languagedetector": "^7.2.1",
"jsdom": "^24.0.0", "jsdom": "^24.0.0",
@@ -84,7 +85,7 @@
"sound-play": "^1.1.0", "sound-play": "^1.1.0",
"steam-shortcut-editor": "https://github.com/hydralauncher/steam-shortcut-editor", "steam-shortcut-editor": "https://github.com/hydralauncher/steam-shortcut-editor",
"sudo-prompt": "^9.2.1", "sudo-prompt": "^9.2.1",
"tar": "^7.4.3", "tar": "^7.5.2",
"tough-cookie": "^5.1.1", "tough-cookie": "^5.1.1",
"user-agents": "^1.1.387", "user-agents": "^1.1.387",
"uuid": "^13.0.0", "uuid": "^13.0.0",

View File

@@ -96,7 +96,7 @@
"search_library": "Search library", "search_library": "Search library",
"recent_searches": "Recent Searches", "recent_searches": "Recent Searches",
"suggestions": "Suggestions", "suggestions": "Suggestions",
"clear_history": "Clear history", "clear_history": "Clear",
"remove_from_history": "Remove from history", "remove_from_history": "Remove from history",
"loading": "Loading...", "loading": "Loading...",
"no_results": "No results", "no_results": "No results",
@@ -414,7 +414,11 @@
"resume_seeding": "Resume seeding", "resume_seeding": "Resume seeding",
"options": "Manage", "options": "Manage",
"extract": "Extract files", "extract": "Extract files",
"extracting": "Extracting files…" "extracting": "Extracting files…",
"network": "Network",
"peak": "Peak",
"seeds": "Seeds",
"peers": "Peers"
}, },
"settings": { "settings": {
"downloads_path": "Downloads path", "downloads_path": "Downloads path",

View File

@@ -93,8 +93,16 @@
}, },
"header": { "header": {
"search": "Buscar juegos", "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", "home": "Inicio",
"catalogue": "Catálogo", "catalogue": "Catálogo",
"library": "Librería",
"downloads": "Descargas", "downloads": "Descargas",
"search_results": "Resultados de búsqueda", "search_results": "Resultados de búsqueda",
"settings": "Ajustes", "settings": "Ajustes",
@@ -406,7 +414,11 @@
"resume_seeding": "Continuar sembrando", "resume_seeding": "Continuar sembrando",
"options": "Administrar", "options": "Administrar",
"extract": "Extraer archivos", "extract": "Extraer archivos",
"extracting": "Extrayendo archivos…" "extracting": "Extrayendo archivos…",
"network": "Red",
"peak": "Pico",
"seeds": "Seeds",
"peers": "Peers"
}, },
"settings": { "settings": {
"downloads_path": "Ruta de descarga", "downloads_path": "Ruta de descarga",
@@ -450,6 +462,7 @@
"description_confirmation_delete_all_sources": "Vas a eliminar todas las fuentes de descargas", "description_confirmation_delete_all_sources": "Vas a eliminar todas las fuentes de descargas",
"button_delete_all_sources": "Eliminar todo", "button_delete_all_sources": "Eliminar todo",
"added_download_source": "Añadir fuente de descarga", "added_download_source": "Añadir fuente de descarga",
"adding": "Añadiendo…",
"download_sources_synced": "Todas las fuentes de descarga están sincronizadas", "download_sources_synced": "Todas las fuentes de descarga están sincronizadas",
"insert_valid_json_url": "Introducí una URL de json válida", "insert_valid_json_url": "Introducí una URL de json válida",
"found_download_option_zero": "Sin opciones de descargas encontrada", "found_download_option_zero": "Sin opciones de descargas encontrada",
@@ -555,6 +568,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.", "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", "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", "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" "hide_to_tray_on_game_start": "Ocultar Hydra en la bandeja al iniciar un juego"
}, },
"notifications": { "notifications": {

View File

@@ -93,11 +93,19 @@
}, },
"header": { "header": {
"search": "Buscar jogos", "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", "catalogue": "Catálogo",
"library": "Biblioteca",
"downloads": "Downloads", "downloads": "Downloads",
"search_results": "Resultados da busca", "search_results": "Resultados da busca",
"settings": "Ajustes", "settings": "Ajustes",
"home": "Início",
"version_available_install": "Versão {{version}} disponível. Clique aqui para reiniciar e instalar.", "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." "version_available_download": "Versão {{version}} disponível. Clique aqui para fazer o download."
}, },
@@ -394,7 +402,11 @@
"resume_seeding": "Semear", "resume_seeding": "Semear",
"options": "Gerenciar", "options": "Gerenciar",
"extract": "Extrair arquivos", "extract": "Extrair arquivos",
"extracting": "Extraindo arquivos…" "extracting": "Extraindo arquivos…",
"network": "Rede",
"peak": "Pico",
"seeds": "Seeds",
"peers": "Peers"
}, },
"settings": { "settings": {
"downloads_path": "Diretório dos downloads", "downloads_path": "Diretório dos downloads",

View File

@@ -30,11 +30,19 @@
}, },
"header": { "header": {
"search": "Procurar jogos", "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", "catalogue": "Catálogo",
"library": "Biblioteca",
"downloads": "Transferências", "downloads": "Transferências",
"search_results": "Resultados da pesquisa", "search_results": "Resultados da pesquisa",
"settings": "Definições", "settings": "Definições",
"home": "Início",
"version_available_install": "Versão {{version}} disponível. Clica aqui para reiniciar e instalar.", "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." "version_available_download": "Versão {{version}} disponível. Clica aqui para fazer o download."
}, },
@@ -221,7 +229,13 @@
"seeding": "A semear", "seeding": "A semear",
"stop_seeding": "Parar de semear", "stop_seeding": "Parar de semear",
"resume_seeding": "Semear", "resume_seeding": "Semear",
"options": "Opções" "options": "Opções",
"extract": "Extrair ficheiros",
"extracting": "A extrair ficheiros…",
"network": "Rede",
"peak": "Pico",
"seeds": "Seeds",
"peers": "Peers"
}, },
"settings": { "settings": {
"downloads_path": "Local das transferências", "downloads_path": "Local das transferências",

View File

@@ -93,8 +93,16 @@
}, },
"header": { "header": {
"search": "Поиск", "search": "Поиск",
"search_library": "Поиск в библиотеке",
"recent_searches": "Недавние поиски",
"suggestions": "Предложения",
"clear_history": "Очистить",
"remove_from_history": "Удалить из истории",
"loading": "Загрузка...",
"no_results": "Нет результатов",
"home": "Главная", "home": "Главная",
"catalogue": "Каталог", "catalogue": "Каталог",
"library": "Библиотека",
"downloads": "Загрузки", "downloads": "Загрузки",
"search_results": "Результаты поиска", "search_results": "Результаты поиска",
"settings": "Настройки", "settings": "Настройки",
@@ -406,7 +414,11 @@
"resume_seeding": "Продолжить раздачу", "resume_seeding": "Продолжить раздачу",
"options": "Управлять", "options": "Управлять",
"extract": "Распаковать файлы", "extract": "Распаковать файлы",
"extracting": "Распаковка файлов…" "extracting": "Распаковка файлов…",
"network": "Сеть",
"peak": "Пик",
"seeds": "Seeds",
"peers": "Peers"
}, },
"settings": { "settings": {
"downloads_path": "Путь загрузок", "downloads_path": "Путь загрузок",

View File

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

View File

@@ -0,0 +1,2 @@
import "./open-auth-window";
import "./sign-out";

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,3 @@
import "./add-download-source";
import "./remove-download-source";
import "./sync-download-sources";

View File

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

View File

@@ -1,107 +1,22 @@
import { appVersion, defaultDownloadsPath, isStaging } from "@main/constants"; import { appVersion, defaultDownloadsPath, isStaging } from "@main/constants";
import { ipcMain } from "electron"; import { ipcMain } from "electron";
import "./catalogue/get-game-shop-details"; import "./auth";
import "./catalogue/get-random-game"; import "./autoupdater";
import "./catalogue/get-game-stats"; import "./catalogue";
import "./hardware/get-disk-free-space"; import "./cloud-save";
import "./hardware/check-folder-write-permission"; import "./download-sources";
import "./library/add-game-to-library"; import "./hardware";
import "./library/add-custom-game-to-library"; import "./library";
import "./library/update-custom-game"; import "./leveldb";
import "./library/update-game-custom-assets"; import "./misc";
import "./library/add-game-to-favorites"; import "./notifications";
import "./library/remove-game-from-favorites"; import "./profile";
import "./library/toggle-game-pin"; import "./themes";
import "./library/create-game-shortcut"; import "./torrenting";
import "./library/close-game"; import "./user";
import "./library/delete-game-folder"; import "./user-preferences";
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 { isPortableVersion } from "@main/helpers"; import { isPortableVersion } from "@main/helpers";
ipcMain.handle("ping", () => "pong"); ipcMain.handle("ping", () => "pong");

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,29 @@
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 "./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 "./open-game-executable-path";
import "./open-game-installer-path";
import "./open-game-installer";
import "./open-game";
import "./refresh-library-assets";
import "./remove-game-from-favorites";
import "./remove-game-from-library";
import "./remove-game";
import "./reset-game-achievements";
import "./select-game-wine-prefix";
import "./toggle-automatic-cloud-sync";
import "./toggle-game-pin";
import "./update-custom-game";
import "./update-executable-path";
import "./update-game-custom-assets";
import "./update-launch-options";
import "./verify-executable-path";

View File

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

View File

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

View File

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

View File

@@ -1,12 +0,0 @@
import { Theme } from "@types";
import { registerEvent } from "../register-event";
import { themesSublevel } from "@main/level";
const addCustomTheme = async (
_event: Electron.IpcMainInvokeEvent,
theme: Theme
) => {
await themesSublevel.put(theme.id, theme);
};
registerEvent("addCustomTheme", addCustomTheme);

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,9 @@
import "./close-editor-window";
import "./copy-theme-achievement-sound";
import "./get-theme-sound-data-url";
import "./get-theme-sound-path";
import "./import-theme-sound-from-store";
import "./open-editor-window";
import "./remove-theme-achievement-sound";
import "./toggle-custom-theme";
import "./update-custom-theme";

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -7,7 +7,9 @@ export const getDownloadSourcesCheckBaseline = async (): Promise<
string | null string | null
> => { > => {
try { try {
const timestamp = await db.get(levelKeys.downloadSourcesCheckBaseline); const timestamp = await db.get(levelKeys.downloadSourcesCheckBaseline, {
valueEncoding: "utf8",
});
return timestamp; return timestamp;
} catch (error) { } catch (error) {
if (error instanceof Error && error.name === "NotFoundError") { if (error instanceof Error && error.name === "NotFoundError") {
@@ -27,7 +29,9 @@ export const updateDownloadSourcesCheckBaseline = async (
timestamp: string timestamp: string
): Promise<void> => { ): Promise<void> => {
const utcTimestamp = new Date(timestamp).toISOString(); 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) // 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 string | null
> => { > => {
try { try {
const timestamp = await db.get(levelKeys.downloadSourcesSinceValue); const timestamp = await db.get(levelKeys.downloadSourcesSinceValue, {
valueEncoding: "utf8",
});
return timestamp; return timestamp;
} catch (error) { } catch (error) {
if (error instanceof Error && error.name === "NotFoundError") { if (error instanceof Error && error.name === "NotFoundError") {
@@ -55,5 +61,7 @@ export const updateDownloadSourcesSinceValue = async (
timestamp: string timestamp: string
): Promise<void> => { ): Promise<void> => {
const utcTimestamp = new Date(timestamp).toISOString(); const utcTimestamp = new Date(timestamp).toISOString();
await db.put(levelKeys.downloadSourcesSinceValue, utcTimestamp); await db.put(levelKeys.downloadSourcesSinceValue, utcTimestamp, {
valueEncoding: "utf8",
});
}; };

View File

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

View File

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

View File

@@ -58,7 +58,13 @@ export class HydraApi {
const decodedBase64 = atob(payload as string); const decodedBase64 = atob(payload as string);
const jsonData = JSON.parse(decodedBase64); const jsonData = JSON.parse(decodedBase64);
const { accessToken, expiresIn, refreshToken } = jsonData; const {
accessToken,
expiresIn,
refreshToken,
featurebaseJwt,
workwondersJwt,
} = jsonData;
const now = new Date(); const now = new Date();
@@ -85,6 +91,8 @@ export class HydraApi {
accessToken, accessToken,
refreshToken, refreshToken,
tokenExpirationTimestamp, tokenExpirationTimestamp,
featurebaseJwt,
workwondersJwt,
}, },
{ valueEncoding: "json" } { valueEncoding: "json" }
); );

View File

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

View File

@@ -138,7 +138,8 @@ export class WindowManager {
(details, callback) => { (details, callback) => {
if ( if (
details.webContentsId !== this.mainWindow?.webContents.id || details.webContentsId !== this.mainWindow?.webContents.id ||
details.url.includes("chatwoot") details.url.includes("chatwoot") ||
details.url.includes("workwonders")
) { ) {
return callback(details); return callback(details);
} }
@@ -159,7 +160,8 @@ export class WindowManager {
if ( if (
details.webContentsId !== this.mainWindow?.webContents.id || details.webContentsId !== this.mainWindow?.webContents.id ||
details.url.includes("featurebase") || details.url.includes("featurebase") ||
details.url.includes("chatwoot") details.url.includes("chatwoot") ||
details.url.includes("workwonders")
) { ) {
return callback(details); return callback(details);
} }

View File

@@ -13,7 +13,6 @@ import type {
UpdateProfileRequest, UpdateProfileRequest,
SeedingStatus, SeedingStatus,
GameAchievement, GameAchievement,
Theme,
FriendRequestSync, FriendRequestSync,
ShortcutLocation, ShortcutLocation,
AchievementCustomNotificationPosition, AchievementCustomNotificationPosition,
@@ -86,7 +85,8 @@ contextBridge.exposeInMainWorld("electron", {
}, },
/* User preferences */ /* User preferences */
getUserPreferences: () => ipcRenderer.invoke("getUserPreferences"), getUserPreferences: () =>
ipcRenderer.invoke("leveldbGet", "userPreferences", null, "json"),
updateUserPreferences: (preferences: UserPreferences) => updateUserPreferences: (preferences: UserPreferences) =>
ipcRenderer.invoke("updateUserPreferences", preferences), ipcRenderer.invoke("updateUserPreferences", preferences),
autoLaunch: (autoLaunchProps: { enabled: boolean; minimized: boolean }) => autoLaunch: (autoLaunchProps: { enabled: boolean; minimized: boolean }) =>
@@ -101,12 +101,7 @@ contextBridge.exposeInMainWorld("electron", {
ipcRenderer.invoke("addDownloadSource", url), ipcRenderer.invoke("addDownloadSource", url),
removeDownloadSource: (url: string, removeAll?: boolean) => removeDownloadSource: (url: string, removeAll?: boolean) =>
ipcRenderer.invoke("removeDownloadSource", url, removeAll), ipcRenderer.invoke("removeDownloadSource", url, removeAll),
getDownloadSources: () => ipcRenderer.invoke("getDownloadSources"),
syncDownloadSources: () => ipcRenderer.invoke("syncDownloadSources"), syncDownloadSources: () => ipcRenderer.invoke("syncDownloadSources"),
getDownloadSourcesCheckBaseline: () =>
ipcRenderer.invoke("getDownloadSourcesCheckBaseline"),
getDownloadSourcesSinceValue: () =>
ipcRenderer.invoke("getDownloadSourcesSinceValue"),
/* Library */ /* Library */
toggleAutomaticCloudSync: ( toggleAutomaticCloudSync: (
@@ -183,8 +178,6 @@ contextBridge.exposeInMainWorld("electron", {
ipcRenderer.invoke("addGameToFavorites", shop, objectId), ipcRenderer.invoke("addGameToFavorites", shop, objectId),
removeGameFromFavorites: (shop: GameShop, objectId: string) => removeGameFromFavorites: (shop: GameShop, objectId: string) =>
ipcRenderer.invoke("removeGameFromFavorites", shop, objectId), ipcRenderer.invoke("removeGameFromFavorites", shop, objectId),
clearNewDownloadOptions: (shop: GameShop, objectId: string) =>
ipcRenderer.invoke("clearNewDownloadOptions", shop, objectId),
toggleGamePin: (shop: GameShop, objectId: string, pinned: boolean) => toggleGamePin: (shop: GameShop, objectId: string, pinned: boolean) =>
ipcRenderer.invoke("toggleGamePin", shop, objectId, pinned), ipcRenderer.invoke("toggleGamePin", shop, objectId, pinned),
updateLaunchOptions: ( updateLaunchOptions: (
@@ -201,7 +194,6 @@ contextBridge.exposeInMainWorld("electron", {
ipcRenderer.invoke("selectGameWinePrefix", shop, objectId, winePrefixPath), ipcRenderer.invoke("selectGameWinePrefix", shop, objectId, winePrefixPath),
verifyExecutablePathInUse: (executablePath: string) => verifyExecutablePathInUse: (executablePath: string) =>
ipcRenderer.invoke("verifyExecutablePathInUse", executablePath), ipcRenderer.invoke("verifyExecutablePathInUse", executablePath),
getLibrary: () => ipcRenderer.invoke("getLibrary"),
refreshLibraryAssets: () => ipcRenderer.invoke("refreshLibraryAssets"), refreshLibraryAssets: () => ipcRenderer.invoke("refreshLibraryAssets"),
openGameInstaller: (shop: GameShop, objectId: string) => openGameInstaller: (shop: GameShop, objectId: string) =>
ipcRenderer.invoke("openGameInstaller", shop, objectId), ipcRenderer.invoke("openGameInstaller", shop, objectId),
@@ -230,8 +222,6 @@ contextBridge.exposeInMainWorld("electron", {
ipcRenderer.invoke("removeGame", shop, objectId), ipcRenderer.invoke("removeGame", shop, objectId),
deleteGameFolder: (shop: GameShop, objectId: string) => deleteGameFolder: (shop: GameShop, objectId: string) =>
ipcRenderer.invoke("deleteGameFolder", shop, objectId), ipcRenderer.invoke("deleteGameFolder", shop, objectId),
getGameByObjectId: (shop: GameShop, objectId: string) =>
ipcRenderer.invoke("getGameByObjectId", shop, objectId),
resetGameAchievements: (shop: GameShop, objectId: string) => resetGameAchievements: (shop: GameShop, objectId: string) =>
ipcRenderer.invoke("resetGameAchievements", shop, objectId), ipcRenderer.invoke("resetGameAchievements", shop, objectId),
changeGamePlayTime: (shop: GameShop, objectId: string, playtime: number) => changeGamePlayTime: (shop: GameShop, objectId: string, playtime: number) =>
@@ -287,8 +277,6 @@ contextBridge.exposeInMainWorld("electron", {
gameArtifactId: string gameArtifactId: string
) => ) =>
ipcRenderer.invoke("downloadGameArtifact", objectId, shop, gameArtifactId), ipcRenderer.invoke("downloadGameArtifact", objectId, shop, gameArtifactId),
getGameArtifacts: (objectId: string, shop: GameShop) =>
ipcRenderer.invoke("getGameArtifacts", objectId, shop),
getGameBackupPreview: (objectId: string, shop: GameShop) => getGameBackupPreview: (objectId: string, shop: GameShop) =>
ipcRenderer.invoke("getGameBackupPreview", objectId, shop), ipcRenderer.invoke("getGameBackupPreview", objectId, shop),
selectGameBackupPath: ( selectGameBackupPath: (
@@ -503,11 +491,10 @@ contextBridge.exposeInMainWorld("electron", {
ipcRenderer.invoke("getUnlockedAchievements", objectId, shop), ipcRenderer.invoke("getUnlockedAchievements", objectId, shop),
/* Auth */ /* Auth */
getAuth: () => ipcRenderer.invoke("getAuth"), getAuth: () => ipcRenderer.invoke("leveldbGet", "auth", null, "json"),
signOut: () => ipcRenderer.invoke("signOut"), signOut: () => ipcRenderer.invoke("signOut"),
openAuthWindow: (page: AuthPage) => openAuthWindow: (page: AuthPage) =>
ipcRenderer.invoke("openAuthWindow", page), ipcRenderer.invoke("openAuthWindow", page),
getSessionHash: () => ipcRenderer.invoke("getSessionHash"),
onSignIn: (cb: () => void) => { onSignIn: (cb: () => void) => {
const listener = (_event: Electron.IpcRendererEvent) => cb(); const listener = (_event: Electron.IpcRendererEvent) => cb();
ipcRenderer.on("on-signin", listener); ipcRenderer.on("on-signin", listener);
@@ -565,16 +552,8 @@ contextBridge.exposeInMainWorld("electron", {
ipcRenderer.invoke("showAchievementTestNotification"), ipcRenderer.invoke("showAchievementTestNotification"),
/* Themes */ /* Themes */
addCustomTheme: (theme: Theme) => ipcRenderer.invoke("addCustomTheme", theme),
getAllCustomThemes: () => ipcRenderer.invoke("getAllCustomThemes"),
deleteAllCustomThemes: () => ipcRenderer.invoke("deleteAllCustomThemes"),
deleteCustomTheme: (themeId: string) =>
ipcRenderer.invoke("deleteCustomTheme", themeId),
updateCustomTheme: (themeId: string, code: string) => updateCustomTheme: (themeId: string, code: string) =>
ipcRenderer.invoke("updateCustomTheme", themeId, code), ipcRenderer.invoke("updateCustomTheme", themeId, code),
getCustomThemeById: (themeId: string) =>
ipcRenderer.invoke("getCustomThemeById", themeId),
getActiveCustomTheme: () => ipcRenderer.invoke("getActiveCustomTheme"),
toggleCustomTheme: (themeId: string, isActive: boolean) => toggleCustomTheme: (themeId: string, isActive: boolean) =>
ipcRenderer.invoke("toggleCustomTheme", themeId, isActive), ipcRenderer.invoke("toggleCustomTheme", themeId, isActive),
copyThemeAchievementSound: (themeId: string, sourcePath: string) => copyThemeAchievementSound: (themeId: string, sourcePath: string) =>
@@ -619,4 +598,28 @@ contextBridge.exposeInMainWorld("electron", {
}, },
closeEditorWindow: (themeId?: string) => closeEditorWindow: (themeId?: string) =>
ipcRenderer.invoke("closeEditorWindow", themeId), ipcRenderer.invoke("closeEditorWindow", themeId),
/* LevelDB Generic CRUD */
leveldb: {
get: (
key: string,
sublevelName?: string | null,
valueEncoding?: "json" | "utf8"
) => ipcRenderer.invoke("leveldbGet", key, sublevelName, valueEncoding),
put: (
key: string,
value: unknown,
sublevelName?: string | null,
valueEncoding?: "json" | "utf8"
) =>
ipcRenderer.invoke("leveldbPut", key, value, sublevelName, valueEncoding),
del: (key: string, sublevelName?: string | null) =>
ipcRenderer.invoke("leveldbDel", key, sublevelName),
clear: (sublevelName: string) =>
ipcRenderer.invoke("leveldbClear", sublevelName),
values: (sublevelName: string) =>
ipcRenderer.invoke("leveldbValues", sublevelName),
iterator: (sublevelName: string) =>
ipcRenderer.invoke("leveldbIterator", sublevelName),
},
}); });

View File

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

View File

@@ -31,6 +31,8 @@ import {
getAchievementSoundUrl, getAchievementSoundUrl,
getAchievementSoundVolume, getAchievementSoundVolume,
} from "./helpers"; } from "./helpers";
import { levelDBService } from "./services/leveldb.service";
import type { UserPreferences } from "@types";
import "./app.scss"; import "./app.scss";
export interface AppProps { export interface AppProps {
@@ -77,11 +79,12 @@ export function App() {
const { showSuccessToast } = useToast(); const { showSuccessToast } = useToast();
useEffect(() => { useEffect(() => {
Promise.all([window.electron.getUserPreferences(), updateLibrary()]).then( Promise.all([
([preferences]) => { levelDBService.get("userPreferences", null, "json"),
dispatch(setUserPreferences(preferences)); updateLibrary(),
} ]).then(([preferences]) => {
); dispatch(setUserPreferences(preferences as UserPreferences | null));
});
}, [navigate, location.pathname, dispatch, updateLibrary]); }, [navigate, location.pathname, dispatch, updateLibrary]);
useEffect(() => { useEffect(() => {
@@ -204,7 +207,11 @@ export function App() {
}, [dispatch, draggingDisabled]); }, [dispatch, draggingDisabled]);
const loadAndApplyTheme = useCallback(async () => { 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) { if (activeTheme?.code) {
injectCustomCss(activeTheme.code); injectCustomCss(activeTheme.code);
} else { } else {

View File

@@ -7,11 +7,13 @@ import {
useToast, useToast,
useUserDetails, useUserDetails,
} from "@renderer/hooks"; } from "@renderer/hooks";
import { levelDBService } from "@renderer/services/leveldb.service";
import "./bottom-panel.scss"; import "./bottom-panel.scss";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { VERSION_CODENAME } from "@renderer/constants"; import { VERSION_CODENAME } from "@renderer/constants";
import type jwt from "jsonwebtoken";
export function BottomPanel() { export function BottomPanel() {
const { t } = useTranslation("bottom_panel"); const { t } = useTranslation("bottom_panel");
@@ -60,7 +62,28 @@ export function BottomPanel() {
}, [t, showSuccessToast]); }, [t, showSuccessToast]);
useEffect(() => { useEffect(() => {
window.electron.getSessionHash().then((result) => setSessionHash(result)); const getSessionHash = async () => {
const auth = (await levelDBService.get("auth", null, "json")) as {
accessToken?: string;
} | null;
if (!auth?.accessToken) {
setSessionHash(null);
return;
}
try {
const jwtModule = await import("jsonwebtoken");
const payload = jwtModule.decode(
auth.accessToken
) as jwt.JwtPayload | null;
setSessionHash(payload?.sessionId ?? null);
} catch {
setSessionHash(null);
}
};
getSessionHash();
}, [userDetails?.id]); }, [userDetails?.id]);
const status = useMemo(() => { const status = useMemo(() => {
@@ -122,10 +145,10 @@ export function BottomPanel() {
</button> </button>
<button <button
data-featurebase-changelog data-open-workwonders-changelog-mini
className="bottom-panel__version-button" className="bottom-panel__version-button"
> >
<small data-featurebase-changelog> <small>
{sessionHash ? `${sessionHash} -` : ""} v{version} &quot; {sessionHash ? `${sessionHash} -` : ""} v{version} &quot;
{VERSION_CODENAME}&quot; {VERSION_CODENAME}&quot;
</small> </small>

View File

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

View File

@@ -15,6 +15,8 @@ import { AutoUpdateSubHeader } from "./auto-update-sub-header";
import { setFilters, setLibrarySearchQuery } from "@renderer/features"; import { setFilters, setLibrarySearchQuery } from "@renderer/features";
import cn from "classnames"; import cn from "classnames";
import { SearchDropdown } from "@renderer/components"; import { SearchDropdown } from "@renderer/components";
import { buildGameDetailsPath } from "@renderer/helpers";
import type { GameShop } from "@types";
const pathTitle: Record<string, string> = { const pathTitle: Record<string, string> = {
"/": "home", "/": "home",
@@ -161,11 +163,11 @@ export function Header() {
const handleSelectSuggestion = (suggestion: { const handleSelectSuggestion = (suggestion: {
title: string; title: string;
objectId: string; objectId: string;
shop: string; shop: GameShop;
}) => { }) => {
setIsDropdownVisible(false); setIsDropdownVisible(false);
inputRef.current?.blur(); inputRef.current?.blur();
navigate(`/game/${suggestion.shop}/${suggestion.objectId}`); navigate(buildGameDetailsPath(suggestion));
}; };
const handleClearSearch = () => { const handleClearSearch = () => {

View File

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

View File

@@ -24,7 +24,8 @@
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
padding: 8px 12px 4px; padding: 8px 12px 8px;
margin-bottom: 4px;
} }
&__section-title { &__section-title {
@@ -35,19 +36,19 @@
letter-spacing: 0.5px; letter-spacing: 0.5px;
} }
&__clear-button { &__clear-text-button {
color: globals.$muted-color; color: globals.$muted-color;
cursor: pointer; cursor: pointer;
padding: 4px; padding: 0;
border-radius: 4px; font-size: 11px;
transition: all ease 0.2s; font-weight: bold;
display: flex; text-transform: uppercase;
align-items: center; transition: color ease 0.2s;
justify-content: center; background: transparent;
border: none;
&:hover { &:hover {
color: #dadbe1; color: #ffffff;
background-color: rgba(255, 255, 255, 0.1);
} }
} }
@@ -74,17 +75,16 @@
transform: translateY(-50%); transform: translateY(-50%);
color: globals.$muted-color; color: globals.$muted-color;
padding: 4px; padding: 4px;
border-radius: 4px;
opacity: 0; opacity: 0;
transition: all ease 0.15s; transition: opacity ease 0.15s;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
background-color: transparent; background-color: transparent;
&:hover { &:hover {
color: #ff5555; color: #ff3333;
background-color: rgba(255, 85, 85, 0.1); background-color: rgba(255, 85, 85, 0.2);
} }
} }
@@ -144,8 +144,8 @@
} }
&__highlight { &__highlight {
background-color: rgba(255, 193, 7, 0.3); background-color: rgba(255, 193, 7, 0.4);
color: #ffc107; color: #ffa000;
font-weight: 600; font-weight: 600;
padding: 0 2px; padding: 0 2px;
border-radius: 2px; border-radius: 2px;

View File

@@ -1,11 +1,6 @@
import { useEffect, useRef, useCallback, useState } from "react"; import { useEffect, useRef, useCallback, useState } from "react";
import { createPortal } from "react-dom"; import { createPortal } from "react-dom";
import { import { ClockIcon, SearchIcon, XIcon } from "@primer/octicons-react";
ClockIcon,
SearchIcon,
TrashIcon,
XIcon,
} from "@primer/octicons-react";
import cn from "classnames"; import cn from "classnames";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import type { SearchHistoryEntry } from "@renderer/hooks/use-search-history"; import type { SearchHistoryEntry } from "@renderer/hooks/use-search-history";
@@ -144,11 +139,10 @@ export function SearchDropdown({
</span> </span>
<button <button
type="button" type="button"
className="search-dropdown__clear-button" className="search-dropdown__clear-text-button"
onClick={onClearHistory} onClick={onClearHistory}
title={t("clear_history")}
> >
<TrashIcon size={14} /> {t("clear_history")}
</button> </button>
</div> </div>
<ul className="search-dropdown__list"> <ul className="search-dropdown__list">

View File

@@ -1,6 +1,8 @@
import { createContext, useCallback, useEffect, useRef, useState } from "react"; import { createContext, useCallback, useEffect, useRef, useState } from "react";
import { setHeaderTitle } from "@renderer/features"; import { setHeaderTitle } from "@renderer/features";
import { levelDBService } from "@renderer/services/leveldb.service";
import { orderBy } from "lodash-es";
import { getSteamLanguage } from "@renderer/helpers"; import { getSteamLanguage } from "@renderer/helpers";
import { import {
useAppDispatch, useAppDispatch,
@@ -10,6 +12,8 @@ import {
} from "@renderer/hooks"; } from "@renderer/hooks";
import type { import type {
Download,
DownloadSource,
GameRepack, GameRepack,
GameShop, GameShop,
GameStats, GameStats,
@@ -92,9 +96,19 @@ export function GameDetailsContextProvider({
); );
const updateGame = useCallback(async () => { const updateGame = useCallback(async () => {
return window.electron const gameKey = `${shop}:${objectId}`;
.getGameByObjectId(shop, objectId) const [game, download] = await Promise.all([
.then((result) => setGame(result)); levelDBService.get(gameKey, "games") as Promise<LibraryGame | null>,
levelDBService.get(gameKey, "downloads") as Promise<Download | null>,
]);
if (!game || game.isDeleted) {
setGame(null);
return;
}
const { id: _id, ...gameWithoutId } = game;
setGame({ id: gameKey, ...gameWithoutId, download: download ?? null });
}, [shop, objectId]); }, [shop, objectId]);
const isGameDownloading = const isGameDownloading =
@@ -297,7 +311,10 @@ export function GameDetailsContextProvider({
const fetchDownloadSources = async () => { const fetchDownloadSources = async () => {
try { try {
const sources = await window.electron.getDownloadSources(); const sourcesRaw = (await levelDBService.values(
"downloadSources"
)) as DownloadSource[];
const sources = orderBy(sourcesRaw, "createdAt", "desc");
const params = { const params = {
take: 100, take: 100,

View File

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

View File

@@ -14,14 +14,11 @@ import type {
GameStats, GameStats,
UserDetails, UserDetails,
FriendRequestSync, FriendRequestSync,
GameArtifact,
LudusaviBackup, LudusaviBackup,
UserAchievement, UserAchievement,
ComparedAchievements, ComparedAchievements,
LibraryGame,
GameRunning, GameRunning,
TorBoxUser, TorBoxUser,
Theme,
Auth, Auth,
ShortcutLocation, ShortcutLocation,
ShopAssets, ShopAssets,
@@ -142,10 +139,6 @@ declare global {
shop: GameShop, shop: GameShop,
objectId: string objectId: string
) => Promise<void>; ) => Promise<void>;
clearNewDownloadOptions: (
shop: GameShop,
objectId: string
) => Promise<void>;
toggleGamePin: ( toggleGamePin: (
shop: GameShop, shop: GameShop,
objectId: string, objectId: string,
@@ -162,7 +155,6 @@ declare global {
winePrefixPath: string | null winePrefixPath: string | null
) => Promise<void>; ) => Promise<void>;
verifyExecutablePathInUse: (executablePath: string) => Promise<Game>; verifyExecutablePathInUse: (executablePath: string) => Promise<Game>;
getLibrary: () => Promise<LibraryGame[]>;
refreshLibraryAssets: () => Promise<void>; refreshLibraryAssets: () => Promise<void>;
openGameInstaller: (shop: GameShop, objectId: string) => Promise<boolean>; openGameInstaller: (shop: GameShop, objectId: string) => Promise<boolean>;
openGameInstallerPath: (shop: GameShop, objectId: string) => Promise<void>; openGameInstallerPath: (shop: GameShop, objectId: string) => Promise<void>;
@@ -177,10 +169,6 @@ declare global {
removeGameFromLibrary: (shop: GameShop, objectId: string) => Promise<void>; removeGameFromLibrary: (shop: GameShop, objectId: string) => Promise<void>;
removeGame: (shop: GameShop, objectId: string) => Promise<void>; removeGame: (shop: GameShop, objectId: string) => Promise<void>;
deleteGameFolder: (shop: GameShop, objectId: string) => Promise<unknown>; deleteGameFolder: (shop: GameShop, objectId: string) => Promise<unknown>;
getGameByObjectId: (
shop: GameShop,
objectId: string
) => Promise<LibraryGame | null>;
onGamesRunning: ( onGamesRunning: (
cb: ( cb: (
gamesRunning: Pick<GameRunning, "id" | "sessionDurationInMillis">[] gamesRunning: Pick<GameRunning, "id" | "sessionDurationInMillis">[]
@@ -194,9 +182,9 @@ declare global {
playtimeInSeconds: number playtimeInSeconds: number
) => Promise<void>; ) => Promise<void>;
/* User preferences */ /* User preferences */
getUserPreferences: () => Promise<UserPreferences | null>;
authenticateRealDebrid: (apiToken: string) => Promise<RealDebridUser>; authenticateRealDebrid: (apiToken: string) => Promise<RealDebridUser>;
authenticateTorBox: (apiToken: string) => Promise<TorBoxUser>; authenticateTorBox: (apiToken: string) => Promise<TorBoxUser>;
getUserPreferences: () => Promise<UserPreferences | null>;
updateUserPreferences: ( updateUserPreferences: (
preferences: Partial<UserPreferences> preferences: Partial<UserPreferences>
) => Promise<void>; ) => Promise<void>;
@@ -217,10 +205,7 @@ declare global {
removeAll = false, removeAll = false,
downloadSourceId?: string downloadSourceId?: string
) => Promise<void>; ) => Promise<void>;
getDownloadSources: () => Promise<DownloadSource[]>;
syncDownloadSources: () => Promise<void>; syncDownloadSources: () => Promise<void>;
getDownloadSourcesCheckBaseline: () => Promise<string | null>;
getDownloadSourcesSinceValue: () => Promise<string | null>;
/* Hardware */ /* Hardware */
getDiskFreeSpace: (path: string) => Promise<DiskUsage>; getDiskFreeSpace: (path: string) => Promise<DiskUsage>;
@@ -237,10 +222,6 @@ declare global {
shop: GameShop, shop: GameShop,
gameArtifactId: string gameArtifactId: string
) => Promise<void>; ) => Promise<void>;
getGameArtifacts: (
objectId: string,
shop: GameShop
) => Promise<GameArtifact[]>;
getGameBackupPreview: ( getGameBackupPreview: (
objectId: string, objectId: string,
shop: GameShop shop: GameShop
@@ -355,7 +336,6 @@ declare global {
getAuth: () => Promise<Auth | null>; getAuth: () => Promise<Auth | null>;
signOut: () => Promise<void>; signOut: () => Promise<void>;
openAuthWindow: (page: AuthPage) => Promise<void>; openAuthWindow: (page: AuthPage) => Promise<void>;
getSessionHash: () => Promise<string | null>;
onSignIn: (cb: () => void) => () => Electron.IpcRenderer; onSignIn: (cb: () => void) => () => Electron.IpcRenderer;
onAccountUpdated: (cb: () => void) => () => Electron.IpcRenderer; onAccountUpdated: (cb: () => void) => () => Electron.IpcRenderer;
onSignOut: (cb: () => void) => () => Electron.IpcRenderer; onSignOut: (cb: () => void) => () => Electron.IpcRenderer;
@@ -408,13 +388,7 @@ declare global {
showAchievementTestNotification: () => Promise<void>; showAchievementTestNotification: () => Promise<void>;
/* Themes */ /* Themes */
addCustomTheme: (theme: Theme) => Promise<void>;
getAllCustomThemes: () => Promise<Theme[]>;
deleteAllCustomThemes: () => Promise<void>;
deleteCustomTheme: (themeId: string) => Promise<void>;
updateCustomTheme: (themeId: string, code: string) => Promise<void>; updateCustomTheme: (themeId: string, code: string) => Promise<void>;
getCustomThemeById: (themeId: string) => Promise<Theme | null>;
getActiveCustomTheme: () => Promise<Theme | null>;
toggleCustomTheme: (themeId: string, isActive: boolean) => Promise<void>; toggleCustomTheme: (themeId: string, isActive: boolean) => Promise<void>;
copyThemeAchievementSound: ( copyThemeAchievementSound: (
themeId: string, themeId: string,
@@ -438,6 +412,25 @@ declare global {
onNewDownloadOptions: ( onNewDownloadOptions: (
cb: (gamesWithNewOptions: { gameId: string; count: number }[]) => void cb: (gamesWithNewOptions: { gameId: string; count: number }[]) => void
) => () => Electron.IpcRenderer; ) => () => Electron.IpcRenderer;
/* LevelDB Generic CRUD */
leveldb: {
get: (
key: string,
sublevelName?: string | null,
valueEncoding?: "json" | "utf8"
) => Promise<unknown>;
put: (
key: string,
value: unknown,
sublevelName?: string | null,
valueEncoding?: "json" | "utf8"
) => Promise<void>;
del: (key: string, sublevelName?: string | null) => Promise<void>;
clear: (sublevelName: string) => Promise<void>;
values: (sublevelName: string) => Promise<unknown[]>;
iterator: (sublevelName: string) => Promise<[string, unknown][]>;
};
} }
interface Window { interface Window {

View File

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

View File

@@ -10,3 +10,4 @@ export * from "./use-download-options-listener";
export * from "./use-game-card"; export * from "./use-game-card";
export * from "./use-search-history"; export * from "./use-search-history";
export * from "./use-search-suggestions"; export * from "./use-search-suggestions";
export * from "./use-hls-video";

View File

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

View File

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

View File

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

View File

@@ -1,4 +1,5 @@
import { useState, useCallback, useEffect } from "react"; import { useState, useCallback, useEffect, useRef } from "react";
import { levelDBService } from "@renderer/services/leveldb.service";
export interface SearchHistoryEntry { export interface SearchHistoryEntry {
query: string; query: string;
@@ -6,22 +7,32 @@ export interface SearchHistoryEntry {
context: "library" | "catalogue"; context: "library" | "catalogue";
} }
const STORAGE_KEY = "search-history"; const LEVELDB_KEY = "searchHistory";
const MAX_HISTORY_ENTRIES = 15; const MAX_HISTORY_ENTRIES = 15;
export function useSearchHistory() { export function useSearchHistory() {
const [history, setHistory] = useState<SearchHistoryEntry[]>([]); const [history, setHistory] = useState<SearchHistoryEntry[]>([]);
const isInitialized = useRef(false);
useEffect(() => { useEffect(() => {
const stored = localStorage.getItem(STORAGE_KEY); const loadHistory = async () => {
if (stored) { if (isInitialized.current) return;
isInitialized.current = true;
try { try {
const parsed = JSON.parse(stored) as SearchHistoryEntry[]; const data = (await levelDBService.get(LEVELDB_KEY, null, "json")) as
setHistory(parsed); | SearchHistoryEntry[]
| null;
if (data) {
setHistory(data);
}
} catch { } catch {
localStorage.removeItem(STORAGE_KEY); setHistory([]);
} }
} };
loadHistory();
}, []); }, []);
const addToHistory = useCallback( const addToHistory = useCallback(
@@ -39,7 +50,7 @@ export function useSearchHistory() {
(entry) => entry.query.toLowerCase() !== query.toLowerCase().trim() (entry) => entry.query.toLowerCase() !== query.toLowerCase().trim()
); );
const updated = [newEntry, ...filtered].slice(0, MAX_HISTORY_ENTRIES); const updated = [newEntry, ...filtered].slice(0, MAX_HISTORY_ENTRIES);
localStorage.setItem(STORAGE_KEY, JSON.stringify(updated)); levelDBService.put(LEVELDB_KEY, updated, null, "json");
return updated; return updated;
}); });
}, },
@@ -49,14 +60,14 @@ export function useSearchHistory() {
const removeFromHistory = useCallback((query: string) => { const removeFromHistory = useCallback((query: string) => {
setHistory((prev) => { setHistory((prev) => {
const updated = prev.filter((entry) => entry.query !== query); const updated = prev.filter((entry) => entry.query !== query);
localStorage.setItem(STORAGE_KEY, JSON.stringify(updated)); levelDBService.put(LEVELDB_KEY, updated, null, "json");
return updated; return updated;
}); });
}, []); }, []);
const clearHistory = useCallback(() => { const clearHistory = useCallback(() => {
setHistory([]); setHistory([]);
localStorage.removeItem(STORAGE_KEY); levelDBService.del(LEVELDB_KEY, null);
}, []); }, []);
const getRecentHistory = useCallback( const getRecentHistory = useCallback(

View File

@@ -1,11 +1,13 @@
import { useState, useEffect, useCallback, useRef } from "react"; import { useState, useEffect, useCallback, useRef } from "react";
import { useAppSelector } from "./redux"; import { useAppSelector } from "./redux";
import { debounce } from "lodash-es"; import { debounce } from "lodash-es";
import { logger } from "@renderer/logger";
import type { GameShop } from "@types";
export interface SearchSuggestion { export interface SearchSuggestion {
title: string; title: string;
objectId: string; objectId: string;
shop: string; shop: GameShop;
iconUrl: string | null; iconUrl: string | null;
source: "library" | "catalogue"; source: "library" | "catalogue";
} }
@@ -19,6 +21,7 @@ export function useSearchSuggestions(
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const library = useAppSelector((state) => state.library.value); const library = useAppSelector((state) => state.library.value);
const abortControllerRef = useRef<AbortController | null>(null); const abortControllerRef = useRef<AbortController | null>(null);
const cacheRef = useRef<Map<string, SearchSuggestion[]>>(new Map());
const getLibrarySuggestions = useCallback( const getLibrarySuggestions = useCallback(
(searchQuery: string, limit: number = 3): SearchSuggestion[] => { (searchQuery: string, limit: number = 3): SearchSuggestion[] => {
@@ -67,6 +70,15 @@ export function useSearchSuggestions(
return; return;
} }
const cacheKey = `${searchQuery.toLowerCase()}_${limit}`;
const cachedResults = cacheRef.current.get(cacheKey);
if (cachedResults) {
setSuggestions(cachedResults);
setIsLoading(false);
return;
}
abortControllerRef.current?.abort(); abortControllerRef.current?.abort();
const abortController = new AbortController(); const abortController = new AbortController();
abortControllerRef.current = abortController; abortControllerRef.current = abortController;
@@ -75,12 +87,12 @@ export function useSearchSuggestions(
try { try {
const response = await window.electron.hydraApi.get< const response = await window.electron.hydraApi.get<
Array<{ {
title: string; title: string;
objectId: string; objectId: string;
shop: string; shop: GameShop;
iconUrl: string | null; iconUrl: string | null;
}> }[]
>("/catalogue/search/suggestions", { >("/catalogue/search/suggestions", {
params: { params: {
query: searchQuery, query: searchQuery,
@@ -98,10 +110,12 @@ export function useSearchSuggestions(
}) })
); );
cacheRef.current.set(cacheKey, catalogueSuggestions);
setSuggestions(catalogueSuggestions); setSuggestions(catalogueSuggestions);
} catch (error) { } catch (error) {
if (!abortController.signal.aborted) { if (!abortController.signal.aborted) {
setSuggestions([]); setSuggestions([]);
logger.error("Failed to fetch catalogue suggestions", error);
} }
} finally { } finally {
if (!abortController.signal.aborted) { if (!abortController.signal.aborted) {

View File

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

View File

@@ -11,6 +11,7 @@ import {
getAchievementSoundVolume, getAchievementSoundVolume,
} from "@renderer/helpers"; } from "@renderer/helpers";
import { AchievementNotificationItem } from "@renderer/components/achievements/notification/achievement-notification"; import { AchievementNotificationItem } from "@renderer/components/achievements/notification/achievement-notification";
import { levelDBService } from "@renderer/services/leveldb.service";
import app from "../../../app.scss?inline"; import app from "../../../app.scss?inline";
import styles from "../../../components/achievements/notification/achievement-notification.scss?inline"; import styles from "../../../components/achievements/notification/achievement-notification.scss?inline";
import root from "react-shadow"; import root from "react-shadow";
@@ -144,7 +145,11 @@ export function AchievementNotification() {
const loadAndApplyTheme = useCallback(async () => { const loadAndApplyTheme = useCallback(async () => {
if (!shadowRootRef) return; 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) { if (activeTheme?.code) {
injectCustomCss(activeTheme.code, shadowRootRef); injectCustomCss(activeTheme.code, shadowRootRef);
} else { } else {

View File

@@ -4,158 +4,512 @@
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: calc(globals.$spacing-unit * 2); gap: calc(globals.$spacing-unit * 2);
margin-inline: calc(globals.$spacing-unit * 3);
padding-block: calc(globals.$spacing-unit * 3);
&__details-with-article { &--queued {
display: flex; padding-bottom: 0;
align-items: center; }
gap: calc(globals.$spacing-unit / 2);
align-self: flex-start; &--completed {
cursor: pointer; padding-top: calc(globals.$spacing-unit * 3);
} }
&__header { &__header {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; gap: calc(globals.$spacing-unit);
gap: calc(globals.$spacing-unit * 2);
&-divider { &-title-group {
display: flex;
align-items: center;
gap: calc(globals.$spacing-unit);
flex: 1; 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 { &-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;
} }
} }
&--hero {
&__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 {
width: 100%; width: 100%;
gap: calc(globals.$spacing-unit * 2); position: relative;
display: flex; overflow: hidden;
flex-direction: column;
margin: 0; margin: 0;
padding: 0; padding: 0;
margin-top: globals.$spacing-unit; padding-bottom: globals.$spacing-unit;
} }
&__item { &__hero-background {
position: absolute;
top: 0;
left: 0;
width: 100%; width: 100%;
background-color: globals.$background-color; height: 120%;
display: flex; z-index: 0;
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;
&--hydra { img {
box-shadow: 0px 0px 16px 0px rgba(12, 241, 202, 0.15); width: 100%;
height: 100%;
object-fit: cover;
object-position: 50% 20%;
} }
} }
&__cover { // PLEASE FIX THE COLORS
width: 280px; &__hero-overlay {
min-width: 280px; position: absolute;
height: auto; top: 0;
border-right: solid 1px globals.$border-color; 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; position: relative;
z-index: 1; z-index: 1;
padding: calc(globals.$spacing-unit * 4);
&-content { padding-bottom: 0;
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 {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
flex: 1; gap: calc(globals.$spacing-unit * 2);
justify-content: center;
gap: calc(globals.$spacing-unit / 2);
font-size: 14px;
} }
&__actions { &__hero-logo {
flex: 1;
min-width: 0;
display: flex; display: flex;
align-items: center; 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 { &__hero-action-row {
position: absolute; display: flex;
top: 12px; justify-content: space-between;
right: 12px; align-items: flex-start;
border-radius: 50%; gap: calc(globals.$spacing-unit * 3);
border: none; margin-top: calc(globals.$spacing-unit * 4);
padding: 8px; 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; min-height: unset;
} }
&__hydra-gradient { &__progress-wrapper {
background: linear-gradient(90deg, #01483c 0%, #0cf1ca 50%, #01483c 100%); flex: 1;
box-shadow: 0px 0px 8px 0px rgba(12, 241, 202, 0.15); display: flex;
flex-direction: column;
gap: calc(globals.$spacing-unit / 2);
}
&__progress-bar {
width: 100%; width: 100%;
position: absolute; height: 8px;
bottom: 0; background-color: rgba(255, 255, 255, 0.08);
height: 2px; border-radius: 4px;
z-index: 1; 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;
} }
} }

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -8,6 +8,7 @@ import {
import useEmblaCarousel from "embla-carousel-react"; import useEmblaCarousel from "embla-carousel-react";
import { gameDetailsContext } from "@renderer/context"; import { gameDetailsContext } from "@renderer/context";
import { useAppSelector } from "@renderer/hooks"; import { useAppSelector } from "@renderer/hooks";
import { VideoPlayer } from "./video-player";
import "./gallery-slider.scss"; import "./gallery-slider.scss";
export function GallerySlider() { export function GallerySlider() {
@@ -106,8 +107,6 @@ export function GallerySlider() {
if (shopDetails?.movies) { if (shopDetails?.movies) {
shopDetails.movies.forEach((video, index) => { shopDetails.movies.forEach((video, index) => {
// Prefer new formats: HLS (best browser support), then DASH H264, then DASH AV1
// Fallback to old format: mp4/webm if new formats are not available
let videoSrc: string | undefined; let videoSrc: string | undefined;
let videoType: string | undefined; let videoType: string | undefined;
@@ -121,11 +120,9 @@ export function GallerySlider() {
videoSrc = video.dash_av1; videoSrc = video.dash_av1;
videoType = "application/dash+xml"; videoType = "application/dash+xml";
} else if (video.mp4?.max) { } else if (video.mp4?.max) {
// Fallback to old format
videoSrc = video.mp4.max; videoSrc = video.mp4.max;
videoType = "video/mp4"; videoType = "video/mp4";
} else if (video.webm?.max) { } else if (video.webm?.max) {
// Fallback to webm if mp4 is not available
videoSrc = video.webm.max; videoSrc = video.webm.max;
videoType = "video/webm"; videoType = "video/webm";
} }
@@ -191,19 +188,17 @@ export function GallerySlider() {
{mediaItems.map((item) => ( {mediaItems.map((item) => (
<div key={item.id} className="gallery-slider__slide"> <div key={item.id} className="gallery-slider__slide">
{item.type === "video" ? ( {item.type === "video" ? (
<video <VideoPlayer
controls videoSrc={item.videoSrc}
className="gallery-slider__media" videoType={item.videoType}
poster={item.poster} poster={item.poster}
autoplay={autoplayEnabled}
loop loop
muted muted
autoPlay={autoplayEnabled} controls
className="gallery-slider__media"
tabIndex={-1} tabIndex={-1}
> />
{item.videoSrc && (
<source src={item.videoSrc} type={item.videoType} />
)}
</video>
) : ( ) : (
<img <img
className="gallery-slider__media" className="gallery-slider__media"

View File

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

View File

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

View File

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

View File

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

View File

@@ -76,7 +76,13 @@ export default function Library() {
switch (filterBy) { switch (filterBy) {
case "recently_played": 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; break;
case "favorites": case "favorites":
filtered = library.filter((game) => game.favorite); filtered = library.filter((game) => game.favorite);

View File

@@ -87,12 +87,16 @@ export function LibraryTab({
<ul className="profile-content__games-grid"> <ul className="profile-content__games-grid">
{pinnedGames?.map((game) => ( {pinnedGames?.map((game) => (
<li key={game.objectId} style={{ listStyle: "none" }}> <li
key={game.objectId}
style={{ listStyle: "none" }}
className="user-library-game__wrapper"
onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave}
>
<UserLibraryGameCard <UserLibraryGameCard
game={game} game={game}
statIndex={statsIndex} statIndex={statsIndex}
onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave}
sortBy={sortBy} sortBy={sortBy}
/> />
</li> </li>
@@ -134,6 +138,9 @@ export function LibraryTab({
<motion.li <motion.li
key={`${sortBy}-${game.objectId}`} key={`${sortBy}-${game.objectId}`}
style={{ listStyle: "none" }} style={{ listStyle: "none" }}
className="user-library-game__wrapper"
onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave}
initial={ initial={
isNewGame isNewGame
? { opacity: 0.5, y: 15, scale: 0.96 } ? { opacity: 0.5, y: 15, scale: 0.96 }
@@ -160,8 +167,6 @@ export function LibraryTab({
<UserLibraryGameCard <UserLibraryGameCard
game={game} game={game}
statIndex={statsIndex} statIndex={statsIndex}
onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave}
sortBy={sortBy} sortBy={sortBy}
/> />
</motion.li> </motion.li>

View File

@@ -25,16 +25,12 @@ import "./user-library-game-card.scss";
interface UserLibraryGameCardProps { interface UserLibraryGameCardProps {
game: UserGame; game: UserGame;
statIndex: number; statIndex: number;
onMouseEnter: () => void;
onMouseLeave: () => void;
sortBy?: string; sortBy?: string;
} }
export function UserLibraryGameCard({ export function UserLibraryGameCard({
game, game,
statIndex, statIndex,
onMouseEnter,
onMouseLeave,
sortBy, sortBy,
}: UserLibraryGameCardProps) { }: UserLibraryGameCardProps) {
const { userProfile, isMe, getUserLibraryGames } = const { userProfile, isMe, getUserLibraryGames } =
@@ -130,129 +126,126 @@ export function UserLibraryGameCard({
return ( return (
<> <>
<li <div
onMouseEnter={onMouseEnter} className="user-library-game__cover"
onMouseLeave={onMouseLeave} onClick={() => navigate(buildUserGameDetailsPath(game))}
className="user-library-game__wrapper" onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
navigate(buildUserGameDetailsPath(game));
}
}}
role="button"
tabIndex={0}
title={isTooltipHovered ? undefined : game.title} title={isTooltipHovered ? undefined : game.title}
> >
<button <div className="user-library-game__overlay">
type="button" {isMe && (
className="user-library-game__cover" <div className="user-library-game__actions-container">
onClick={() => navigate(buildUserGameDetailsPath(game))} <button
> type="button"
<div className="user-library-game__overlay"> className="user-library-game__pin-button"
{isMe && ( onClick={(e) => {
<div className="user-library-game__actions-container"> e.stopPropagation();
<button toggleGamePinned();
type="button" }}
className="user-library-game__pin-button" disabled={isPinning}
onClick={(e) => { >
e.stopPropagation(); {game.isPinned ? (
toggleGamePinned(); <PinSlashIcon size={12} />
}} ) : (
disabled={isPinning} <PinIcon size={12} />
> )}
{game.isPinned ? ( </button>
<PinSlashIcon size={12} />
) : (
<PinIcon size={12} />
)}
</button>
</div>
)}
<div
className="user-library-game__playtime"
data-tooltip-place="top"
data-tooltip-content={
game.hasManuallyUpdatedPlaytime
? t("manual_playtime_tooltip")
: undefined
}
data-tooltip-id={game.objectId}
>
{game.hasManuallyUpdatedPlaytime ? (
<AlertFillIcon
size={11}
className="user-library-game__manual-playtime"
/>
) : (
<ClockIcon size={11} />
)}
<span className="user-library-game__playtime-long">
{formatPlayTime(game.playTimeInSeconds)}
</span>
<span className="user-library-game__playtime-short">
{formatPlayTime(game.playTimeInSeconds, true)}
</span>
</div> </div>
)}
<div
className="user-library-game__playtime"
data-tooltip-place="top"
data-tooltip-content={
game.hasManuallyUpdatedPlaytime
? t("manual_playtime_tooltip")
: undefined
}
data-tooltip-id={game.objectId}
>
{game.hasManuallyUpdatedPlaytime ? (
<AlertFillIcon
size={11}
className="user-library-game__manual-playtime"
/>
) : (
<ClockIcon size={11} />
)}
<span className="user-library-game__playtime-long">
{formatPlayTime(game.playTimeInSeconds)}
</span>
<span className="user-library-game__playtime-short">
{formatPlayTime(game.playTimeInSeconds, true)}
</span>
</div>
{userProfile?.hasActiveSubscription && {userProfile?.hasActiveSubscription && game.achievementCount > 0 && (
game.achievementCount > 0 && ( <div className="user-library-game__stats">
<div className="user-library-game__stats"> <div className="user-library-game__stats-header">
<div className="user-library-game__stats-header"> <div className="user-library-game__stats-content">
<div className="user-library-game__stats-content"> <div
<div className="user-library-game__stats-item"
className="user-library-game__stats-item" style={{
style={{ transform: `translateY(${-100 * (statIndex % getStatsItemCount())}%)`,
transform: `translateY(${-100 * (statIndex % getStatsItemCount())}%)`, }}
}} >
> <TrophyIcon size={13} />
<TrophyIcon size={13} />
<span>
{game.unlockedAchievementCount} /{" "}
{game.achievementCount}
</span>
</div>
{game.achievementsPointsEarnedSum > 0 && (
<div
className="user-library-game__stats-item"
style={{
transform: `translateY(${-100 * (statIndex % getStatsItemCount())}%)`,
}}
>
<HydraIcon width={16} height={16} />
{formatAchievementPoints(
game.achievementsPointsEarnedSum
)}
</div>
)}
</div>
<span> <span>
{formatDownloadProgress( {game.unlockedAchievementCount} / {game.achievementCount}
game.unlockedAchievementCount / game.achievementCount,
1
)}
</span> </span>
</div> </div>
<progress {game.achievementsPointsEarnedSum > 0 && (
max={1} <div
value={ className="user-library-game__stats-item"
game.unlockedAchievementCount / game.achievementCount style={{
} transform: `translateY(${-100 * (statIndex % getStatsItemCount())}%)`,
className="user-library-game__achievements-progress" }}
/> >
<HydraIcon width={16} height={16} />
{formatAchievementPoints(
game.achievementsPointsEarnedSum
)}
</div>
)}
</div> </div>
)}
</div>
{imageError || !game.coverImageUrl ? ( <span>
<div className="user-library-game__cover-placeholder"> {formatDownloadProgress(
<ImageIcon size={48} /> game.unlockedAchievementCount / game.achievementCount,
1
)}
</span>
</div>
<progress
max={1}
value={game.unlockedAchievementCount / game.achievementCount}
className="user-library-game__achievements-progress"
/>
</div> </div>
) : (
<img
src={game.coverImageUrl}
alt={game.title}
className="user-library-game__game-image"
onError={() => setImageError(true)}
/>
)} )}
</button> </div>
</li>
{imageError || !game.coverImageUrl ? (
<div className="user-library-game__cover-placeholder">
<ImageIcon size={48} />
</div>
) : (
<img
src={game.coverImageUrl}
alt={game.title}
className="user-library-game__game-image"
onError={() => setImageError(true)}
/>
)}
</div>
<Tooltip <Tooltip
id={game.objectId} id={game.objectId}
style={{ style={{

View File

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

View File

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

View File

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

View File

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

View File

@@ -11,6 +11,7 @@ import {
import { useToast } from "@renderer/hooks"; import { useToast } from "@renderer/hooks";
import { THEME_WEB_STORE_URL } from "@renderer/constants"; import { THEME_WEB_STORE_URL } from "@renderer/constants";
import { logger } from "@renderer/logger"; import { logger } from "@renderer/logger";
import { levelDBService } from "@renderer/services/leveldb.service";
interface ImportThemeModalProps { interface ImportThemeModalProps {
visible: boolean; visible: boolean;
@@ -45,9 +46,12 @@ export const ImportThemeModal = ({
}; };
try { 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; if (!currentTheme) return;
@@ -61,7 +65,11 @@ export const ImportThemeModal = ({
logger.error("Failed to import theme sound", soundError); 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) { if (activeTheme) {
removeCustomCss(); removeCustomCss();

View File

@@ -5,6 +5,7 @@ import type { Theme } from "@types";
import { ImportThemeModal } from "./modals/import-theme-modal"; import { ImportThemeModal } from "./modals/import-theme-modal";
import { settingsContext } from "@renderer/context"; import { settingsContext } from "@renderer/context";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { levelDBService } from "@renderer/services/leveldb.service";
interface SettingsAppearanceProps { interface SettingsAppearanceProps {
appearance: { appearance: {
@@ -31,7 +32,7 @@ export function SettingsAppearance({
const navigate = useNavigate(); const navigate = useNavigate();
const loadThemes = useCallback(async () => { const loadThemes = useCallback(async () => {
const themesList = await window.electron.getAllCustomThemes(); const themesList = (await levelDBService.values("themes")) as Theme[];
setThemes(themesList); setThemes(themesList);
}, []); }, []);

View File

@@ -21,6 +21,8 @@ import { DownloadSourceStatus } from "@shared";
import { settingsContext } from "@renderer/context"; import { settingsContext } from "@renderer/context";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { setFilters, clearFilters } from "@renderer/features"; import { setFilters, clearFilters } from "@renderer/features";
import { levelDBService } from "@renderer/services/leveldb.service";
import { orderBy } from "lodash-es";
import "./settings-download-sources.scss"; import "./settings-download-sources.scss";
import { logger } from "@renderer/logger"; import { logger } from "@renderer/logger";
@@ -52,8 +54,11 @@ export function SettingsDownloadSources() {
useEffect(() => { useEffect(() => {
const fetchDownloadSources = async () => { const fetchDownloadSources = async () => {
const sources = await window.electron.getDownloadSources(); const sources = (await levelDBService.values(
setDownloadSources(sources); "downloadSources"
)) as DownloadSource[];
const sorted = orderBy(sources, "createdAt", "desc");
setDownloadSources(sorted);
}; };
fetchDownloadSources(); fetchDownloadSources();
@@ -73,8 +78,11 @@ export function SettingsDownloadSources() {
const intervalId = setInterval(async () => { const intervalId = setInterval(async () => {
try { try {
await window.electron.syncDownloadSources(); await window.electron.syncDownloadSources();
const sources = await window.electron.getDownloadSources(); const sources = (await levelDBService.values(
setDownloadSources(sources); "downloadSources"
)) as DownloadSource[];
const sorted = orderBy(sources, "createdAt", "desc");
setDownloadSources(sorted);
} catch (error) { } catch (error) {
logger.error("Failed to fetch download sources:", error); logger.error("Failed to fetch download sources:", error);
} }
@@ -88,8 +96,11 @@ export function SettingsDownloadSources() {
try { try {
await window.electron.removeDownloadSource(false, downloadSource.id); await window.electron.removeDownloadSource(false, downloadSource.id);
const sources = await window.electron.getDownloadSources(); const sources = (await levelDBService.values(
setDownloadSources(sources); "downloadSources"
)) as DownloadSource[];
const sorted = orderBy(sources, "createdAt", "desc");
setDownloadSources(sorted);
showSuccessToast(t("removed_download_source")); showSuccessToast(t("removed_download_source"));
} catch (error) { } catch (error) {
logger.error("Failed to remove download source:", error); logger.error("Failed to remove download source:", error);
@@ -103,8 +114,11 @@ export function SettingsDownloadSources() {
try { try {
await window.electron.removeDownloadSource(true); await window.electron.removeDownloadSource(true);
const sources = await window.electron.getDownloadSources(); const sources = (await levelDBService.values(
setDownloadSources(sources); "downloadSources"
)) as DownloadSource[];
const sorted = orderBy(sources, "createdAt", "desc");
setDownloadSources(sorted);
showSuccessToast(t("removed_all_download_sources")); showSuccessToast(t("removed_all_download_sources"));
} catch (error) { } catch (error) {
logger.error("Failed to remove all download sources:", error); logger.error("Failed to remove all download sources:", error);
@@ -116,8 +130,11 @@ export function SettingsDownloadSources() {
const handleAddDownloadSource = async () => { const handleAddDownloadSource = async () => {
try { try {
const sources = await window.electron.getDownloadSources(); const sources = (await levelDBService.values(
setDownloadSources(sources); "downloadSources"
)) as DownloadSource[];
const sorted = orderBy(sources, "createdAt", "desc");
setDownloadSources(sorted);
} catch (error) { } catch (error) {
logger.error("Failed to refresh download sources:", error); logger.error("Failed to refresh download sources:", error);
} }
@@ -127,8 +144,11 @@ export function SettingsDownloadSources() {
setIsSyncingDownloadSources(true); setIsSyncingDownloadSources(true);
try { try {
await window.electron.syncDownloadSources(); await window.electron.syncDownloadSources();
const sources = await window.electron.getDownloadSources(); const sources = (await levelDBService.values(
setDownloadSources(sources); "downloadSources"
)) as DownloadSource[];
const sorted = orderBy(sources, "createdAt", "desc");
setDownloadSources(sorted);
showSuccessToast(t("download_sources_synced_successfully")); showSuccessToast(t("download_sources_synced_successfully"));
} finally { } finally {

View File

@@ -16,6 +16,7 @@ import { injectCustomCss, getAchievementSoundVolume } from "@renderer/helpers";
import { AchievementNotificationItem } from "@renderer/components/achievements/notification/achievement-notification"; import { AchievementNotificationItem } from "@renderer/components/achievements/notification/achievement-notification";
import { generateAchievementCustomNotificationTest } from "@shared"; import { generateAchievementCustomNotificationTest } from "@shared";
import { CollapsedMenu } from "@renderer/components/collapsed-menu/collapsed-menu"; import { CollapsedMenu } from "@renderer/components/collapsed-menu/collapsed-menu";
import { levelDBService } from "@renderer/services/leveldb.service";
import app from "../../app.scss?inline"; import app from "../../app.scss?inline";
import styles from "../../components/achievements/notification/achievement-notification.scss?inline"; import styles from "../../components/achievements/notification/achievement-notification.scss?inline";
import root from "react-shadow"; import root from "react-shadow";
@@ -64,15 +65,16 @@ export default function ThemeEditor() {
useEffect(() => { useEffect(() => {
if (themeId) { if (themeId) {
window.electron.getCustomThemeById(themeId).then((loadedTheme) => { levelDBService.get(themeId, "themes").then((loadedTheme) => {
if (loadedTheme) { const theme = loadedTheme as Theme | null;
setTheme(loadedTheme); if (theme) {
setCode(loadedTheme.code); setTheme(theme);
if (loadedTheme.originalSoundPath) { setCode(theme.code);
setSoundPath(loadedTheme.originalSoundPath); if (theme.originalSoundPath) {
setSoundPath(theme.originalSoundPath);
} }
if (shadowRootRef) { if (shadowRootRef) {
injectCustomCss(loadedTheme.code, shadowRootRef); injectCustomCss(theme.code, shadowRootRef);
} }
} }
}); });
@@ -132,7 +134,10 @@ export default function ThemeEditor() {
if (filePaths && filePaths.length > 0) { if (filePaths && filePaths.length > 0) {
const originalPath = filePaths[0]; const originalPath = filePaths[0];
await window.electron.copyThemeAchievementSound(theme.id, originalPath); 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) { if (updatedTheme) {
setTheme(updatedTheme); setTheme(updatedTheme);
if (updatedTheme.originalSoundPath) { if (updatedTheme.originalSoundPath) {
@@ -146,7 +151,10 @@ export default function ThemeEditor() {
if (!theme) return; if (!theme) return;
await window.electron.removeThemeAchievementSound(theme.id); 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) { if (updatedTheme) {
setTheme(updatedTheme); setTheme(updatedTheme);
} }

View File

@@ -0,0 +1,36 @@
class LevelDBService {
get(
key: string,
sublevelName?: string | null,
valueEncoding?: "json" | "utf8"
): Promise<unknown> {
return window.electron.leveldb.get(key, sublevelName, valueEncoding);
}
put(
key: string,
value: unknown,
sublevelName?: string | null,
valueEncoding?: "json" | "utf8"
): Promise<void> {
return window.electron.leveldb.put(key, value, sublevelName, valueEncoding);
}
del(key: string, sublevelName?: string | null): Promise<void> {
return window.electron.leveldb.del(key, sublevelName);
}
clear(sublevelName: string): Promise<void> {
return window.electron.leveldb.clear(sublevelName);
}
values(sublevelName: string): Promise<unknown[]> {
return window.electron.leveldb.values(sublevelName);
}
iterator(sublevelName: string): Promise<[string, unknown][]> {
return window.electron.leveldb.iterator(sublevelName);
}
}
export const levelDBService = new LevelDBService();

View File

@@ -20,6 +20,8 @@ export interface Auth {
accessToken: string; accessToken: string;
refreshToken: string; refreshToken: string;
tokenExpirationTimestamp: number; tokenExpirationTimestamp: number;
featurebaseJwt: string;
workwondersJwt: string;
} }
export interface User { export interface User {

View File

@@ -14,7 +14,7 @@ export interface SteamVideoSource {
"480": string; "480": string;
} }
export interface SteamMovies { export interface SteamMovie {
id: number; id: number;
dash_av1?: string; dash_av1?: string;
dash_h264?: string; dash_h264?: string;
@@ -34,7 +34,7 @@ export interface SteamAppDetails {
short_description: string; short_description: string;
publishers: string[]; publishers: string[];
genres: SteamGenre[]; genres: SteamGenre[];
movies?: SteamMovies[]; movies?: SteamMovie[];
supported_languages: string; supported_languages: string;
screenshots?: SteamScreenshot[]; screenshots?: SteamScreenshot[];
pc_requirements: { pc_requirements: {

View File

@@ -5690,6 +5690,11 @@ hasown@^2.0.2:
dependencies: dependencies:
function-bind "^1.1.2" 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: hoist-non-react-statics@^3.3.2:
version "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" 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== integrity sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==
js-yaml@^4.1.0: js-yaml@^4.1.0:
version "4.1.0" version "4.1.1"
resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-4.1.0.tgz#c1fb65f8f5017901cdd2c951864ba18458a10602" resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-4.1.1.tgz#854c292467705b699476e1a2decc0c8a3458806b"
integrity sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA== integrity sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==
dependencies: dependencies:
argparse "^2.0.1" argparse "^2.0.1"
@@ -6325,7 +6330,7 @@ jsonwebtoken@^9.0.2:
object.assign "^4.1.4" object.assign "^4.1.4"
object.values "^1.1.6" object.values "^1.1.6"
jwa@^1.4.1: jwa@^1.4.2:
version "1.4.2" version "1.4.2"
resolved "https://registry.yarnpkg.com/jwa/-/jwa-1.4.2.tgz#16011ac6db48de7b102777e57897901520eec7b9" resolved "https://registry.yarnpkg.com/jwa/-/jwa-1.4.2.tgz#16011ac6db48de7b102777e57897901520eec7b9"
integrity sha512-eeH5JO+21J78qMvTIDdBXidBd6nG2kZjg5Ohz/1fpa28Z4CcsWUzJ1ZZyFq/3z3N17aZy+ZuBoHljASbL1WfOw== integrity sha512-eeH5JO+21J78qMvTIDdBXidBd6nG2kZjg5Ohz/1fpa28Z4CcsWUzJ1ZZyFq/3z3N17aZy+ZuBoHljASbL1WfOw==
@@ -6335,11 +6340,11 @@ jwa@^1.4.1:
safe-buffer "^5.0.1" safe-buffer "^5.0.1"
jws@^3.2.2: jws@^3.2.2:
version "3.2.2" version "3.2.3"
resolved "https://registry.yarnpkg.com/jws/-/jws-3.2.2.tgz#001099f3639468c9414000e99995fa52fb478304" resolved "https://registry.yarnpkg.com/jws/-/jws-3.2.3.tgz#5ac0690b460900a27265de24520526853c0b8ca1"
integrity sha512-YHlZCB6lMTllWDtSPHz/ZXTsi8S00usEV6v1tjq8tOUZzw7DpSDWVXjXDre6ed1w/pd495ODpHZYSdkRTsa0HA== integrity sha512-byiJ0FLRdLdSVSReO/U4E7RoEyOCKnEnEPMjq3HxWtvzLsV08/i5RQKsFVNkCldrCaPr2vDNAOMsfs8T/Hze7g==
dependencies: dependencies:
jwa "^1.4.1" jwa "^1.4.2"
safe-buffer "^5.0.1" safe-buffer "^5.0.1"
keyv@^4.0.0, keyv@^4.5.3: 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" mkdirp "^1.0.3"
yallist "^4.0.0" yallist "^4.0.0"
tar@^7.4.3: tar@^7.5.2:
version "7.5.1" version "7.5.2"
resolved "https://registry.yarnpkg.com/tar/-/tar-7.5.1.tgz#750a8bd63b7c44c1848e7bf982260a083cf747c9" resolved "https://registry.yarnpkg.com/tar/-/tar-7.5.2.tgz#115c061495ec51ff3c6745ff8f6d0871c5b1dedc"
integrity sha512-nlGpxf+hv0v7GkWBK2V9spgactGOp0qvfWRxUMjqHyzrt3SgwE48DIv/FhqPHJYLHpgW1opq3nERbz5Anq7n1g== integrity sha512-7NyxrTE4Anh8km8iEy7o0QYPs+0JKBTj5ZaqHg6B39erLg0qYXN3BijtShwbsNSvQ+LN75+KV+C4QR/f6Gwnpg==
dependencies: dependencies:
"@isaacs/fs-minipass" "^4.0.0" "@isaacs/fs-minipass" "^4.0.0"
chownr "^3.0.0" chownr "^3.0.0"