Compare commits

..

83 Commits

Author SHA1 Message Date
Moyasee
3f7b9e2a0b fix: merge conflict 2026-01-11 20:42:20 +02:00
Chubby Granny Chaser
3b3926156e Merge pull request #1919 from Stormm232/main
Hungarian Translation of 3.8.0
2026-01-11 18:32:10 +00:00
Chubby Granny Chaser
0089e08ba7 Merge branch 'main' into main 2026-01-11 18:32:04 +00:00
Chubby Granny Chaser
bced12d077 Merge pull request #1934 from hydralauncher/feat/adding-banner-removal
Feat/adding banner removal
2026-01-11 18:31:03 +00:00
Chubby Granny Chaser
f8ba72a0e2 refactor: clean up code formatting and improve readability in DownloadGroup and ProfileHero components
- Adjusted indentation and spacing for better code clarity in DownloadGroup.
- Removed unnecessary blank lines in ProfileHero to streamline the code structure.
- Ensured consistent formatting across both components.
2026-01-11 17:14:24 +00:00
Chubby Granny Chaser
2029f861f6 Merge branch 'main' of https://github.com/hydralauncher/hydra into feat/vikingfile-support 2026-01-11 17:14:07 +00:00
Chubby Granny Chaser
46e248c62a feat: add banner management features and translations
- Introduced new translations for banner actions including "Change banner", "Replace banner", "Remove banner", and confirmation prompts in English, Spanish, Portuguese, and Russian.
- Updated the UploadBackgroundImageButton component to support banner management with options to change, replace, or remove the banner.
- Implemented a confirmation modal for removing the banner.
- Enhanced user experience with animations for dropdown menus and button interactions.
- Removed deprecated Qiwi downloader support and added Rootz downloader integration.
2026-01-11 17:13:54 +00:00
Moyasee
dba8f9fb22 feat: add disabled hint for HTTP downloader setting during active downloads and update z-index for error message 2026-01-11 19:13:51 +02:00
Moyasee
b565ef7f00 Merge branch 'feat/LBX-367' of https://github.com/hydralauncher/hydra into feat/LBX-367 2026-01-11 17:54:05 +02:00
Moyasee
9298d9aa09 fix: enable native HTTP downloader in settings 2026-01-11 17:50:16 +02:00
Moyase
5b05fc2644 Merge branch 'main' into feat/LBX-367 2026-01-11 16:13:19 +02:00
Moyase
605d064ec0 Merge pull request #1924 from hydralauncher/fix/friends-box-display
feat: add empty state for friends box and new translation key
2026-01-11 16:13:07 +02:00
Moyase
c0956b1bc1 Merge branch 'main' into fix/friends-box-display 2026-01-11 16:06:20 +02:00
Moyasee
2e152d321e refactor: remove HttpMultiLinkDownloader and update download handling logic 2026-01-11 16:00:39 +02:00
Moyasee
a553b049ba Merge branch 'feat/LBX-367' of https://github.com/hydralauncher/hydra into feat/LBX-367 2026-01-11 15:37:13 +02:00
Moyasee
467b27baa3 refactor: remove unused JsMultiLinkDownloader and ensure aria2 spawning on startup 2026-01-11 15:36:16 +02:00
Moyase
4342a4a5d5 Merge branch 'main' into feat/LBX-367 2026-01-11 15:30:17 +02:00
Chubby Granny Chaser
d9d443ee6d Merge pull request #1932 from hydralauncher/dependabot/npm_and_yarn/npm_and_yarn-e9d8c310be
Some checks failed
Build / build (ubuntu-latest) (push) Has been cancelled
Build / build (windows-2022) (push) Has been cancelled
chore(deps): bump @smithy/config-resolver from 4.3.1 to 4.4.5 in the npm_and_yarn group across 1 directory
2026-01-11 02:38:10 +00:00
Chubby Granny Chaser
a912b57ccc Merge branch 'main' into dependabot/npm_and_yarn/npm_and_yarn-e9d8c310be 2026-01-11 02:20:44 +00:00
Moyase
447c146035 Merge pull request #1923 from hydralauncher/fix/archive-extraction
fix: archives with password doesn't extract properly
2026-01-11 04:12:01 +02:00
Moyase
39ff44f9d1 Merge branch 'main' into fix/archive-extraction 2026-01-11 04:09:00 +02:00
Chubby Granny Chaser
dbe101b7df Merge pull request #1927 from Sneezedip/main
Fix translation for hydra_cloud_feature_found (pt-PT)
2026-01-11 02:08:39 +00:00
Moyasee
5e4e03a958 refactor: enhance download management by adding filename resolution and extraction handling in DownloadManage 2026-01-10 20:11:20 +02:00
Moyasee
da0ae54b60 refactor: update cancel download confirmation text and enhance error handling in JsHttpDownloader 2026-01-10 19:47:55 +02:00
Moyasee
562e30eecf refactor: add cancel download confirmation modal and enhance download management in DownloadGroup 2026-01-10 18:49:31 +02:00
dependabot[bot]
e7a62c16fa chore(deps): bump @smithy/config-resolver
Bumps the npm_and_yarn group with 1 update in the / directory: [@smithy/config-resolver](https://github.com/smithy-lang/smithy-typescript/tree/HEAD/packages/config-resolver).


Updates `@smithy/config-resolver` from 4.3.1 to 4.4.5
- [Release notes](https://github.com/smithy-lang/smithy-typescript/releases)
- [Changelog](https://github.com/smithy-lang/smithy-typescript/blob/main/packages/config-resolver/CHANGELOG.md)
- [Commits](https://github.com/smithy-lang/smithy-typescript/commits/@smithy/config-resolver@4.4.5/packages/config-resolver)

---
updated-dependencies:
- dependency-name: "@smithy/config-resolver"
  dependency-version: 4.4.5
  dependency-type: indirect
  dependency-group: npm_and_yarn
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-01-08 23:29:01 +00:00
Moyasee
ed044d797f refactor: streamline download preparation and status handling in DownloadManager 2026-01-07 20:35:43 +02:00
Moyasee
ed3cce160f refactor: adjust file size handling in DownloadManager to ensure accurate download status updates 2026-01-07 17:32:46 +02:00
Moyasee
c67b275657 refactor: improve download initiation and error handling in DownloadManager 2026-01-07 17:26:29 +02:00
Moyasee
a2e866317d refactor: update interruptedDownload type to Download | null for improved type safety 2026-01-06 20:01:15 +02:00
Moyasee
a7c82de4a7 refactor: enhance download management by prioritizing interrupted downloads and improving error logging 2026-01-06 19:59:52 +02:00
Moyasee
027761a1b5 refactor: update cancelDownload method to conditionally delete file based on parameter 2026-01-06 19:18:42 +02:00
Moyasee
ca2f70aede refactor: enhance filename extraction and handling in download services 2026-01-06 18:50:36 +02:00
Moyasee
2b3a8bf6b6 refactor: replace type assertions with non-null assertions for download ID in DownloadManager 2026-01-06 18:11:22 +02:00
Moyasee
81b3ad3612 refactor: update download ID extraction and improve optional chaining in download services 2026-01-06 18:05:05 +02:00
Moyasee
8f477072ba refactor: improve error handling and download path preparation in JsHttpDownloader 2026-01-06 17:56:46 +02:00
Moyasee
569700e85c refactor: streamline download status updates in DownloadManager 2026-01-06 17:47:12 +02:00
Moyasee
4975f2def9 refactor: optimize chunk handling in JsHttpDownloader 2026-01-06 17:42:42 +02:00
Moyasee
77af7509ac feat: implement native HTTP downloader option and enhance download management 2026-01-06 17:41:05 +02:00
Sneezedip
f37ccbb4c0 Fix translation for hydra_cloud_feature_found 2026-01-06 12:33:14 -01:00
Moyasee
feb8d78e01 fix: update password index initialization in tryPassword function for correct behavior 2026-01-04 21:10:37 +02:00
Kiwo.2
44f39d94c4 Added new lines* 2026-01-04 14:07:34 +01:00
Kiwo.2
e618d313b3 Merge branch 'main' of https://github.com/Stormm232/hydra 2026-01-04 13:56:46 +01:00
Kiwo.2
7eec87c192 Added new lines 2026-01-04 13:56:32 +01:00
Moyasee
2ccc93ea61 feat: add empty state for friends box and new translation key 2026-01-04 04:23:59 +02:00
Zamitto
7e7390885e feat: adding ww feedback button
Some checks failed
Build / build (ubuntu-latest) (push) Has been cancelled
Build / build (windows-2022) (push) Has been cancelled
2026-01-03 19:55:48 -03:00
Moyase
64815f4f8d Merge pull request #1910 from hydralauncher/feat/vikingfile-support
feat: VikingFile support and display url availability
2026-01-03 23:42:55 +02:00
Moyasee
4dfdc4d798 chore: remove commented code in DownloadSettingsModal 2026-01-03 23:40:07 +02:00
Moyasee
9bbfab2aff Merge branch 'feat/vikingfile-support' of https://github.com/hydralauncher/hydra into feat/vikingfile-support 2026-01-03 23:35:00 +02:00
Moyasee
01938f8905 refactor: simplify downloader sorting and enhance availability indicators in DownloadSettingsModal 2026-01-03 23:34:19 +02:00
Moyase
f60ad5908d Merge branch 'main' into feat/vikingfile-support 2026-01-03 23:28:17 +02:00
Moyasee
fe6553bcdc chore: remove unused HTTPS import in vikingfile service 2026-01-03 23:23:35 +02:00
Moyasee
87895bb715 refactor: enhance disabled state styling and logic in DownloadSettingsModal 2026-01-03 23:22:40 +02:00
Chubby Granny Chaser
290209f372 chore: remove unnecessary blank lines in RealDebridInfoModal component files 2026-01-03 21:07:35 +00:00
Chubby Granny Chaser
87fcbaa56e chore: bump version to 3.8.0 and update translations for downloader status and notifications 2026-01-03 21:07:09 +00:00
Zamitto
c32ce14630 Merge pull request #1915 from hydralauncher/feat/add-workwonders
feat: add workwonders
2026-01-03 17:19:17 -03:00
Zamitto
e52f10a5ff chore: bump ww version 2026-01-03 17:17:16 -03:00
Moyase
bcdbe31596 Merge pull request #1914 from hydralauncher/fix/friend-request-endpoint
fix: update API endpoint for deleting friend requests in useUserDetai…
2026-01-03 22:08:45 +02:00
Zamitto
7ed514b6ef feat: parse locale before ww init 2026-01-03 16:54:42 -03:00
Moyase
42386ae0b5 Merge branch 'main' into fix/friend-request-endpoint 2026-01-03 21:48:52 +02:00
Moyase
04d8f900a6 Merge pull request #1913 from hydralauncher/fix/friends-and-karma-ui
refactor: remove karma description from translations across multiple …
2026-01-03 21:48:41 +02:00
Zamitto
8b3bcd88b1 feat: add workwonders 2026-01-03 16:42:49 -03:00
Moyasee
b2bffeb2b0 fix: update API endpoint for deleting friend requests in useUserDetails hook 2026-01-03 21:01:39 +02:00
Moyase
0a194eaa29 Merge branch 'main' into fix/friends-and-karma-ui 2026-01-03 20:13:08 +02:00
Moyasee
07c277c033 refactor: remove karma description from translations across multiple languages 2026-01-03 20:11:22 +02:00
Moyasee
345696ad06 Merge branch 'feat/vikingfile-support' of https://github.com/hydralauncher/hydra into feat/vikingfile-support 2026-01-03 19:47:39 +02:00
Moyasee
6c4e8c406f refactor: update HTTP module imports to use node: prefix for consistency 2026-01-03 19:44:39 +02:00
Moyase
c46a1e7848 Merge branch 'main' into feat/vikingfile-support 2026-01-03 19:41:49 +02:00
Moyase
590e09a8c3 Merge pull request #1912 from hydralauncher/fix/notifications-page-ui
Some checks failed
Build / build (ubuntu-latest) (push) Has been cancelled
Build / build (windows-2022) (push) Has been cancelled
feat: add unread filter option and enhance notifications UI
2026-01-03 19:41:30 +02:00
Moyasee
c1d7ea27f3 feat: add unread filter option and enhance notifications UI 2026-01-03 19:37:47 +02:00
Chubby Granny Chaser
15dbd3b2ad Merge pull request #1909 from hydralauncher/fix/library-game-covers
fix: library cards not using placeholder and icon as a game cover
2026-01-03 16:19:33 +00:00
Moyasee
4584783f44 refactor: enhance download progress tracking in DownloadManager 2026-01-03 04:47:46 +02:00
Moyasee
765ec70dd0 refactor: streamline downloader logic in DownloadSettingsModal 2026-01-03 01:40:21 +02:00
Moyasee
de483da51c fix: handle download not found exception in HttpDownloader and enforce IPv4 in HTTP agents 2026-01-03 01:08:25 +02:00
Moyasee
2bc0266775 feat: add loading state to download button and enhance UI with spinner 2026-01-03 00:18:07 +02:00
Moyasee
c9729fb3eb chore: update build and release workflows to include MAIN_VITE_NIMBUS_API_URL 2026-01-02 23:59:21 +02:00
Moyasee
9a7ad148e3 fix: use logger for error handling in VikingFile.ts 2026-01-02 23:24:20 +02:00
Moyasee
d929fbaeaa refactor: simplify header assignment in HttpDownloader 2026-01-02 23:23:08 +02:00
Moyasee
8fa33119d6 feat: add support for VikingFile and display if link is available 2026-01-02 23:20:08 +02:00
Moyasee
92d87c5d33 refactor: remove unnecessary useEffect in LibraryGameCard 2025-12-31 01:59:25 +02:00
Moyasee
af884d3772 refactor: simplify cover image assignment in LibraryGameCard 2025-12-30 14:09:04 +02:00
Moyasee
dc31ac0831 fix: library cards not using placeholder and icon as a game cover 2025-12-30 00:25:45 +02:00
Chubby Granny Chaser
9769eecec6 Merge pull request #1907 from hydralauncher/fix/lint-buzz
Some checks failed
Build / build (ubuntu-latest) (push) Has been cancelled
Build / build (windows-2022) (push) Has been cancelled
refactor: improve code formatting and consistency in DownloadManager
2025-12-27 00:56:29 +00:00
69 changed files with 3157 additions and 799 deletions

View File

@@ -1,6 +1,7 @@
MAIN_VITE_API_URL= MAIN_VITE_API_URL=
MAIN_VITE_AUTH_URL= MAIN_VITE_AUTH_URL=
MAIN_VITE_WS_URL= MAIN_VITE_WS_URL=
MAIN_VITE_NIMBUS_API_URL=
RENDERER_VITE_REAL_DEBRID_REFERRAL_ID= RENDERER_VITE_REAL_DEBRID_REFERRAL_ID=
RENDERER_VITE_TORBOX_REFERRAL_CODE= RENDERER_VITE_TORBOX_REFERRAL_CODE=
MAIN_VITE_LAUNCHER_SUBDOMAIN= MAIN_VITE_LAUNCHER_SUBDOMAIN=

View File

@@ -57,6 +57,7 @@ jobs:
MAIN_VITE_AUTH_URL: ${{ vars.MAIN_VITE_STAGING_AUTH_URL }} MAIN_VITE_AUTH_URL: ${{ vars.MAIN_VITE_STAGING_AUTH_URL }}
MAIN_VITE_CHECKOUT_URL: ${{ vars.MAIN_VITE_STAGING_CHECKOUT_URL }} MAIN_VITE_CHECKOUT_URL: ${{ vars.MAIN_VITE_STAGING_CHECKOUT_URL }}
MAIN_VITE_WS_URL: ${{ vars.MAIN_VITE_WS_STAGING_URL }} MAIN_VITE_WS_URL: ${{ vars.MAIN_VITE_WS_STAGING_URL }}
MAIN_VITE_NIMBUS_API_URL: ${{ vars.MAIN_VITE_NIMBUS_API_URL }}
RENDERER_VITE_EXTERNAL_RESOURCES_URL: ${{ vars.EXTERNAL_RESOURCES_URL }} RENDERER_VITE_EXTERNAL_RESOURCES_URL: ${{ vars.EXTERNAL_RESOURCES_URL }}
MAIN_VITE_EXTERNAL_RESOURCES_URL: ${{ vars.EXTERNAL_RESOURCES_URL }} MAIN_VITE_EXTERNAL_RESOURCES_URL: ${{ vars.EXTERNAL_RESOURCES_URL }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
@@ -73,6 +74,7 @@ jobs:
MAIN_VITE_AUTH_URL: ${{ vars.MAIN_VITE_STAGING_AUTH_URL }} MAIN_VITE_AUTH_URL: ${{ vars.MAIN_VITE_STAGING_AUTH_URL }}
MAIN_VITE_CHECKOUT_URL: ${{ vars.MAIN_VITE_STAGING_CHECKOUT_URL }} MAIN_VITE_CHECKOUT_URL: ${{ vars.MAIN_VITE_STAGING_CHECKOUT_URL }}
MAIN_VITE_WS_URL: ${{ vars.MAIN_VITE_WS_STAGING_URL }} MAIN_VITE_WS_URL: ${{ vars.MAIN_VITE_WS_STAGING_URL }}
MAIN_VITE_NIMBUS_API_URL: ${{ vars.MAIN_VITE_NIMBUS_API_URL }}
RENDERER_VITE_EXTERNAL_RESOURCES_URL: ${{ vars.EXTERNAL_RESOURCES_URL }} RENDERER_VITE_EXTERNAL_RESOURCES_URL: ${{ vars.EXTERNAL_RESOURCES_URL }}
MAIN_VITE_EXTERNAL_RESOURCES_URL: ${{ vars.EXTERNAL_RESOURCES_URL }} MAIN_VITE_EXTERNAL_RESOURCES_URL: ${{ vars.EXTERNAL_RESOURCES_URL }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

View File

@@ -54,9 +54,10 @@ jobs:
MAIN_VITE_API_URL: ${{ vars.MAIN_VITE_API_URL }} MAIN_VITE_API_URL: ${{ vars.MAIN_VITE_API_URL }}
MAIN_VITE_AUTH_URL: ${{ vars.MAIN_VITE_AUTH_URL }} MAIN_VITE_AUTH_URL: ${{ vars.MAIN_VITE_AUTH_URL }}
MAIN_VITE_CHECKOUT_URL: ${{ vars.MAIN_VITE_CHECKOUT_URL }} MAIN_VITE_CHECKOUT_URL: ${{ vars.MAIN_VITE_CHECKOUT_URL }}
MAIN_VITE_WS_URL: ${{ vars.MAIN_VITE_WS_URL }}
MAIN_VITE_NIMBUS_API_URL: ${{ vars.MAIN_VITE_NIMBUS_API_URL }}
RENDERER_VITE_EXTERNAL_RESOURCES_URL: ${{ vars.EXTERNAL_RESOURCES_URL }} RENDERER_VITE_EXTERNAL_RESOURCES_URL: ${{ vars.EXTERNAL_RESOURCES_URL }}
MAIN_VITE_EXTERNAL_RESOURCES_URL: ${{ vars.EXTERNAL_RESOURCES_URL }} MAIN_VITE_EXTERNAL_RESOURCES_URL: ${{ vars.EXTERNAL_RESOURCES_URL }}
MAIN_VITE_WS_URL: ${{ vars.MAIN_VITE_WS_URL }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
RENDERER_VITE_SENTRY_DSN: ${{ vars.SENTRY_DSN }} RENDERER_VITE_SENTRY_DSN: ${{ vars.SENTRY_DSN }}
@@ -71,9 +72,10 @@ jobs:
MAIN_VITE_API_URL: ${{ vars.MAIN_VITE_API_URL }} MAIN_VITE_API_URL: ${{ vars.MAIN_VITE_API_URL }}
MAIN_VITE_AUTH_URL: ${{ vars.MAIN_VITE_AUTH_URL }} MAIN_VITE_AUTH_URL: ${{ vars.MAIN_VITE_AUTH_URL }}
MAIN_VITE_CHECKOUT_URL: ${{ vars.MAIN_VITE_CHECKOUT_URL }} MAIN_VITE_CHECKOUT_URL: ${{ vars.MAIN_VITE_CHECKOUT_URL }}
MAIN_VITE_WS_URL: ${{ vars.MAIN_VITE_WS_URL }}
MAIN_VITE_NIMBUS_API_URL: ${{ vars.MAIN_VITE_NIMBUS_API_URL }}
RENDERER_VITE_EXTERNAL_RESOURCES_URL: ${{ vars.EXTERNAL_RESOURCES_URL }} RENDERER_VITE_EXTERNAL_RESOURCES_URL: ${{ vars.EXTERNAL_RESOURCES_URL }}
MAIN_VITE_EXTERNAL_RESOURCES_URL: ${{ vars.EXTERNAL_RESOURCES_URL }} MAIN_VITE_EXTERNAL_RESOURCES_URL: ${{ vars.EXTERNAL_RESOURCES_URL }}
MAIN_VITE_WS_URL: ${{ vars.MAIN_VITE_WS_URL }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
RENDERER_VITE_SENTRY_DSN: ${{ vars.SENTRY_DSN }} RENDERER_VITE_SENTRY_DSN: ${{ vars.SENTRY_DSN }}

View File

@@ -1,6 +1,6 @@
{ {
"name": "hydralauncher", "name": "hydralauncher",
"version": "3.7.6", "version": "3.8.0",
"description": "Hydra", "description": "Hydra",
"main": "./out/main/index.js", "main": "./out/main/index.js",
"author": "Los Broxas", "author": "Los Broxas",
@@ -91,6 +91,7 @@
"user-agents": "^1.1.387", "user-agents": "^1.1.387",
"uuid": "^13.0.0", "uuid": "^13.0.0",
"winreg": "^1.2.5", "winreg": "^1.2.5",
"workwonders-sdk": "0.0.10",
"ws": "^8.18.1", "ws": "^8.18.1",
"yaml": "^2.6.1", "yaml": "^2.6.1",
"yup": "^1.5.0" "yup": "^1.5.0"

View File

@@ -1,4 +1,5 @@
import aria2p import aria2p
from aria2p.client import ClientException as DownloadNotFound
class HttpDownloader: class HttpDownloader:
def __init__(self): def __init__(self):
@@ -11,12 +12,16 @@ class HttpDownloader:
) )
) )
def start_download(self, url: str, save_path: str, header: str, out: str = None): def start_download(self, url: str, save_path: str, header, out: str = None):
if self.download: if self.download:
self.aria2.resume([self.download]) self.aria2.resume([self.download])
else: else:
downloads = self.aria2.add(url, options={"header": header, "dir": save_path, "out": out}) options = {"dir": save_path}
if header:
options["header"] = header
if out:
options["out"] = out
downloads = self.aria2.add(url, options=options)
self.download = downloads[0] self.download = downloads[0]
def pause_download(self): def pause_download(self):
@@ -32,7 +37,11 @@ class HttpDownloader:
if self.download == None: if self.download == None:
return None return None
download = self.aria2.get_download(self.download.gid) try:
download = self.aria2.get_download(self.download.gid)
except DownloadNotFound:
self.download = None
return None
response = { response = {
'folderName': download.name, 'folderName': download.name,

View File

@@ -1,151 +0,0 @@
import aria2p
from aria2p.client import ClientException as DownloadNotFound
class HttpMultiLinkDownloader:
def __init__(self):
self.downloads = []
self.completed_downloads = []
self.total_size = None
self.aria2 = aria2p.API(
aria2p.Client(
host="http://localhost",
port=6800,
secret=""
)
)
def start_download(self, urls: list[str], save_path: str, header: str = None, out: str = None, total_size: int = None):
"""Add multiple URLs to download queue with same options"""
options = {"dir": save_path}
if header:
options["header"] = header
if out:
options["out"] = out
# Clear any existing downloads first
self.cancel_download()
self.completed_downloads = []
self.total_size = total_size
for url in urls:
try:
added_downloads = self.aria2.add(url, options=options)
self.downloads.extend(added_downloads)
except Exception as e:
print(f"Error adding download for URL {url}: {str(e)}")
def pause_download(self):
"""Pause all active downloads"""
if self.downloads:
try:
self.aria2.pause(self.downloads)
except Exception as e:
print(f"Error pausing downloads: {str(e)}")
def cancel_download(self):
"""Cancel and remove all downloads"""
if self.downloads:
try:
# First try to stop the downloads
self.aria2.remove(self.downloads)
except Exception as e:
print(f"Error removing downloads: {str(e)}")
finally:
# Clear the downloads list regardless of success/failure
self.downloads = []
self.completed_downloads = []
def get_download_status(self):
"""Get status for all tracked downloads, auto-remove completed/failed ones"""
if not self.downloads and not self.completed_downloads:
return []
total_completed = 0
current_download_speed = 0
active_downloads = []
to_remove = []
# First calculate sizes from completed downloads
for completed in self.completed_downloads:
total_completed += completed['size']
# Then check active downloads
for download in self.downloads:
try:
current_download = self.aria2.get_download(download.gid)
# Skip downloads that are not properly initialized
if not current_download or not current_download.files:
to_remove.append(download)
continue
# Add to completed size and speed calculations
total_completed += current_download.completed_length
current_download_speed += current_download.download_speed
# If download is complete, move it to completed_downloads
if current_download.status == 'complete':
self.completed_downloads.append({
'name': current_download.name,
'size': current_download.total_length
})
to_remove.append(download)
else:
active_downloads.append({
'name': current_download.name,
'size': current_download.total_length,
'completed': current_download.completed_length,
'speed': current_download.download_speed
})
except DownloadNotFound:
to_remove.append(download)
continue
except Exception as e:
print(f"Error getting download status: {str(e)}")
continue
# Clean up completed/removed downloads from active list
for download in to_remove:
try:
if download in self.downloads:
self.downloads.remove(download)
except ValueError:
pass
# Return aggregate status
if self.total_size or active_downloads or self.completed_downloads:
# Use the first active download's name as the folder name, or completed if none active
folder_name = None
if active_downloads:
folder_name = active_downloads[0]['name']
elif self.completed_downloads:
folder_name = self.completed_downloads[0]['name']
if folder_name and '/' in folder_name:
folder_name = folder_name.split('/')[0]
# Use provided total size if available, otherwise sum from downloads
total_size = self.total_size
if not total_size:
total_size = sum(d['size'] for d in active_downloads) + sum(d['size'] for d in self.completed_downloads)
# Calculate completion status based on total downloaded vs total size
is_complete = len(active_downloads) == 0 and total_completed >= (total_size * 0.99) # Allow 1% margin for size differences
# If all downloads are complete, clear the completed_downloads list to prevent status updates
if is_complete:
self.completed_downloads = []
return [{
'folderName': folder_name,
'fileSize': total_size,
'progress': total_completed / total_size if total_size > 0 else 0,
'downloadSpeed': current_download_speed,
'numPeers': 0,
'numSeeds': 0,
'status': 'complete' if is_complete else 'active',
'bytesDownloaded': total_completed,
}]
return []

View File

@@ -3,7 +3,6 @@ import sys, json, urllib.parse, psutil
from torrent_downloader import TorrentDownloader from torrent_downloader import TorrentDownloader
from http_downloader import HttpDownloader from http_downloader import HttpDownloader
from profile_image_processor import ProfileImageProcessor from profile_image_processor import ProfileImageProcessor
from http_multi_link_downloader import HttpMultiLinkDownloader
import libtorrent as lt import libtorrent as lt
app = Flask(__name__) app = Flask(__name__)
@@ -25,15 +24,7 @@ if start_download_payload:
initial_download = json.loads(urllib.parse.unquote(start_download_payload)) initial_download = json.loads(urllib.parse.unquote(start_download_payload))
downloading_game_id = initial_download['game_id'] downloading_game_id = initial_download['game_id']
if isinstance(initial_download['url'], list): if initial_download['url'].startswith('magnet'):
# Handle multiple URLs using HttpMultiLinkDownloader
http_multi_downloader = HttpMultiLinkDownloader()
downloads[initial_download['game_id']] = http_multi_downloader
try:
http_multi_downloader.start_download(initial_download['url'], initial_download['save_path'], initial_download.get('header'), initial_download.get("out"))
except Exception as e:
print("Error starting multi-link download", e)
elif initial_download['url'].startswith('magnet'):
torrent_downloader = TorrentDownloader(torrent_session) torrent_downloader = TorrentDownloader(torrent_session)
downloads[initial_download['game_id']] = torrent_downloader downloads[initial_download['game_id']] = torrent_downloader
try: try:
@@ -78,14 +69,6 @@ def status():
if not status: if not status:
return jsonify(None) return jsonify(None)
if isinstance(status, list):
if not status: # Empty list
return jsonify(None)
# For multi-link downloader, use the aggregated status
# The status will already be aggregated by the HttpMultiLinkDownloader
return jsonify(status[0]), 200
return jsonify(status), 200 return jsonify(status), 200
@app.route("/seed-status", methods=["GET"]) @app.route("/seed-status", methods=["GET"])
@@ -104,21 +87,7 @@ def seed_status():
if not response: if not response:
continue continue
if isinstance(response, list): if response.get('status') == 5: # Torrent seeding check
# For multi-link downloader, check if all files are complete
if response and all(item['status'] == 'complete' for item in response):
seed_status.append({
'gameId': game_id,
'status': 'complete',
'folderName': response[0]['folderName'],
'fileSize': sum(item['fileSize'] for item in response),
'bytesDownloaded': sum(item['bytesDownloaded'] for item in response),
'downloadSpeed': 0,
'numPeers': 0,
'numSeeds': 0,
'progress': 1.0
})
elif response.get('status') == 5: # Original torrent seeding check
seed_status.append({ seed_status.append({
'gameId': game_id, 'gameId': game_id,
**response, **response,
@@ -180,15 +149,7 @@ def action():
existing_downloader = downloads.get(game_id) existing_downloader = downloads.get(game_id)
if isinstance(url, list): if url.startswith('magnet'):
# Handle multiple URLs using HttpMultiLinkDownloader
if existing_downloader and isinstance(existing_downloader, HttpMultiLinkDownloader):
existing_downloader.start_download(url, data['save_path'], data.get('header'), data.get('out'))
else:
http_multi_downloader = HttpMultiLinkDownloader()
downloads[game_id] = http_multi_downloader
http_multi_downloader.start_download(url, data['save_path'], data.get('header'), data.get('out'))
elif url.startswith('magnet'):
if existing_downloader and isinstance(existing_downloader, TorrentDownloader): if existing_downloader and isinstance(existing_downloader, TorrentDownloader):
existing_downloader.start_download(url, data['save_path']) existing_downloader.start_download(url, data['save_path'])
else: else:

View File

@@ -175,6 +175,7 @@
"repacks_modal_description": "Choose the repack you want to download", "repacks_modal_description": "Choose the repack you want to download",
"select_folder_hint": "To change the default folder, go to the <0>Settings</0>", "select_folder_hint": "To change the default folder, go to the <0>Settings</0>",
"download_now": "Download now", "download_now": "Download now",
"loading": "Loading...",
"no_shop_details": "Could not retrieve shop details.", "no_shop_details": "Could not retrieve shop details.",
"download_options": "Download options", "download_options": "Download options",
"download_path": "Download path", "download_path": "Download path",
@@ -184,6 +185,12 @@
"open_screenshot": "Open screenshot {{number}}", "open_screenshot": "Open screenshot {{number}}",
"download_settings": "Download settings", "download_settings": "Download settings",
"downloader": "Downloader", "downloader": "Downloader",
"downloader_online": "Online",
"downloader_not_configured": "Available but not configured",
"downloader_offline": "Link is offline",
"downloader_not_available": "Not available",
"recommended": "Recommended",
"go_to_settings": "Go to Settings",
"select_executable": "Select", "select_executable": "Select",
"no_executable_selected": "No executable selected", "no_executable_selected": "No executable selected",
"open_folder": "Open folder", "open_folder": "Open folder",
@@ -397,6 +404,10 @@
"completed": "Completed", "completed": "Completed",
"removed": "Not downloaded", "removed": "Not downloaded",
"cancel": "Cancel", "cancel": "Cancel",
"cancel_download": "Cancel download?",
"cancel_download_description": "Are you sure you want to cancel this download? All downloaded files will be deleted.",
"keep_downloading": "No, keep downloading",
"yes_cancel": "Yes, cancel",
"filter": "Filter downloaded games", "filter": "Filter downloaded games",
"remove": "Remove", "remove": "Remove",
"downloading_metadata": "Downloading metadata…", "downloading_metadata": "Downloading metadata…",
@@ -587,7 +598,10 @@
"notification_preview": "Achievement Notification Preview", "notification_preview": "Achievement Notification Preview",
"enable_friend_start_game_notifications": "When a friend starts playing a game", "enable_friend_start_game_notifications": "When a friend starts playing a game",
"autoplay_trailers_on_game_page": "Automatically start playing trailers on game page", "autoplay_trailers_on_game_page": "Automatically start playing trailers on game page",
"hide_to_tray_on_game_start": "Hide Hydra to tray on game startup" "hide_to_tray_on_game_start": "Hide Hydra to tray on game startup",
"downloads": "Downloads",
"use_native_http_downloader": "Use native HTTP downloader (experimental)",
"cannot_change_downloader_while_downloading": "Cannot change this setting while a download is in progress"
}, },
"notifications": { "notifications": {
"download_complete": "Download complete", "download_complete": "Download complete",
@@ -682,6 +696,7 @@
"blocked_users": "Blocked users", "blocked_users": "Blocked users",
"unblock": "Unblock", "unblock": "Unblock",
"no_friends_added": "You have no added friends", "no_friends_added": "You have no added friends",
"no_friends_yet": "You haven't added any friends yet",
"view_all": "View all", "view_all": "View all",
"load_more": "Load more", "load_more": "Load more",
"loading": "Loading", "loading": "Loading",
@@ -709,8 +724,15 @@
"profile_reported": "Profile reported", "profile_reported": "Profile reported",
"your_friend_code": "Your friend code:", "your_friend_code": "Your friend code:",
"copy_friend_code": "Copy friend code", "copy_friend_code": "Copy friend code",
"copied": "Copied!",
"upload_banner": "Upload banner", "upload_banner": "Upload banner",
"uploading_banner": "Uploading banner…", "uploading_banner": "Uploading banner…",
"change_banner": "Change banner",
"replace_banner": "Replace banner",
"remove_banner": "Remove banner",
"remove_banner_modal_title": "Remove banner?",
"remove_banner_confirmation": "Are you sure you want to remove your banner? You can always pick a new one when you want.",
"remove": "Remove",
"background_image_updated": "Background image updated", "background_image_updated": "Background image updated",
"stats": "Stats", "stats": "Stats",
"achievements": "achievements", "achievements": "achievements",
@@ -728,13 +750,10 @@
"game_added_to_pinned": "Game added to pinned", "game_added_to_pinned": "Game added to pinned",
"karma": "Karma", "karma": "Karma",
"karma_count": "karma", "karma_count": "karma",
"karma_description": "Earned from positive likes on reviews",
"user_reviews": "Reviews", "user_reviews": "Reviews",
"delete_review": "Delete Review", "delete_review": "Delete Review",
"loading_reviews": "Loading reviews...", "loading_reviews": "Loading reviews...",
"wrapped_2025": "Wrapped 2025", "wrapped_2025": "Wrapped 2025"
"view_my_wrapped_button": "View My Wrapped 2025",
"view_wrapped_button": "View {{displayName}}'s Wrapped 2025"
}, },
"library": { "library": {
"library": "Library", "library": "Library",
@@ -795,6 +814,7 @@
"empty_description": "You're all caught up! Check back later for new updates.", "empty_description": "You're all caught up! Check back later for new updates.",
"empty_filter_description": "No notifications match this filter.", "empty_filter_description": "No notifications match this filter.",
"filter_all": "All", "filter_all": "All",
"filter_unread": "Unread",
"filter_friends": "Friends", "filter_friends": "Friends",
"filter_badges": "Badges", "filter_badges": "Badges",
"filter_upvotes": "Upvotes", "filter_upvotes": "Upvotes",

View File

@@ -182,6 +182,12 @@
"open_screenshot": "Abrir captura número {{number}}", "open_screenshot": "Abrir captura número {{number}}",
"download_settings": "Descargar ajustes", "download_settings": "Descargar ajustes",
"downloader": "Descargador", "downloader": "Descargador",
"downloader_online": "En línea",
"downloader_not_configured": "Disponible pero no configurado",
"downloader_offline": "El enlace está fuera de línea",
"downloader_not_available": "No disponible",
"recommended": "Recomendado",
"go_to_settings": "Ir a Ajustes",
"select_executable": "Seleccionar", "select_executable": "Seleccionar",
"no_executable_selected": "Sin ejecutable seleccionado", "no_executable_selected": "Sin ejecutable seleccionado",
"open_folder": "Abrir carpeta", "open_folder": "Abrir carpeta",
@@ -651,6 +657,7 @@
"sending": "Enviando", "sending": "Enviando",
"friend_request_sent": "Solicitud de amistad enviada", "friend_request_sent": "Solicitud de amistad enviada",
"friends": "Amistades", "friends": "Amistades",
"badges": "Insignias",
"friends_list": "Lista de amistades", "friends_list": "Lista de amistades",
"user_not_found": "Usuario no encontrado", "user_not_found": "Usuario no encontrado",
"block_user": "Bloquear usuario", "block_user": "Bloquear usuario",
@@ -661,12 +668,16 @@
"ignore_request": "Ignorar solicitud", "ignore_request": "Ignorar solicitud",
"cancel_request": "Cancelar solicitud", "cancel_request": "Cancelar solicitud",
"undo_friendship": "Deshacer amistad", "undo_friendship": "Deshacer amistad",
"friendship_removed": "Amigo eliminado",
"request_accepted": "Solicitud aceptada", "request_accepted": "Solicitud aceptada",
"user_blocked_successfully": "Usuario bloqueado exitosamente", "user_blocked_successfully": "Usuario bloqueado exitosamente",
"user_block_modal_text": "Esto va a bloquear a {{displayName}}", "user_block_modal_text": "Esto va a bloquear a {{displayName}}",
"blocked_users": "Usuarios bloqueados", "blocked_users": "Usuarios bloqueados",
"unblock": "Desbloquear", "unblock": "Desbloquear",
"no_friends_added": "No tenés amistades añadidas", "no_friends_added": "No tenés amistades añadidas",
"view_all": "Ver todo",
"load_more": "Cargar más",
"loading": "Cargando",
"pending": "Pendiente", "pending": "Pendiente",
"no_pending_invites": "No tenés invitaciones pendientes", "no_pending_invites": "No tenés invitaciones pendientes",
"no_blocked_users": "No has bloqueado a nadie", "no_blocked_users": "No has bloqueado a nadie",
@@ -690,8 +701,16 @@
"report_reason_other": "Otros", "report_reason_other": "Otros",
"profile_reported": "Perfil reportado", "profile_reported": "Perfil reportado",
"your_friend_code": "Tu código de amistad:", "your_friend_code": "Tu código de amistad:",
"copy_friend_code": "Copiar código de amistad",
"copied": "¡Copiado!",
"upload_banner": "Subir banner", "upload_banner": "Subir banner",
"uploading_banner": "Subiendo banner…", "uploading_banner": "Subiendo banner…",
"change_banner": "Cambiar banner",
"replace_banner": "Reemplazar banner",
"remove_banner": "Eliminar banner",
"remove_banner_modal_title": "¿Eliminar banner?",
"remove_banner_confirmation": "¿Estás seguro de que querés eliminar tu banner? Siempre podés elegir uno nuevo cuando quieras.",
"remove": "Eliminar",
"background_image_updated": "Imagen de fondo actualizada", "background_image_updated": "Imagen de fondo actualizada",
"stats": "Estadísticas", "stats": "Estadísticas",
"achievements": "logros", "achievements": "logros",
@@ -710,11 +729,11 @@
"amount_minutes_short": "{{amount}}m", "amount_minutes_short": "{{amount}}m",
"karma": "Karma", "karma": "Karma",
"karma_count": "karma", "karma_count": "karma",
"karma_description": "Conseguido por me gustas positivos en reseñas",
"sort_by": "Filtrar por:", "sort_by": "Filtrar por:",
"game_added_to_pinned": "Juego añadido a fijados", "game_added_to_pinned": "Juego añadido a fijados",
"user_reviews": "Reseñas", "user_reviews": "Reseñas",
"loading_reviews": "Cargando reseñas...", "loading_reviews": "Cargando reseñas...",
"wrapped_2025": "Wrapped 2025",
"no_reviews": "Sin reseñas aún", "no_reviews": "Sin reseñas aún",
"delete_review": "Eliminar reseña" "delete_review": "Eliminar reseña"
}, },
@@ -767,5 +786,41 @@
"all_games": "Todos los Juegos", "all_games": "Todos los Juegos",
"recently_played": "Jugados Recientemente", "recently_played": "Jugados Recientemente",
"favorites": "Favoritos" "favorites": "Favoritos"
},
"notifications_page": {
"title": "Notificaciones",
"mark_all_as_read": "Marcar todo como leído",
"clear_all": "Limpiar todo",
"loading": "Cargando...",
"empty_title": "Sin notificaciones",
"empty_description": "¡Estás al día! Volvé más tarde para ver nuevas actualizaciones.",
"empty_filter_description": "No hay notificaciones que coincidan con este filtro.",
"filter_all": "Todas",
"filter_unread": "No leídas",
"filter_friends": "Amigos",
"filter_badges": "Insignias",
"filter_upvotes": "Votos",
"filter_local": "Locales",
"load_more": "Cargar más",
"dismiss": "Descartar",
"accept": "Aceptar",
"refuse": "Rechazar",
"notification": "Notificación",
"friend_request_received_title": "¡Nueva solicitud de amistad!",
"friend_request_received_description": "{{displayName}} quiere ser tu amigo",
"friend_request_accepted_title": "¡Solicitud de amistad aceptada!",
"friend_request_accepted_description": "{{displayName}} aceptó tu solicitud de amistad",
"badge_received_title": "¡Obtuviste una nueva insignia!",
"badge_received_description": "{{badgeName}}",
"review_upvote_title": "¡Tu reseña de {{gameTitle}} recibió votos!",
"review_upvote_description": "Tu reseña recibió {{count}} nuevos votos",
"marked_all_as_read": "Todas las notificaciones marcadas como leídas",
"failed_to_mark_as_read": "Error al marcar las notificaciones como leídas",
"cleared_all": "Todas las notificaciones eliminadas",
"failed_to_clear": "Error al eliminar las notificaciones",
"failed_to_load": "Error al cargar las notificaciones",
"failed_to_dismiss": "Error al descartar la notificación",
"friend_request_accepted": "Solicitud de amistad aceptada",
"friend_request_refused": "Solicitud de amistad rechazada"
} }
} }

View File

@@ -673,8 +673,7 @@
"game_removed_from_pinned": "Peli poistettu kiinnitetyistä", "game_removed_from_pinned": "Peli poistettu kiinnitetyistä",
"game_added_to_pinned": "Peli lisätty kiinnitettyihin", "game_added_to_pinned": "Peli lisätty kiinnitettyihin",
"karma": "Karma", "karma": "Karma",
"karma_count": "karmaa", "karma_count": "karmaa"
"karma_description": "Ansittu positiivisilla arvosteluäänillä"
}, },
"achievement": { "achievement": {
"achievement_unlocked": "Saavutus avattu", "achievement_unlocked": "Saavutus avattu",

View File

@@ -26,6 +26,7 @@
"game_has_no_executable": "A játékhoz nincs tallózva futtatható fájl", "game_has_no_executable": "A játékhoz nincs tallózva futtatható fájl",
"sign_in": "Bejelentkezés", "sign_in": "Bejelentkezés",
"friends": "Barátok", "friends": "Barátok",
"notifications": "Értesítések",
"need_help": "Elakadtál?", "need_help": "Elakadtál?",
"favorites": "Kedvenc Játékaim", "favorites": "Kedvenc Játékaim",
"playable_button_title": "Csak az azonnal játszható játékokat mutasd", "playable_button_title": "Csak az azonnal játszható játékokat mutasd",
@@ -174,6 +175,7 @@
"repacks_modal_description": "Válaszd ki a repacket amit leszeretnél tölteni", "repacks_modal_description": "Válaszd ki a repacket amit leszeretnél tölteni",
"select_folder_hint": "A letöltési mappát a <0>Beállításokban</0> változtathatod meg", "select_folder_hint": "A letöltési mappát a <0>Beállításokban</0> változtathatod meg",
"download_now": "Letöltés", "download_now": "Letöltés",
"loading": "Töltés...",
"no_shop_details": "A bolt adatai nem érhetőek el.", "no_shop_details": "A bolt adatai nem érhetőek el.",
"download_options": "Letöltési opciók", "download_options": "Letöltési opciók",
"download_path": "Letöltési hely", "download_path": "Letöltési hely",
@@ -182,7 +184,13 @@
"screenshot": "Screenshot {{number}}", "screenshot": "Screenshot {{number}}",
"open_screenshot": "{{number}} Screenshot megnyitása ", "open_screenshot": "{{number}} Screenshot megnyitása ",
"download_settings": "Letöltési beállítások", "download_settings": "Letöltési beállítások",
"downloader": "Letöltési mód", "downloader": "Letöltő",
"downloader_online": "Elérhető",
"downloader_not_configured": "Elérhető de nincs beállítva",
"downloader_offline": "A link nem elérhető",
"downloader_not_available": "Nem elérhető",
"recommended": "Ajánlott",
"go_to_settings": "Beállítások megnyitása",
"select_executable": "Tallózás", "select_executable": "Tallózás",
"no_executable_selected": "Nincs futtatható fájl tallózva", "no_executable_selected": "Nincs futtatható fájl tallózva",
"open_folder": "Mappa megnyitása", "open_folder": "Mappa megnyitása",
@@ -418,9 +426,11 @@
"extract": "Fájlok kibontása", "extract": "Fájlok kibontása",
"extracting": "Fájlok kibontása…", "extracting": "Fájlok kibontása…",
"delete_archive_title": "Szeretnéd törölni ezt a fájlt? {{fileName}}", "delete_archive_title": "Szeretnéd törölni ezt a fájlt? {{fileName}}",
"delete_archive_description": "A tömörített fájl ki lett csomagolva, s többé nincs rá szükség. ", "delete_archive_description": "A tömörített fájl ki lett csomagolva és többé nincs rá szükség.",
"yes": "Igen", "yes": "Igen",
"no": "Nem" "no": "Nem",
"network": "HÁLÓZAT",
"peak": "CSÚCS"
}, },
"settings": { "settings": {
"downloads_path": "Letöltési útvonalak", "downloads_path": "Letöltési útvonalak",
@@ -444,7 +454,7 @@
"debrid_linked_message": "Fiók összekapcsolva: \"{{username}}\" ", "debrid_linked_message": "Fiók összekapcsolva: \"{{username}}\" ",
"save_changes": "Változtatások mentése", "save_changes": "Változtatások mentése",
"changes_saved": "Változtatások sikeresen mentve", "changes_saved": "Változtatások sikeresen mentve",
"download_sources_description": "A Hydra lefogja tölteni a letöltési linkeket a forrásokból. Az URL Forrásnak közvetlen linknek kell lennie egy .json fájlhoz, ami tartalmazza a linkeket.", "download_sources_description": "A Hydra lefogja tölteni a letöltési linkeket a forrásokból, ennek az URL Forrásnak közvetlen linknek kell lennie egy .json fájlhoz, ami tartalmazza a linkeket.",
"validate_download_source": "Érvényesítés", "validate_download_source": "Érvényesítés",
"remove_download_source": "Eltávolítás", "remove_download_source": "Eltávolítás",
"add_download_source": "Forrás hozáadása", "add_download_source": "Forrás hozáadása",
@@ -556,6 +566,7 @@
"show_download_speed_in_megabytes": "Letöltési sebesség megabájt/másodpercben lévő megjelenítése", "show_download_speed_in_megabytes": "Letöltési sebesség megabájt/másodpercben lévő megjelenítése",
"extract_files_by_default": "Fájlok kicsomagolása letöltés után", "extract_files_by_default": "Fájlok kicsomagolása letöltés után",
"enable_steam_achievements": "Steam-achievementek utáni keresés engedélyezése", "enable_steam_achievements": "Steam-achievementek utáni keresés engedélyezése",
"enable_new_download_options_badges": "Új letöltési helyek",
"achievement_custom_notification_position": "Achievement-értesítések egyéni elhelyezése", "achievement_custom_notification_position": "Achievement-értesítések egyéni elhelyezése",
"top-left": "Bal felső sarok", "top-left": "Bal felső sarok",
"top-center": "Felső közép", "top-center": "Felső közép",
@@ -636,9 +647,9 @@
"sort_by": "Rendezés:", "sort_by": "Rendezés:",
"achievements_earned": "Elért achievementek", "achievements_earned": "Elért achievementek",
"played_recently": "Nemrég játszva", "played_recently": "Nemrég játszva",
"playtime": "Játszottidő", "playtime": "Játékidő",
"total_play_time": "Teljes játszottidő", "total_play_time": "Teljes játékidő",
"manual_playtime_tooltip": "Ez a játszottidő manuálisan lett frissítve", "manual_playtime_tooltip": "Ez a játékidő manuálisan lett frissítve",
"no_recent_activity_title": "Hmmm… itt semmi sincs", "no_recent_activity_title": "Hmmm… itt semmi sincs",
"no_recent_activity_description": "Mostanában nem játszottál semmivel. Hát ideje ezt megváltoztatni!", "no_recent_activity_description": "Mostanában nem játszottál semmivel. Hát ideje ezt megváltoztatni!",
"display_name": "Profilnév", "display_name": "Profilnév",
@@ -660,6 +671,7 @@
"sending": "Küldés..", "sending": "Küldés..",
"friend_request_sent": "Barátfelkérés elküldve", "friend_request_sent": "Barátfelkérés elküldve",
"friends": "Barátok", "friends": "Barátok",
"badges": "Kitűzők",
"friends_list": "Barát lista", "friends_list": "Barát lista",
"user_not_found": "Felhasználó nem találva", "user_not_found": "Felhasználó nem találva",
"block_user": "Felhasználó letiltása", "block_user": "Felhasználó letiltása",
@@ -670,12 +682,16 @@
"ignore_request": "Kérés ignorálása", "ignore_request": "Kérés ignorálása",
"cancel_request": "Kérés visszavonása", "cancel_request": "Kérés visszavonása",
"undo_friendship": "Barát eltávolítása", "undo_friendship": "Barát eltávolítása",
"friendship_removed": "Barát eltávolítva",
"request_accepted": "Barátfelkérés elfogadva", "request_accepted": "Barátfelkérés elfogadva",
"user_blocked_successfully": "Felhasználó sikeresen letiltva", "user_blocked_successfully": "Felhasználó sikeresen letiltva",
"user_block_modal_text": "Ez által letiltod őt: {{displayName}}", "user_block_modal_text": "Ez által letiltod őt: {{displayName}}",
"blocked_users": "Letiltott felhasználók", "blocked_users": "Letiltott felhasználók",
"unblock": "Tiltás feloldása", "unblock": "Tiltás feloldása",
"no_friends_added": "Nincs bejelölt barátod", "no_friends_added": "Nincs bejelölt barátod",
"view_all": "Összes megtekintése",
"load_more": "Több betöltése",
"loading": "Töltés..",
"pending": "Függőben", "pending": "Függőben",
"no_pending_invites": "Nincs függőben lévő barátfelkérésed", "no_pending_invites": "Nincs függőben lévő barátfelkérésed",
"no_blocked_users": "Nincs letiltott felhasználó", "no_blocked_users": "Nincs letiltott felhasználó",
@@ -699,6 +715,7 @@
"report_reason_other": "Egyéb", "report_reason_other": "Egyéb",
"profile_reported": "Profil bejelentve", "profile_reported": "Profil bejelentve",
"your_friend_code": "A barát kódod:", "your_friend_code": "A barát kódod:",
"copy_friend_code": "Barátkód kimásolása",
"upload_banner": "Borítókép feltöltése", "upload_banner": "Borítókép feltöltése",
"uploading_banner": "Borítókép feltöltése…", "uploading_banner": "Borítókép feltöltése…",
"background_image_updated": "Borítókép frissítve", "background_image_updated": "Borítókép frissítve",
@@ -718,10 +735,12 @@
"game_added_to_pinned": "Játék hozzáadva a kitűzöttekhez", "game_added_to_pinned": "Játék hozzáadva a kitűzöttekhez",
"karma": "Karma", "karma": "Karma",
"karma_count": "karma", "karma_count": "karma",
"karma_description": "Pozitív értékelésekkel szerzett pontok",
"user_reviews": "Vélemények", "user_reviews": "Vélemények",
"delete_review": "Vélemény Törlése", "delete_review": "Vélemény Törlése",
"loading_reviews": "Vélemények betöltése..." "loading_reviews": "Vélemények betöltése...",
"wrapped_2025": "Wrapped 2025",
"view_my_wrapped_button": "Wrapped 2025 megtekintése",
"view_wrapped_button": "{{displayName}} Wrapped 2025 megtekintése"
}, },
"library": { "library": {
"library": "Könyvtár", "library": "Könyvtár",
@@ -739,7 +758,7 @@
"amount_minutes": "{{amount}} perc", "amount_minutes": "{{amount}} perc",
"amount_hours_short": "{{amount}}ó", "amount_hours_short": "{{amount}}ó",
"amount_minutes_short": "{{amount}}p", "amount_minutes_short": "{{amount}}p",
"manual_playtime_tooltip": "Ez a játszottidő manuálisan lett frissítve", "manual_playtime_tooltip": "Ez a játékidő manuálisan lett frissítve",
"all_games": "Összes Játék", "all_games": "Összes Játék",
"recently_played": "Nemrég Játszva", "recently_played": "Nemrég Játszva",
"favorites": "Kedvencek" "favorites": "Kedvencek"
@@ -772,5 +791,41 @@
"hydra_cloud_feature_found": "Épp felfedeztél egy Hydra Cloud funkciót!", "hydra_cloud_feature_found": "Épp felfedeztél egy Hydra Cloud funkciót!",
"learn_more": "Tudj meg többet", "learn_more": "Tudj meg többet",
"debrid_description": "Akár 4x gyorsabb letöltés a Nimbusszal" "debrid_description": "Akár 4x gyorsabb letöltés a Nimbusszal"
},
"notifications_page": {
"title": "Értesítések",
"mark_all_as_read": "Megjelölés olvasottként",
"clear_all": "Összes Törlése",
"loading": "Töltés..",
"empty_title": "Nincsenek értesítések",
"empty_description": "Már mindet láttad! Nézz vissza később az újdonságokért.",
"empty_filter_description": "Nincs értesítés ami megfelel ennek a szűrőnek.",
"filter_all": "Összes",
"filter_unread": "Olvasatlan",
"filter_friends": "Barátok",
"filter_badges": "Kitűzők",
"filter_upvotes": "Felpontok",
"filter_local": "Helyi",
"load_more": "Több betöltése",
"dismiss": "Eltüntetés",
"accept": "Elfogad",
"refuse": "Elutasít",
"notification": "Értesítés",
"friend_request_received_title": "Új barátkérelem!",
"friend_request_received_description": "{{displayName}} a barátod szeretne lenni",
"friend_request_accepted_title": "Barátkérelem elfogadva!",
"friend_request_accepted_description": "{{displayName}} elfogadta a barátkérelmed",
"badge_received_title": "Kaptál egy új kitűzőt!",
"badge_received_description": "{{badgeName}}",
"review_upvote_title": "A véleményed a(z) {{gameTitle}} játékhoz felpont-ot kapott!",
"review_upvote_description": "A véleményed {{count}} új felpontot kapott",
"marked_all_as_read": "Összes értesítés olvasottnak jelölve",
"failed_to_mark_as_read": "Az értesítések olvasottnak jelölése nem sikerült",
"cleared_all": "Összes értesítés eltüntetve",
"failed_to_clear": "Az értesítések eltüntetése nem sikerült",
"failed_to_load": "Az értesítések betöltése nem sikerült",
"failed_to_dismiss": "Értesítés eltüntetése nem sikerült",
"friend_request_accepted": "Barátfelkérés elfogadva",
"friend_request_refused": "Barátfelkérés elutasítva"
} }
} }

View File

@@ -673,8 +673,7 @@
"game_removed_from_pinned": "Spēle dzēsta no piespraustajiem", "game_removed_from_pinned": "Spēle dzēsta no piespraustajiem",
"game_added_to_pinned": "Spēle pievienota piespraustajiem", "game_added_to_pinned": "Spēle pievienota piespraustajiem",
"karma": "Karma", "karma": "Karma",
"karma_count": "karma", "karma_count": "karma"
"karma_description": "Nopelnīta ar pozitīviem atsauksmju vērtējumiem"
}, },
"achievement": { "achievement": {
"achievement_unlocked": "Sasniegums atbloķēts", "achievement_unlocked": "Sasniegums atbloķēts",

View File

@@ -172,6 +172,12 @@
"open_screenshot": "Ver captura de tela {{number}}", "open_screenshot": "Ver captura de tela {{number}}",
"download_settings": "Ajustes do download", "download_settings": "Ajustes do download",
"downloader": "Downloader", "downloader": "Downloader",
"downloader_online": "Online",
"downloader_not_configured": "Disponível mas não configurado",
"downloader_offline": "Link está offline",
"downloader_not_available": "Não disponível",
"recommended": "Recomendado",
"go_to_settings": "Ir para Configurações",
"select_executable": "Explorar", "select_executable": "Explorar",
"no_executable_selected": "Nenhum executável selecionado", "no_executable_selected": "Nenhum executável selecionado",
"open_folder": "Abrir pasta", "open_folder": "Abrir pasta",
@@ -654,6 +660,7 @@
"see_profile": "Ver perfil", "see_profile": "Ver perfil",
"friend_request_sent": "Pedido de amizade enviado", "friend_request_sent": "Pedido de amizade enviado",
"friends": "Amigos", "friends": "Amigos",
"badges": "Insígnias",
"add": "Adicionar", "add": "Adicionar",
"sending": "Enviando", "sending": "Enviando",
"friends_list": "Lista de amigos", "friends_list": "Lista de amigos",
@@ -666,12 +673,16 @@
"ignore_request": "Ignorar pedido", "ignore_request": "Ignorar pedido",
"cancel_request": "Cancelar pedido", "cancel_request": "Cancelar pedido",
"undo_friendship": "Desfazer amizade", "undo_friendship": "Desfazer amizade",
"friendship_removed": "Amigo removido",
"request_accepted": "Pedido de amizade aceito", "request_accepted": "Pedido de amizade aceito",
"user_blocked_successfully": "Usuário bloqueado com sucesso", "user_blocked_successfully": "Usuário bloqueado com sucesso",
"user_block_modal_text": "Bloquear {{displayName}}", "user_block_modal_text": "Bloquear {{displayName}}",
"blocked_users": "Usuários bloqueados", "blocked_users": "Usuários bloqueados",
"unblock": "Desbloquear", "unblock": "Desbloquear",
"no_friends_added": "Você ainda não possui amigos adicionados", "no_friends_added": "Você ainda não possui amigos adicionados",
"view_all": "Ver todos",
"load_more": "Carregar mais",
"loading": "Carregando",
"pending": "Pendentes", "pending": "Pendentes",
"no_pending_invites": "Você não possui convites de amizade pendentes", "no_pending_invites": "Você não possui convites de amizade pendentes",
"no_blocked_users": "Você não tem nenhum usuário bloqueado", "no_blocked_users": "Você não tem nenhum usuário bloqueado",
@@ -695,8 +706,16 @@
"report_reason_other": "Outro", "report_reason_other": "Outro",
"profile_reported": "Perfil reportado", "profile_reported": "Perfil reportado",
"your_friend_code": "Seu código de amigo:", "your_friend_code": "Seu código de amigo:",
"copy_friend_code": "Copiar código de amigo",
"copied": "Copiado!",
"upload_banner": "Carregar banner", "upload_banner": "Carregar banner",
"uploading_banner": "Carregando banner…", "uploading_banner": "Carregando banner…",
"change_banner": "Alterar banner",
"replace_banner": "Substituir banner",
"remove_banner": "Remover banner",
"remove_banner_modal_title": "Remover banner?",
"remove_banner_confirmation": "Tem certeza de que deseja remover seu banner? Você sempre pode escolher um novo quando quiser.",
"remove": "Remover",
"background_image_updated": "Imagem de fundo salva", "background_image_updated": "Imagem de fundo salva",
"stats": "Estatísticas", "stats": "Estatísticas",
"achievements": "conquistas", "achievements": "conquistas",
@@ -720,10 +739,10 @@
"achievements_earned": "Conquistas recebidas", "achievements_earned": "Conquistas recebidas",
"karma": "Karma", "karma": "Karma",
"karma_count": "karma", "karma_count": "karma",
"karma_description": "Ganho a partir de curtidas positivas em avaliações",
"manual_playtime_tooltip": "Este tempo de jogo foi atualizado manualmente", "manual_playtime_tooltip": "Este tempo de jogo foi atualizado manualmente",
"user_reviews": "Avaliações", "user_reviews": "Avaliações",
"loading_reviews": "Carregando avaliações...", "loading_reviews": "Carregando avaliações...",
"wrapped_2025": "Wrapped 2025",
"no_reviews": "Ainda não há avaliações", "no_reviews": "Ainda não há avaliações",
"delete_review": "Excluir avaliação" "delete_review": "Excluir avaliação"
}, },
@@ -776,5 +795,41 @@
"all_games": "Todos os Jogos", "all_games": "Todos os Jogos",
"recently_played": "Jogados Recentemente", "recently_played": "Jogados Recentemente",
"favorites": "Favoritos" "favorites": "Favoritos"
},
"notifications_page": {
"title": "Notificações",
"mark_all_as_read": "Marcar todas como lidas",
"clear_all": "Limpar todas",
"loading": "Carregando...",
"empty_title": "Sem notificações",
"empty_description": "Você está em dia! Volte mais tarde para ver novas atualizações.",
"empty_filter_description": "Nenhuma notificação corresponde a este filtro.",
"filter_all": "Todas",
"filter_unread": "Não lidas",
"filter_friends": "Amigos",
"filter_badges": "Insígnias",
"filter_upvotes": "Votos",
"filter_local": "Locais",
"load_more": "Carregar mais",
"dismiss": "Descartar",
"accept": "Aceitar",
"refuse": "Recusar",
"notification": "Notificação",
"friend_request_received_title": "Nova solicitação de amizade!",
"friend_request_received_description": "{{displayName}} quer ser seu amigo",
"friend_request_accepted_title": "Solicitação de amizade aceita!",
"friend_request_accepted_description": "{{displayName}} aceitou sua solicitação de amizade",
"badge_received_title": "Você recebeu uma nova insígnia!",
"badge_received_description": "{{badgeName}}",
"review_upvote_title": "Sua avaliação de {{gameTitle}} recebeu votos!",
"review_upvote_description": "Sua avaliação recebeu {{count}} novos votos",
"marked_all_as_read": "Todas as notificações marcadas como lidas",
"failed_to_mark_as_read": "Falha ao marcar notificações como lidas",
"cleared_all": "Todas as notificações limpas",
"failed_to_clear": "Falha ao limpar notificações",
"failed_to_load": "Falha ao carregar notificações",
"failed_to_dismiss": "Falha ao descartar notificação",
"friend_request_accepted": "Solicitação de amizade aceita",
"friend_request_refused": "Solicitação de amizade recusada"
} }
} }

View File

@@ -508,7 +508,7 @@
"show_and_compare_achievements": "Mostra e compara as tuas conquistas com as de outros utilizadores", "show_and_compare_achievements": "Mostra e compara as tuas conquistas com as de outros utilizadores",
"animated_profile_banner": "Banner animado no perfil", "animated_profile_banner": "Banner animado no perfil",
"cloud_saving": "Progresso dos jogos na nuvem", "cloud_saving": "Progresso dos jogos na nuvem",
"hydra_cloud_feature_found": "Descubriste uma funcionalidade Hydra Cloud!", "hydra_cloud_feature_found": "Descobriste uma funcionalidade Hydra Cloud!",
"learn_more": "Saber mais" "learn_more": "Saber mais"
} }
} }

View File

@@ -182,6 +182,12 @@
"open_screenshot": "Открыть скриншот {{number}}", "open_screenshot": "Открыть скриншот {{number}}",
"download_settings": "Параметры загрузки", "download_settings": "Параметры загрузки",
"downloader": "Загрузчик", "downloader": "Загрузчик",
"downloader_online": "Онлайн",
"downloader_not_configured": "Доступен, но не настроен",
"downloader_offline": "Ссылка недоступна",
"downloader_not_available": "Недоступно",
"recommended": "Рекомендуется",
"go_to_settings": "Перейти в настройки",
"select_executable": "Выбрать", "select_executable": "Выбрать",
"no_executable_selected": "Файл не выбран", "no_executable_selected": "Файл не выбран",
"open_folder": "Открыть папку", "open_folder": "Открыть папку",
@@ -651,6 +657,7 @@
"sending": "Отправка", "sending": "Отправка",
"friend_request_sent": "Запрос в друзья отправлен", "friend_request_sent": "Запрос в друзья отправлен",
"friends": "Друзья", "friends": "Друзья",
"badges": "Значки",
"friends_list": "Список друзей", "friends_list": "Список друзей",
"user_not_found": "Пользователь не найден", "user_not_found": "Пользователь не найден",
"block_user": "Заблокировать пользователя", "block_user": "Заблокировать пользователя",
@@ -661,12 +668,16 @@
"ignore_request": "Игнорировать запрос", "ignore_request": "Игнорировать запрос",
"cancel_request": "Отменить запрос", "cancel_request": "Отменить запрос",
"undo_friendship": "Удалить друга", "undo_friendship": "Удалить друга",
"friendship_removed": "Друг удален",
"request_accepted": "Запрос принят", "request_accepted": "Запрос принят",
"user_blocked_successfully": "Пользователь успешно заблокирован", "user_blocked_successfully": "Пользователь успешно заблокирован",
"user_block_modal_text": "{{displayName}} будет заблокирован", "user_block_modal_text": "{{displayName}} будет заблокирован",
"blocked_users": "Заблокированные пользователи", "blocked_users": "Заблокированные пользователи",
"unblock": "Разблокировать", "unblock": "Разблокировать",
"no_friends_added": "Вы ещё не добавили ни одного друга", "no_friends_added": "Вы ещё не добавили ни одного друга",
"view_all": "Показать все",
"load_more": "Загрузить еще",
"loading": "Загрузка",
"pending": "Ожидание", "pending": "Ожидание",
"no_pending_invites": "У вас нет запросов ожидающих ответа", "no_pending_invites": "У вас нет запросов ожидающих ответа",
"no_blocked_users": "Вы не заблокировали ни одного пользователя", "no_blocked_users": "Вы не заблокировали ни одного пользователя",
@@ -690,8 +701,16 @@
"report_reason_other": "Другое", "report_reason_other": "Другое",
"profile_reported": "Жалоба на профиль отправлена", "profile_reported": "Жалоба на профиль отправлена",
"your_friend_code": "Код вашего друга:", "your_friend_code": "Код вашего друга:",
"copy_friend_code": "Копировать код друга",
"copied": "Скопировано!",
"upload_banner": "Загрузить баннер", "upload_banner": "Загрузить баннер",
"uploading_banner": "Загрузка баннера...", "uploading_banner": "Загрузка баннера...",
"change_banner": "Изменить баннер",
"replace_banner": "Заменить баннер",
"remove_banner": "Удалить баннер",
"remove_banner_modal_title": "Удалить баннер?",
"remove_banner_confirmation": "Вы уверены, что хотите удалить свой баннер? Вы всегда можете выбрать новый, когда захотите.",
"remove": "Удалить",
"background_image_updated": "Фоновое изображение обновлено", "background_image_updated": "Фоновое изображение обновлено",
"stats": "Статистика", "stats": "Статистика",
"achievements": "Достижения", "achievements": "Достижения",
@@ -709,9 +728,9 @@
"game_added_to_pinned": "Игра добавлена в закрепленные", "game_added_to_pinned": "Игра добавлена в закрепленные",
"karma": "Карма", "karma": "Карма",
"karma_count": "карма", "karma_count": "карма",
"karma_description": "Заработана положительными оценками отзывов",
"user_reviews": "Отзывы", "user_reviews": "Отзывы",
"loading_reviews": "Загрузка отзывов...", "loading_reviews": "Загрузка отзывов...",
"wrapped_2025": "Wrapped 2025",
"no_reviews": "Пока нет отзывов", "no_reviews": "Пока нет отзывов",
"delete_review": "Удалить отзыв" "delete_review": "Удалить отзыв"
}, },
@@ -764,5 +783,41 @@
"all_games": "Все игры", "all_games": "Все игры",
"recently_played": "Недавно сыгранные", "recently_played": "Недавно сыгранные",
"favorites": "Избранное" "favorites": "Избранное"
},
"notifications_page": {
"title": "Уведомления",
"mark_all_as_read": "Отметить все как прочитанные",
"clear_all": "Очистить все",
"loading": "Загрузка...",
"empty_title": "Нет уведомлений",
"empty_description": "Вы в курсе всех событий! Загляните позже за новыми обновлениями.",
"empty_filter_description": "Нет уведомлений, соответствующих этому фильтру.",
"filter_all": "Все",
"filter_unread": "Непрочитанные",
"filter_friends": "Друзья",
"filter_badges": "Значки",
"filter_upvotes": "Голоса",
"filter_local": "Локальные",
"load_more": "Загрузить еще",
"dismiss": "Отклонить",
"accept": "Принять",
"refuse": "Отклонить",
"notification": "Уведомление",
"friend_request_received_title": "Новый запрос в друзья!",
"friend_request_received_description": "{{displayName}} хочет добавить вас в друзья",
"friend_request_accepted_title": "Запрос в друзья принят!",
"friend_request_accepted_description": "{{displayName}} принял ваш запрос в друзья",
"badge_received_title": "Вы получили новый значок!",
"badge_received_description": "{{badgeName}}",
"review_upvote_title": "Ваш отзыв на {{gameTitle}} получил голоса!",
"review_upvote_description": "Ваш отзыв получил {{count}} новых голосов",
"marked_all_as_read": "Все уведомления отмечены как прочитанные",
"failed_to_mark_as_read": "Не удалось отметить уведомления как прочитанные",
"cleared_all": "Все уведомления очищены",
"failed_to_clear": "Не удалось очистить уведомления",
"failed_to_load": "Не удалось загрузить уведомления",
"failed_to_dismiss": "Не удалось отклонить уведомление",
"friend_request_accepted": "Запрос в друзья принят",
"friend_request_refused": "Запрос в друзья отклонен"
} }
} }

View File

@@ -706,7 +706,6 @@
"game_added_to_pinned": "Oyun sabitlenmişlere eklendi", "game_added_to_pinned": "Oyun sabitlenmişlere eklendi",
"karma": "Karma", "karma": "Karma",
"karma_count": "karma", "karma_count": "karma",
"karma_description": "İncelemelerdeki olumlu beğenilerden kazanılır",
"user_reviews": "İncelemeler", "user_reviews": "İncelemeler",
"delete_review": "İncelemeyi Sil", "delete_review": "İncelemeyi Sil",
"loading_reviews": "İncelemeler yükleniyor..." "loading_reviews": "İncelemeler yükleniyor..."

View File

@@ -668,8 +668,7 @@
"game_removed_from_pinned": "Гру видалено із закріплених", "game_removed_from_pinned": "Гру видалено із закріплених",
"game_added_to_pinned": "Гру додано до закріплених", "game_added_to_pinned": "Гру додано до закріплених",
"karma": "Карма", "karma": "Карма",
"karma_count": "карма", "karma_count": "карма"
"karma_description": "Зароблена позитивними оцінками на відгуках"
}, },
"achievement": { "achievement": {
"achievement_unlocked": "Досягнення розблоковано", "achievement_unlocked": "Досягнення розблоковано",

View File

@@ -689,7 +689,6 @@
"game_removed_from_pinned": "游戏已从置顶移除", "game_removed_from_pinned": "游戏已从置顶移除",
"karma": "业力", "karma": "业力",
"karma_count": "业力值", "karma_count": "业力值",
"karma_description": "通过评论获得的点赞",
"loading_reviews": "正在加载评价...", "loading_reviews": "正在加载评价...",
"manual_playtime_tooltip": "该游戏时长已手动更新", "manual_playtime_tooltip": "该游戏时长已手动更新",
"pinned": "已置顶", "pinned": "已置顶",

View File

@@ -0,0 +1,59 @@
import path from "node:path";
import fs from "node:fs";
import { getDownloadsPath } from "../helpers/get-downloads-path";
import { registerEvent } from "../register-event";
import { downloadsSublevel, levelKeys } from "@main/level";
import { GameShop } from "@types";
const getGameInstallerActionType = async (
_event: Electron.IpcMainInvokeEvent,
shop: GameShop,
objectId: string
): Promise<"install" | "open-folder"> => {
const downloadKey = levelKeys.game(shop, objectId);
const download = await downloadsSublevel.get(downloadKey);
if (!download?.folderName) return "open-folder";
const gamePath = path.join(
download.downloadPath ?? (await getDownloadsPath()),
download.folderName
);
if (!fs.existsSync(gamePath)) {
await downloadsSublevel.del(downloadKey);
return "open-folder";
}
// macOS always opens folder
if (process.platform === "darwin") {
return "open-folder";
}
// If path is a file, it will show in folder (open-folder behavior)
if (fs.lstatSync(gamePath).isFile()) {
return "open-folder";
}
// Check for setup.exe
const setupPath = path.join(gamePath, "setup.exe");
if (fs.existsSync(setupPath)) {
return "install";
}
// Check if there's exactly one .exe file
const gamePathFileNames = fs.readdirSync(gamePath);
const gamePathExecutableFiles = gamePathFileNames.filter(
(fileName: string) => path.extname(fileName).toLowerCase() === ".exe"
);
if (gamePathExecutableFiles.length === 1) {
return "install";
}
// Otherwise, opens folder
return "open-folder";
};
registerEvent("getGameInstallerActionType", getGameInstallerActionType);

View File

@@ -13,6 +13,7 @@ import "./delete-game-folder";
import "./extract-game-download"; import "./extract-game-download";
import "./get-default-wine-prefix-selection-path"; import "./get-default-wine-prefix-selection-path";
import "./get-game-by-object-id"; import "./get-game-by-object-id";
import "./get-game-installer-action-type";
import "./get-library"; import "./get-library";
import "./open-game-executable-path"; import "./open-game-executable-path";
import "./open-game-installer-path"; import "./open-game-installer-path";

View File

@@ -51,22 +51,30 @@ const updateProfile = async (
"backgroundImageUrl", "backgroundImageUrl",
]); ]);
if (updateProfile.profileImageUrl) { if (updateProfile.profileImageUrl !== undefined) {
const profileImageUrl = await uploadImage( if (updateProfile.profileImageUrl === null) {
"profile-image", payload["profileImageUrl"] = null;
updateProfile.profileImageUrl } else {
).catch(() => undefined); const profileImageUrl = await uploadImage(
"profile-image",
updateProfile.profileImageUrl
).catch(() => undefined);
payload["profileImageUrl"] = profileImageUrl; payload["profileImageUrl"] = profileImageUrl;
}
} }
if (updateProfile.backgroundImageUrl) { if (updateProfile.backgroundImageUrl !== undefined) {
const backgroundImageUrl = await uploadImage( if (updateProfile.backgroundImageUrl === null) {
"background-image", payload["backgroundImageUrl"] = null;
updateProfile.backgroundImageUrl } else {
).catch(() => undefined); const backgroundImageUrl = await uploadImage(
"background-image",
updateProfile.backgroundImageUrl
).catch(() => undefined);
payload["backgroundImageUrl"] = backgroundImageUrl; payload["backgroundImageUrl"] = backgroundImageUrl;
}
} }
return patchUserProfile(payload); return patchUserProfile(payload);

View File

@@ -2,7 +2,7 @@ import { downloadsSublevel } from "./level/sublevels/downloads";
import { orderBy } 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 { Download, UserPreferences } from "@types";
import { import {
SystemPath, SystemPath,
CommonRedistManager, CommonRedistManager,
@@ -18,6 +18,7 @@ import {
DeckyPlugin, DeckyPlugin,
DownloadSourcesChecker, DownloadSourcesChecker,
WSClient, WSClient,
logger,
} from "@main/services"; } from "@main/services";
import { migrateDownloadSources } from "./helpers/migrate-download-sources"; import { migrateDownloadSources } from "./helpers/migrate-download-sources";
@@ -71,18 +72,47 @@ export const loadState = async () => {
return orderBy(games, "timestamp", "desc"); return orderBy(games, "timestamp", "desc");
}); });
downloads.forEach((download) => { let interruptedDownload: Download | null = null;
for (const download of downloads) {
const downloadKey = levelKeys.game(download.shop, download.objectId);
// Reset extracting state
if (download.extracting) { if (download.extracting) {
downloadsSublevel.put(levelKeys.game(download.shop, download.objectId), { await downloadsSublevel.put(downloadKey, {
...download, ...download,
extracting: false, extracting: false,
}); });
} }
});
const [nextItemOnQueue] = downloads.filter((game) => game.queued); // Find interrupted active download (download that was running when app closed)
// Mark it as paused but remember it for auto-resume
if (download.status === "active" && !interruptedDownload) {
interruptedDownload = download;
await downloadsSublevel.put(downloadKey, {
...download,
status: "paused",
});
} else if (download.status === "active") {
// Mark other active downloads as paused
await downloadsSublevel.put(downloadKey, {
...download,
status: "paused",
});
}
}
const downloadsToSeed = downloads.filter( // Re-fetch downloads after status updates
const updatedDownloads = await downloadsSublevel
.values()
.all()
.then((games) => orderBy(games, "timestamp", "desc"));
// Prioritize interrupted download, then queued downloads
const downloadToResume =
interruptedDownload ?? updatedDownloads.find((game) => game.queued);
const downloadsToSeed = updatedDownloads.filter(
(game) => (game) =>
game.shouldSeed && game.shouldSeed &&
game.downloader === Downloader.Torrent && game.downloader === Downloader.Torrent &&
@@ -90,7 +120,23 @@ export const loadState = async () => {
game.uri !== null game.uri !== null
); );
await DownloadManager.startRPC(nextItemOnQueue, downloadsToSeed); // For torrents or if JS downloader is disabled, use Python RPC
const isTorrent = downloadToResume?.downloader === Downloader.Torrent;
// Default to true - native HTTP downloader is enabled by default
const useJsDownloader =
(userPreferences?.useNativeHttpDownloader ?? true) && !isTorrent;
if (useJsDownloader && downloadToResume) {
// Start Python RPC for seeding only, then resume HTTP download with JS
await DownloadManager.startRPC(undefined, downloadsToSeed);
await DownloadManager.startDownload(downloadToResume).catch((err) => {
// If resume fails, just log it - user can manually retry
logger.error("Failed to auto-resume download:", err);
});
} else {
// Use Python RPC for everything (torrent or fallback)
await DownloadManager.startRPC(downloadToResume, downloadsToSeed);
}
startMainLoop(); startMainLoop();

View File

@@ -46,7 +46,7 @@ export class SevenZip {
onProgress?: (progress: ExtractionProgress) => void onProgress?: (progress: ExtractionProgress) => void
): Promise<ExtractionResult> { ): Promise<ExtractionResult> {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const tryPassword = (index = -1) => { const tryPassword = (index = 0) => {
const password = passwords[index] ?? ""; const password = passwords[index] ?? "";
logger.info( logger.info(
`Trying password "${password || "(empty)"}" on ${filePath}` `Trying password "${password || "(empty)"}" on ${filePath}`
@@ -115,7 +115,7 @@ export class SevenZip {
}); });
}; };
tryPassword(); tryPassword(0);
}); });
} }

View File

@@ -4,10 +4,11 @@ import { publishDownloadCompleteNotification } from "../notifications";
import type { Download, DownloadProgress, UserPreferences } from "@types"; import type { Download, DownloadProgress, UserPreferences } from "@types";
import { import {
GofileApi, GofileApi,
QiwiApi,
DatanodesApi, DatanodesApi,
MediafireApi, MediafireApi,
PixelDrainApi, PixelDrainApi,
VikingFileApi,
RootzApi,
} from "../hosters"; } from "../hosters";
import { PythonRPC } from "../python-rpc"; import { PythonRPC } from "../python-rpc";
import { import {
@@ -17,7 +18,7 @@ import {
} from "./types"; } from "./types";
import { calculateETA, getDirSize } from "./helpers"; import { calculateETA, getDirSize } from "./helpers";
import { RealDebridClient } from "./real-debrid"; import { RealDebridClient } from "./real-debrid";
import path from "path"; import path from "node: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 { sortBy } from "lodash-es";
@@ -25,9 +26,13 @@ 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";
import { BuzzheavierApi, FuckingFastApi } from "@main/services/hosters"; import { BuzzheavierApi, FuckingFastApi } from "@main/services/hosters";
import { JsHttpDownloader } from "./js-http-downloader";
export class DownloadManager { export class DownloadManager {
private static downloadingGameId: string | null = null; private static downloadingGameId: string | null = null;
private static jsDownloader: JsHttpDownloader | null = null;
private static usingJsDownloader = false;
private static isPreparingDownload = false;
private static extractFilename( private static extractFilename(
url: string, url: string,
@@ -51,7 +56,7 @@ export class DownloadManager {
const urlObj = new URL(url); const urlObj = new URL(url);
const pathname = urlObj.pathname; const pathname = urlObj.pathname;
const pathParts = pathname.split("/"); const pathParts = pathname.split("/");
const filename = pathParts[pathParts.length - 1]; const filename = pathParts.at(-1);
if (filename?.includes(".") && filename.length > 0) { if (filename?.includes(".") && filename.length > 0) {
return decodeURIComponent(filename); return decodeURIComponent(filename);
@@ -67,6 +72,34 @@ export class DownloadManager {
return filename.replaceAll(/[<>:"/\\|?*]/g, "_"); return filename.replaceAll(/[<>:"/\\|?*]/g, "_");
} }
private static resolveFilename(
resumingFilename: string | undefined,
originalUrl: string,
downloadUrl: string
): string | undefined {
if (resumingFilename) return resumingFilename;
const extracted =
this.extractFilename(originalUrl, downloadUrl) ||
this.extractFilename(downloadUrl);
return extracted ? this.sanitizeFilename(extracted) : undefined;
}
private static buildDownloadOptions(
url: string,
savePath: string,
filename: string | undefined,
headers?: Record<string, string>
) {
return {
url,
savePath,
filename,
headers,
};
}
private static createDownloadPayload( private static createDownloadPayload(
directUrl: string, directUrl: string,
originalUrl: string, originalUrl: string,
@@ -98,6 +131,19 @@ export class DownloadManager {
}; };
} }
private static async shouldUseJsDownloader(): Promise<boolean> {
const userPreferences = await db.get<string, UserPreferences | null>(
levelKeys.userPreferences,
{ valueEncoding: "json" }
);
// Default to true - native HTTP downloader is enabled by default (opt-out)
return userPreferences?.useNativeHttpDownloader ?? true;
}
private static isHttpDownloader(downloader: Downloader): boolean {
return downloader !== Downloader.Torrent;
}
public static async startRPC( public static async startRPC(
download?: Download, download?: Download,
downloadsToSeed?: Download[] downloadsToSeed?: Download[]
@@ -122,7 +168,87 @@ export class DownloadManager {
} }
} }
private static async getDownloadStatus() { private static async getDownloadStatusFromJs(): Promise<DownloadProgress | null> {
if (!this.downloadingGameId) return null;
const downloadId = this.downloadingGameId;
// Return a "preparing" status while fetching download options
if (this.isPreparingDownload) {
try {
const download = await downloadsSublevel.get(downloadId);
if (!download) return null;
return {
numPeers: 0,
numSeeds: 0,
downloadSpeed: 0,
timeRemaining: -1,
isDownloadingMetadata: true, // Use this to indicate "preparing"
isCheckingFiles: false,
progress: 0,
gameId: downloadId,
download,
};
} catch {
return null;
}
}
if (!this.jsDownloader) return null;
const status = this.jsDownloader.getDownloadStatus();
if (!status) return null;
try {
const download = await downloadsSublevel.get(downloadId);
if (!download) return null;
const { progress, downloadSpeed, bytesDownloaded, fileSize, folderName } =
status;
// Only update fileSize in database if we actually know it (> 0)
// Otherwise keep the existing value to avoid showing "0 B"
const effectiveFileSize = fileSize > 0 ? fileSize : download.fileSize;
const updatedDownload = {
...download,
bytesDownloaded,
fileSize: effectiveFileSize,
progress,
folderName,
status:
status.status === "complete"
? ("complete" as const)
: ("active" as const),
};
if (status.status === "active" || status.status === "complete") {
await downloadsSublevel.put(downloadId, updatedDownload);
}
return {
numPeers: 0,
numSeeds: 0,
downloadSpeed,
timeRemaining: calculateETA(
effectiveFileSize ?? 0,
bytesDownloaded,
downloadSpeed
),
isDownloadingMetadata: false,
isCheckingFiles: false,
progress,
gameId: downloadId,
download: updatedDownload,
};
} catch (err) {
logger.error("[DownloadManager] Error getting JS download status:", err);
return null;
}
}
private static async getDownloadStatusFromRpc(): Promise<DownloadProgress | null> {
const response = await PythonRPC.rpc.get<LibtorrentPayload | null>( const response = await PythonRPC.rpc.get<LibtorrentPayload | null>(
"/status" "/status"
); );
@@ -171,105 +297,141 @@ export class DownloadManager {
gameId: downloadId, gameId: downloadId,
download, download,
} as DownloadProgress; } as DownloadProgress;
} catch (err) { } catch {
return null; return null;
} }
} }
private static async getDownloadStatus(): Promise<DownloadProgress | null> {
if (this.usingJsDownloader) {
return this.getDownloadStatusFromJs();
}
return this.getDownloadStatusFromRpc();
}
public static async watchDownloads() { public static async watchDownloads() {
const status = await this.getDownloadStatus(); const status = await this.getDownloadStatus();
if (!status) return;
if (status) { const { gameId, progress } = status;
const { gameId, progress } = status; const [download, game] = await Promise.all([
downloadsSublevel.get(gameId),
gamesSublevel.get(gameId),
]);
const [download, game] = await Promise.all([ if (!download || !game) return;
downloadsSublevel.get(gameId),
gamesSublevel.get(gameId),
]);
if (!download || !game) return; this.sendProgressUpdate(progress, status, game);
const userPreferences = await db.get<string, UserPreferences | null>( if (progress === 1) {
levelKeys.userPreferences, await this.handleDownloadCompletion(download, game, gameId);
{ valueEncoding: "json" } }
}
private static sendProgressUpdate(
progress: number,
status: DownloadProgress,
game: any
) {
if (WindowManager.mainWindow) {
WindowManager.mainWindow.setProgressBar(progress === 1 ? -1 : progress);
WindowManager.mainWindow.webContents.send(
"on-download-progress",
structuredClone({ ...status, game })
);
}
}
private static async handleDownloadCompletion(
download: Download,
game: any,
gameId: string
) {
publishDownloadCompleteNotification(game);
const userPreferences = await db.get<string, UserPreferences | null>(
levelKeys.userPreferences,
{ valueEncoding: "json" }
);
await this.updateDownloadStatus(
download,
gameId,
userPreferences?.seedAfterDownloadComplete
);
if (download.automaticallyExtract) {
this.handleExtraction(download, game);
}
await this.processNextQueuedDownload();
}
private static async updateDownloadStatus(
download: Download,
gameId: string,
shouldSeed?: boolean
) {
const shouldExtract = download.automaticallyExtract;
if (shouldSeed && download.downloader === Downloader.Torrent) {
await downloadsSublevel.put(gameId, {
...download,
status: "seeding",
shouldSeed: true,
queued: false,
extracting: shouldExtract,
});
} else {
await downloadsSublevel.put(gameId, {
...download,
status: "complete",
shouldSeed: false,
queued: false,
extracting: shouldExtract,
});
this.cancelDownload(gameId);
}
}
private static handleExtraction(download: Download, game: any) {
const gameFilesManager = new GameFilesManager(game.shop, game.objectId);
if (
FILE_EXTENSIONS_TO_EXTRACT.some((ext) =>
download.folderName?.endsWith(ext)
)
) {
gameFilesManager.extractDownloadedFile();
} else if (download.folderName) {
gameFilesManager
.extractFilesInDirectory(
path.join(download.downloadPath, download.folderName)
)
.then(() => gameFilesManager.setExtractionComplete());
}
}
private static async processNextQueuedDownload() {
const downloads = await downloadsSublevel
.values()
.all()
.then((games) =>
sortBy(
games.filter((game) => game.status === "paused" && game.queued),
"timestamp",
"DESC"
)
); );
if (WindowManager.mainWindow && download) { const [nextItemOnQueue] = downloads;
WindowManager.mainWindow.setProgressBar(progress === 1 ? -1 : progress);
WindowManager.mainWindow.webContents.send(
"on-download-progress",
JSON.parse(JSON.stringify({ ...status, game }))
);
}
const shouldExtract = download.automaticallyExtract; if (nextItemOnQueue) {
this.resumeDownload(nextItemOnQueue);
if (progress === 1 && download) { } else {
publishDownloadCompleteNotification(game); this.downloadingGameId = null;
this.usingJsDownloader = false;
if ( this.jsDownloader = null;
userPreferences?.seedAfterDownloadComplete &&
download.downloader === Downloader.Torrent
) {
await downloadsSublevel.put(gameId, {
...download,
status: "seeding",
shouldSeed: true,
queued: false,
extracting: shouldExtract,
});
} else {
await downloadsSublevel.put(gameId, {
...download,
status: "complete",
shouldSeed: false,
queued: false,
extracting: shouldExtract,
});
this.cancelDownload(gameId);
}
if (shouldExtract) {
const gameFilesManager = new GameFilesManager(
game.shop,
game.objectId
);
if (
FILE_EXTENSIONS_TO_EXTRACT.some((ext) =>
download.folderName?.endsWith(ext)
)
) {
gameFilesManager.extractDownloadedFile();
} else if (download.folderName) {
gameFilesManager
.extractFilesInDirectory(
path.join(download.downloadPath, download.folderName)
)
.then(() => gameFilesManager.setExtractionComplete());
}
}
const downloads = await downloadsSublevel
.values()
.all()
.then((games) =>
sortBy(
games.filter((game) => game.status === "paused" && game.queued),
"timestamp",
"DESC"
)
);
const [nextItemOnQueue] = downloads;
if (nextItemOnQueue) {
this.resumeDownload(nextItemOnQueue);
} else {
this.downloadingGameId = null;
}
}
} }
} }
@@ -309,12 +471,17 @@ export class DownloadManager {
} }
static async pauseDownload(downloadKey = this.downloadingGameId) { static async pauseDownload(downloadKey = this.downloadingGameId) {
await PythonRPC.rpc if (this.usingJsDownloader && this.jsDownloader) {
.post("/action", { logger.log("[DownloadManager] Pausing JS download");
action: "pause", this.jsDownloader.pauseDownload();
game_id: downloadKey, } else {
} as PauseDownloadPayload) await PythonRPC.rpc
.catch(() => {}); .post("/action", {
action: "pause",
game_id: downloadKey,
} as PauseDownloadPayload)
.catch(() => {});
}
if (downloadKey === this.downloadingGameId) { if (downloadKey === this.downloadingGameId) {
WindowManager.mainWindow?.setProgressBar(-1); WindowManager.mainWindow?.setProgressBar(-1);
@@ -327,14 +494,23 @@ export class DownloadManager {
} }
static async cancelDownload(downloadKey = this.downloadingGameId) { static async cancelDownload(downloadKey = this.downloadingGameId) {
await PythonRPC.rpc if (this.usingJsDownloader && this.jsDownloader) {
.post("/action", { action: "cancel", game_id: downloadKey }) logger.log("[DownloadManager] Cancelling JS download");
.catch((err) => logger.error("Failed to cancel game download", err)); this.jsDownloader.cancelDownload();
this.jsDownloader = null;
this.usingJsDownloader = false;
} else if (!this.isPreparingDownload) {
await PythonRPC.rpc
.post("/action", { action: "cancel", game_id: downloadKey })
.catch((err) => logger.error("Failed to cancel game download", err));
}
if (downloadKey === this.downloadingGameId) { if (downloadKey === this.downloadingGameId) {
WindowManager.mainWindow?.setProgressBar(-1); WindowManager.mainWindow?.setProgressBar(-1);
WindowManager.mainWindow?.webContents.send("on-download-progress", null); WindowManager.mainWindow?.webContents.send("on-download-progress", null);
this.downloadingGameId = null; this.downloadingGameId = null;
this.isPreparingDownload = false;
this.usingJsDownloader = false;
} }
} }
@@ -354,6 +530,241 @@ export class DownloadManager {
}); });
} }
private static async getJsDownloadOptions(download: Download): Promise<{
url: string;
savePath: string;
filename?: string;
headers?: Record<string, string>;
} | null> {
const resumingFilename = download.folderName || undefined;
switch (download.downloader) {
case Downloader.Gofile:
return this.getGofileDownloadOptions(download, resumingFilename);
case Downloader.PixelDrain:
return this.getPixelDrainDownloadOptions(download, resumingFilename);
case Downloader.Datanodes:
return this.getDatanodesDownloadOptions(download, resumingFilename);
case Downloader.Buzzheavier:
return this.getBuzzheavierDownloadOptions(download, resumingFilename);
case Downloader.FuckingFast:
return this.getFuckingFastDownloadOptions(download, resumingFilename);
case Downloader.Mediafire:
return this.getMediafireDownloadOptions(download, resumingFilename);
case Downloader.RealDebrid:
return this.getRealDebridDownloadOptions(download, resumingFilename);
case Downloader.TorBox:
return this.getTorBoxDownloadOptions(download, resumingFilename);
case Downloader.Hydra:
return this.getHydraDownloadOptions(download, resumingFilename);
case Downloader.VikingFile:
return this.getVikingFileDownloadOptions(download, resumingFilename);
case Downloader.Rootz:
return this.getRootzDownloadOptions(download, resumingFilename);
default:
return null;
}
}
private static async getGofileDownloadOptions(
download: Download,
resumingFilename?: string
) {
const id = download.uri.split("/").pop();
const token = await GofileApi.authorize();
const downloadLink = await GofileApi.getDownloadLink(id!);
await GofileApi.checkDownloadUrl(downloadLink);
const filename = this.resolveFilename(
resumingFilename,
download.uri,
downloadLink
);
return this.buildDownloadOptions(
downloadLink,
download.downloadPath,
filename,
{ Cookie: `accountToken=${token}` }
);
}
private static async getPixelDrainDownloadOptions(
download: Download,
resumingFilename?: string
) {
const id = download.uri.split("/").pop();
const downloadUrl = await PixelDrainApi.getDownloadUrl(id!);
const filename = this.resolveFilename(
resumingFilename,
download.uri,
downloadUrl
);
return this.buildDownloadOptions(
downloadUrl,
download.downloadPath,
filename
);
}
private static async getDatanodesDownloadOptions(
download: Download,
resumingFilename?: string
) {
const downloadUrl = await DatanodesApi.getDownloadUrl(download.uri);
const filename = this.resolveFilename(
resumingFilename,
download.uri,
downloadUrl
);
return this.buildDownloadOptions(
downloadUrl,
download.downloadPath,
filename
);
}
private static async getBuzzheavierDownloadOptions(
download: Download,
resumingFilename?: string
) {
logger.log(
`[DownloadManager] Processing Buzzheavier download for URI: ${download.uri}`
);
const directUrl = await BuzzheavierApi.getDirectLink(download.uri);
const filename = this.resolveFilename(
resumingFilename,
download.uri,
directUrl
);
return this.buildDownloadOptions(
directUrl,
download.downloadPath,
filename
);
}
private static async getFuckingFastDownloadOptions(
download: Download,
resumingFilename?: string
) {
logger.log(
`[DownloadManager] Processing FuckingFast download for URI: ${download.uri}`
);
const directUrl = await FuckingFastApi.getDirectLink(download.uri);
const filename = this.resolveFilename(
resumingFilename,
download.uri,
directUrl
);
return this.buildDownloadOptions(
directUrl,
download.downloadPath,
filename
);
}
private static async getMediafireDownloadOptions(
download: Download,
resumingFilename?: string
) {
const downloadUrl = await MediafireApi.getDownloadUrl(download.uri);
const filename = this.resolveFilename(
resumingFilename,
download.uri,
downloadUrl
);
return this.buildDownloadOptions(
downloadUrl,
download.downloadPath,
filename
);
}
private static async getRealDebridDownloadOptions(
download: Download,
resumingFilename?: string
) {
const downloadUrl = await RealDebridClient.getDownloadUrl(download.uri);
if (!downloadUrl) throw new Error(DownloadError.NotCachedOnRealDebrid);
const filename = this.resolveFilename(
resumingFilename,
download.uri,
downloadUrl
);
return this.buildDownloadOptions(
downloadUrl,
download.downloadPath,
filename
);
}
private static async getTorBoxDownloadOptions(
download: Download,
resumingFilename?: string
) {
const { name, url } = await TorBoxClient.getDownloadInfo(download.uri);
if (!url) return null;
return this.buildDownloadOptions(
url,
download.downloadPath,
resumingFilename || name
);
}
private static async getHydraDownloadOptions(
download: Download,
resumingFilename?: string
) {
const downloadUrl = await HydraDebridClient.getDownloadUrl(download.uri);
if (!downloadUrl) throw new Error(DownloadError.NotCachedOnHydra);
const filename = this.resolveFilename(
resumingFilename,
download.uri,
downloadUrl
);
return this.buildDownloadOptions(
downloadUrl,
download.downloadPath,
filename
);
}
private static async getVikingFileDownloadOptions(
download: Download,
resumingFilename?: string
) {
logger.log(
`[DownloadManager] Processing VikingFile download for URI: ${download.uri}`
);
const downloadUrl = await VikingFileApi.getDownloadUrl(download.uri);
const filename = this.resolveFilename(
resumingFilename,
download.uri,
downloadUrl
);
return this.buildDownloadOptions(
downloadUrl,
download.downloadPath,
filename
);
}
private static async getRootzDownloadOptions(
download: Download,
resumingFilename?: string
) {
const downloadUrl = await RootzApi.getDownloadUrl(download.uri);
const filename = this.resolveFilename(
resumingFilename,
download.uri,
downloadUrl
);
return this.buildDownloadOptions(
downloadUrl,
download.downloadPath,
filename
);
}
private static async getDownloadPayload(download: Download) { private static async getDownloadPayload(download: Download) {
const downloadId = levelKeys.game(download.shop, download.objectId); const downloadId = levelKeys.game(download.shop, download.objectId);
@@ -385,15 +796,6 @@ export class DownloadManager {
save_path: download.downloadPath, save_path: download.downloadPath,
}; };
} }
case Downloader.Qiwi: {
const downloadUrl = await QiwiApi.getDownloadUrl(download.uri);
return {
action: "start",
game_id: downloadId,
url: downloadUrl,
save_path: download.downloadPath,
};
}
case Downloader.Datanodes: { case Downloader.Datanodes: {
const downloadUrl = await DatanodesApi.getDownloadUrl(download.uri); const downloadUrl = await DatanodesApi.getDownloadUrl(download.uri);
return { return {
@@ -499,12 +901,75 @@ export class DownloadManager {
allow_multiple_connections: true, allow_multiple_connections: true,
}; };
} }
case Downloader.VikingFile: {
logger.log(
`[DownloadManager] Processing VikingFile download for URI: ${download.uri}`
);
const downloadUrl = await VikingFileApi.getDownloadUrl(download.uri);
return this.createDownloadPayload(
downloadUrl,
download.uri,
downloadId,
download.downloadPath
);
}
case Downloader.Rootz: {
const downloadUrl = await RootzApi.getDownloadUrl(download.uri);
return {
action: "start",
game_id: downloadId,
url: downloadUrl,
save_path: download.downloadPath,
};
}
default:
return undefined;
} }
} }
static async startDownload(download: Download) { static async startDownload(download: Download) {
const payload = await this.getDownloadPayload(download); const useJsDownloader = await this.shouldUseJsDownloader();
await PythonRPC.rpc.post("/action", payload); const isHttp = this.isHttpDownloader(download.downloader);
this.downloadingGameId = levelKeys.game(download.shop, download.objectId); const downloadId = levelKeys.game(download.shop, download.objectId);
if (useJsDownloader && isHttp) {
logger.log("[DownloadManager] Using JS HTTP downloader");
// Set preparing state immediately so UI knows download is starting
this.downloadingGameId = downloadId;
this.isPreparingDownload = true;
this.usingJsDownloader = true;
try {
const options = await this.getJsDownloadOptions(download);
if (!options) {
this.isPreparingDownload = false;
this.usingJsDownloader = false;
this.downloadingGameId = null;
throw new Error("Failed to get download options for JS downloader");
}
this.jsDownloader = new JsHttpDownloader();
this.isPreparingDownload = false;
this.jsDownloader.startDownload(options).catch((err) => {
logger.error("[DownloadManager] JS download error:", err);
this.usingJsDownloader = false;
this.jsDownloader = null;
});
} catch (err) {
this.isPreparingDownload = false;
this.usingJsDownloader = false;
this.downloadingGameId = null;
throw err;
}
} else {
logger.log("[DownloadManager] Using Python RPC downloader");
const payload = await this.getDownloadPayload(download);
await PythonRPC.rpc.post("/action", payload);
this.downloadingGameId = downloadId;
this.usingJsDownloader = false;
}
} }
} }

View File

@@ -1,3 +1,4 @@
export * from "./download-manager"; export * from "./download-manager";
export * from "./real-debrid"; export * from "./real-debrid";
export * from "./torbox"; export * from "./torbox";
export * from "./js-http-downloader";

View File

@@ -0,0 +1,380 @@
import fs from "node:fs";
import path from "node:path";
import { Readable } from "node:stream";
import { pipeline } from "node:stream/promises";
import { logger } from "../logger";
export interface JsHttpDownloaderStatus {
folderName: string;
fileSize: number;
progress: number;
downloadSpeed: number;
numPeers: number;
numSeeds: number;
status: "active" | "paused" | "complete" | "error";
bytesDownloaded: number;
}
export interface JsHttpDownloaderOptions {
url: string;
savePath: string;
filename?: string;
headers?: Record<string, string>;
}
export class JsHttpDownloader {
private abortController: AbortController | null = null;
private writeStream: fs.WriteStream | null = null;
private currentOptions: JsHttpDownloaderOptions | null = null;
private bytesDownloaded = 0;
private fileSize = 0;
private downloadSpeed = 0;
private status: "active" | "paused" | "complete" | "error" = "paused";
private folderName = "";
private lastSpeedUpdate = Date.now();
private bytesAtLastSpeedUpdate = 0;
private isDownloading = false;
async startDownload(options: JsHttpDownloaderOptions): Promise<void> {
if (this.isDownloading) {
logger.log(
"[JsHttpDownloader] Download already in progress, resuming..."
);
return this.resumeDownload();
}
this.currentOptions = options;
this.abortController = new AbortController();
this.status = "active";
this.isDownloading = true;
const { url, savePath, filename, headers = {} } = options;
const { filePath, startByte, usedFallback } = this.prepareDownloadPath(
savePath,
filename,
url
);
const requestHeaders = this.buildRequestHeaders(headers, startByte);
try {
await this.executeDownload(
url,
requestHeaders,
filePath,
startByte,
savePath,
usedFallback
);
} catch (err) {
this.handleDownloadError(err as Error);
} finally {
this.isDownloading = false;
this.cleanup();
}
}
private prepareDownloadPath(
savePath: string,
filename: string | undefined,
url: string
): { filePath: string; startByte: number; usedFallback: boolean } {
const extractedFilename = filename || this.extractFilename(url);
const usedFallback = !extractedFilename;
const resolvedFilename = extractedFilename || "download";
this.folderName = resolvedFilename;
const filePath = path.join(savePath, resolvedFilename);
if (!fs.existsSync(savePath)) {
fs.mkdirSync(savePath, { recursive: true });
}
let startByte = 0;
if (fs.existsSync(filePath)) {
const stats = fs.statSync(filePath);
startByte = stats.size;
this.bytesDownloaded = startByte;
logger.log(`[JsHttpDownloader] Resuming download from byte ${startByte}`);
}
this.resetSpeedTracking();
return { filePath, startByte, usedFallback };
}
private buildRequestHeaders(
headers: Record<string, string>,
startByte: number
): Record<string, string> {
const requestHeaders: Record<string, string> = { ...headers };
if (startByte > 0) {
requestHeaders["Range"] = `bytes=${startByte}-`;
}
return requestHeaders;
}
private resetSpeedTracking(): void {
this.lastSpeedUpdate = Date.now();
this.bytesAtLastSpeedUpdate = this.bytesDownloaded;
this.downloadSpeed = 0;
}
private parseFileSize(response: Response, startByte: number): void {
const contentRange = response.headers.get("content-range");
if (contentRange) {
const match = /bytes \d+-\d+\/(\d+)/.exec(contentRange);
if (match) {
this.fileSize = Number.parseInt(match[1], 10);
}
return;
}
const contentLength = response.headers.get("content-length");
if (contentLength) {
this.fileSize = startByte + Number.parseInt(contentLength, 10);
}
}
private async executeDownload(
url: string,
requestHeaders: Record<string, string>,
filePath: string,
startByte: number,
savePath: string,
usedFallback: boolean
): Promise<void> {
const response = await fetch(url, {
headers: requestHeaders,
signal: this.abortController?.signal,
});
// Handle 416 Range Not Satisfiable - existing file is larger than server file
// This happens when downloading same game from different source
if (response.status === 416 && startByte > 0) {
logger.log(
"[JsHttpDownloader] Range not satisfiable, deleting existing file and restarting"
);
if (fs.existsSync(filePath)) {
fs.unlinkSync(filePath);
}
this.bytesDownloaded = 0;
this.resetSpeedTracking();
// Retry without Range header
const headersWithoutRange = { ...requestHeaders };
delete headersWithoutRange["Range"];
return this.executeDownload(
url,
headersWithoutRange,
filePath,
0,
savePath,
usedFallback
);
}
if (!response.ok && response.status !== 206) {
throw new Error(`HTTP error! status: ${response.status}`);
}
this.parseFileSize(response, startByte);
// If we used "download" fallback, try to get filename from Content-Disposition
let actualFilePath = filePath;
if (usedFallback && startByte === 0) {
const headerFilename = this.parseContentDisposition(response);
if (headerFilename) {
actualFilePath = path.join(savePath, headerFilename);
this.folderName = headerFilename;
logger.log(
`[JsHttpDownloader] Using filename from Content-Disposition: ${headerFilename}`
);
}
}
if (!response.body) {
throw new Error("Response body is null");
}
const flags = startByte > 0 ? "a" : "w";
this.writeStream = fs.createWriteStream(actualFilePath, { flags });
const readableStream = this.createReadableStream(response.body.getReader());
await pipeline(readableStream, this.writeStream);
this.status = "complete";
this.downloadSpeed = 0;
logger.log("[JsHttpDownloader] Download complete");
}
private parseContentDisposition(response: Response): string | undefined {
const header = response.headers.get("content-disposition");
if (!header) return undefined;
// Try to extract filename from Content-Disposition header
// Formats: attachment; filename="file.zip" or attachment; filename=file.zip
const filenameMatch = /filename\*?=['"]?(?:UTF-8'')?([^"';\n]+)['"]?/i.exec(
header
);
if (filenameMatch?.[1]) {
try {
return decodeURIComponent(filenameMatch[1].trim());
} catch {
return filenameMatch[1].trim();
}
}
return undefined;
}
private createReadableStream(
reader: ReadableStreamDefaultReader<Uint8Array>
): Readable {
const onChunk = (length: number) => {
this.bytesDownloaded += length;
this.updateSpeed();
};
return new Readable({
read() {
reader
.read()
.then(({ done, value }) => {
if (done) {
this.push(null);
return;
}
onChunk(value.length);
this.push(Buffer.from(value));
})
.catch((err: Error) => {
if (err.name === "AbortError") {
this.push(null);
} else {
this.destroy(err);
}
});
},
});
}
private handleDownloadError(err: Error): void {
// Handle abort/cancellation errors - these are expected when user pauses/cancels
if (
err.name === "AbortError" ||
(err as NodeJS.ErrnoException).code === "ERR_STREAM_PREMATURE_CLOSE"
) {
logger.log("[JsHttpDownloader] Download aborted");
this.status = "paused";
} else {
logger.error("[JsHttpDownloader] Download error:", err);
this.status = "error";
throw err;
}
}
private async resumeDownload(): Promise<void> {
if (!this.currentOptions) {
throw new Error("No download options available for resume");
}
this.isDownloading = false;
await this.startDownload(this.currentOptions);
}
pauseDownload(): void {
if (this.abortController) {
logger.log("[JsHttpDownloader] Pausing download");
this.abortController.abort();
this.status = "paused";
this.downloadSpeed = 0;
}
}
cancelDownload(deleteFile = true): void {
if (this.abortController) {
logger.log("[JsHttpDownloader] Cancelling download");
this.abortController.abort();
}
this.cleanup();
if (deleteFile && this.currentOptions && this.status !== "complete") {
const filePath = path.join(this.currentOptions.savePath, this.folderName);
if (fs.existsSync(filePath)) {
try {
fs.unlinkSync(filePath);
logger.log("[JsHttpDownloader] Deleted partial file");
} catch (err) {
logger.error(
"[JsHttpDownloader] Failed to delete partial file:",
err
);
}
}
}
this.reset();
}
getDownloadStatus(): JsHttpDownloaderStatus | null {
if (!this.currentOptions && this.status !== "active") {
return null;
}
return {
folderName: this.folderName,
fileSize: this.fileSize,
progress: this.fileSize > 0 ? this.bytesDownloaded / this.fileSize : 0,
downloadSpeed: this.downloadSpeed,
numPeers: 0,
numSeeds: 0,
status: this.status,
bytesDownloaded: this.bytesDownloaded,
};
}
private updateSpeed(): void {
const now = Date.now();
const elapsed = (now - this.lastSpeedUpdate) / 1000;
if (elapsed >= 1) {
const bytesDelta = this.bytesDownloaded - this.bytesAtLastSpeedUpdate;
this.downloadSpeed = bytesDelta / elapsed;
this.lastSpeedUpdate = now;
this.bytesAtLastSpeedUpdate = this.bytesDownloaded;
}
}
private extractFilename(url: string): string | undefined {
try {
const urlObj = new URL(url);
const pathname = urlObj.pathname;
const pathParts = pathname.split("/");
const filename = pathParts.at(-1);
if (filename?.includes(".") && filename.length > 0) {
return decodeURIComponent(filename);
}
} catch {
// Invalid URL
}
return undefined;
}
private cleanup(): void {
if (this.writeStream) {
this.writeStream.close();
this.writeStream = null;
}
this.abortController = null;
}
private reset(): void {
this.currentOptions = null;
this.bytesDownloaded = 0;
this.fileSize = 0;
this.downloadSpeed = 0;
this.status = "paused";
this.folderName = "";
this.isDownloading = false;
}
}

View File

@@ -1,4 +1,6 @@
import axios from "axios"; import axios from "axios";
import http from "node:http";
import https from "node:https";
import { import {
HOSTER_USER_AGENT, HOSTER_USER_AGENT,
extractHosterFilename, extractHosterFilename,
@@ -28,6 +30,12 @@ export class BuzzheavierApi {
await axios.get(baseUrl, { await axios.get(baseUrl, {
headers: { "User-Agent": HOSTER_USER_AGENT }, headers: { "User-Agent": HOSTER_USER_AGENT },
timeout: 30000, timeout: 30000,
httpAgent: new http.Agent({
family: 4, // Force IPv4
}),
httpsAgent: new https.Agent({
family: 4, // Force IPv4
}),
}); });
const downloadUrl = `${baseUrl}/download`; const downloadUrl = `${baseUrl}/download`;
@@ -43,6 +51,12 @@ export class BuzzheavierApi {
validateStatus: (status) => validateStatus: (status) =>
status === 200 || status === 204 || status === 301 || status === 302, status === 200 || status === 204 || status === 301 || status === 302,
timeout: 30000, timeout: 30000,
httpAgent: new http.Agent({
family: 4, // Force IPv4
}),
httpsAgent: new https.Agent({
family: 4, // Force IPv4
}),
}); });
const hxRedirect = headResponse.headers["hx-redirect"]; const hxRedirect = headResponse.headers["hx-redirect"];

View File

@@ -1,7 +1,8 @@
export * from "./gofile"; export * from "./gofile";
export * from "./qiwi";
export * from "./datanodes"; export * from "./datanodes";
export * from "./mediafire"; export * from "./mediafire";
export * from "./pixeldrain"; export * from "./pixeldrain";
export * from "./buzzheavier"; export * from "./buzzheavier";
export * from "./fuckingfast"; export * from "./fuckingfast";
export * from "./vikingfile";
export * from "./rootz";

View File

@@ -1,15 +0,0 @@
import { requestWebPage } from "@main/helpers";
export class QiwiApi {
public static async getDownloadUrl(url: string) {
const document = await requestWebPage(url);
const fileName = document.querySelector("h1")?.textContent;
const slug = url.split("/").pop();
const extension = fileName?.split(".").pop();
const downloadUrl = `https://spyderrock.com/${slug}.${extension}`;
return downloadUrl;
}
}

View File

@@ -0,0 +1,58 @@
import axios, { AxiosError } from "axios";
import { logger } from "../logger";
interface RootzApiResponse {
success: boolean;
data?: {
url: string;
fileName: string;
size: number;
mimeType: string;
expiresIn: number;
expiresAt: string | null;
downloads: number;
canDelete: boolean;
fileId: string;
isMirrored: boolean;
sourceService: string | null;
adsEnabled: boolean;
};
error?: string;
}
export class RootzApi {
public static async getDownloadUrl(uri: string): Promise<string> {
try {
const url = new URL(uri);
const pathSegments = url.pathname.split("/").filter(Boolean);
if (pathSegments.length < 2 || pathSegments[0] !== "d") {
throw new Error("Invalid rootz URL format");
}
const id = pathSegments[1];
const apiUrl = `https://www.rootz.so/api/files/download-by-short/${id}`;
const response = await axios.get<RootzApiResponse>(apiUrl);
if (response.data.success && response.data.data?.url) {
return response.data.data.url;
}
throw new Error("Failed to get download URL from rootz API");
} catch (error) {
if (axios.isAxiosError(error)) {
const axiosError = error as AxiosError<RootzApiResponse>;
if (axiosError.response?.status === 404) {
const errorMessage =
axiosError.response.data?.error || "File not found";
logger.error(`[Rootz] ${errorMessage}`);
throw new Error(errorMessage);
}
}
logger.error("[Rootz] Error fetching download URL:", error);
throw error;
}
}
}

View File

@@ -0,0 +1,46 @@
import axios from "axios";
import { logger } from "../logger";
interface UnlockResponse {
link: string;
hoster: string;
}
export class VikingFileApi {
public static async getDownloadUrl(uri: string): Promise<string> {
const unlockResponse = await axios.post<UnlockResponse>(
`${import.meta.env.MAIN_VITE_NIMBUS_API_URL}/hosters/unlock`,
{ url: uri }
);
if (!unlockResponse.data.link) {
throw new Error("Failed to unlock VikingFile URL");
}
const redirectUrl = unlockResponse.data.link;
try {
const redirectResponse = await axios.head(redirectUrl, {
maxRedirects: 0,
validateStatus: (status) =>
status === 301 || status === 302 || status === 200,
});
if (
redirectResponse.headers.location ||
redirectResponse.status === 301 ||
redirectResponse.status === 302
) {
return redirectResponse.headers.location || redirectUrl;
}
return redirectUrl;
} catch (error) {
logger.error(
`[VikingFile] Error following redirect, using redirect URL:`,
error
);
return redirectUrl;
}
}
}

View File

@@ -1,4 +1,5 @@
import axios from "axios"; import axios from "axios";
import http from "node:http";
import cp from "node:child_process"; import cp from "node:child_process";
import fs from "node:fs"; import fs from "node:fs";
@@ -31,6 +32,9 @@ export class PythonRPC {
public static readonly RPC_PORT = "8084"; public static readonly RPC_PORT = "8084";
public static readonly rpc = axios.create({ public static readonly rpc = axios.create({
baseURL: `http://localhost:${this.RPC_PORT}`, baseURL: `http://localhost:${this.RPC_PORT}`,
httpAgent: new http.Agent({
family: 4, // Force IPv4
}),
}); });
private static pythonProcess: cp.ChildProcess | null = null; private static pythonProcess: cp.ChildProcess | null = null;

View File

@@ -7,6 +7,7 @@ interface ImportMetaEnv {
readonly MAIN_VITE_CHECKOUT_URL: string; readonly MAIN_VITE_CHECKOUT_URL: string;
readonly MAIN_VITE_EXTERNAL_RESOURCES_URL: string; readonly MAIN_VITE_EXTERNAL_RESOURCES_URL: string;
readonly MAIN_VITE_WS_URL: string; readonly MAIN_VITE_WS_URL: string;
readonly MAIN_VITE_NIMBUS_API_URL: string;
readonly MAIN_VITE_LAUNCHER_SUBDOMAIN: string; readonly MAIN_VITE_LAUNCHER_SUBDOMAIN: string;
readonly ELECTRON_RENDERER_URL: string; readonly ELECTRON_RENDERER_URL: string;
} }

View File

@@ -206,6 +206,8 @@ contextBridge.exposeInMainWorld("electron", {
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),
getGameInstallerActionType: (shop: GameShop, objectId: string) =>
ipcRenderer.invoke("getGameInstallerActionType", shop, objectId),
openGameInstallerPath: (shop: GameShop, objectId: string) => openGameInstallerPath: (shop: GameShop, objectId: string) =>
ipcRenderer.invoke("openGameInstallerPath", shop, objectId), ipcRenderer.invoke("openGameInstallerPath", shop, objectId),
openGameExecutablePath: (shop: GameShop, objectId: string) => openGameExecutablePath: (shop: GameShop, objectId: string) =>

View File

@@ -1,6 +1,6 @@
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import { Sidebar, BottomPanel, Header, Toast } from "@renderer/components"; import { Sidebar, BottomPanel, Header, Toast } from "@renderer/components";
import { WorkWondersSdk } from "workwonders-sdk";
import { import {
useAppDispatch, useAppDispatch,
useAppSelector, useAppSelector,
@@ -52,6 +52,8 @@ export function App() {
const { clearDownload, setLastPacket } = useDownload(); const { clearDownload, setLastPacket } = useDownload();
const workwondersRef = useRef<WorkWondersSdk | null>(null);
const { const {
hasActiveSubscription, hasActiveSubscription,
fetchUserDetails, fetchUserDetails,
@@ -114,7 +116,30 @@ export function App() {
return () => unsubscribe(); return () => unsubscribe();
}, [updateLibrary]); }, [updateLibrary]);
useEffect(() => { const setupWorkWonders = useCallback(
async (token?: string, locale?: string) => {
if (workwondersRef.current) return;
const possibleLocales = ["en", "pt", "ru"];
const parsedLocale =
possibleLocales.find((l) => l === locale?.slice(0, 2)) ?? "en";
workwondersRef.current = new WorkWondersSdk();
await workwondersRef.current.init({
organization: "hydra",
token,
locale: parsedLocale,
});
await workwondersRef.current.initChangelogWidget();
workwondersRef.current.initChangelogWidgetMini();
workwondersRef.current.initFeedbackWidget();
},
[workwondersRef]
);
const setupExternalResources = useCallback(async () => {
const cachedUserDetails = window.localStorage.getItem("userDetails"); const cachedUserDetails = window.localStorage.getItem("userDetails");
if (cachedUserDetails) { if (cachedUserDetails) {
@@ -125,21 +150,26 @@ export function App() {
dispatch(setProfileBackground(profileBackground)); dispatch(setProfileBackground(profileBackground));
} }
fetchUserDetails() const userPreferences = await window.electron.getUserPreferences();
.then((response) => { const userDetails = await fetchUserDetails().catch(() => null);
if (response) {
updateUserDetails(response);
}
})
.finally(() => {
if (document.getElementById("external-resources")) return;
const $script = document.createElement("script"); if (userDetails) {
$script.id = "external-resources"; updateUserDetails(userDetails);
$script.src = `${import.meta.env.RENDERER_VITE_EXTERNAL_RESOURCES_URL}/bundle.js?t=${Date.now()}`; }
document.head.appendChild($script);
}); setupWorkWonders(userDetails?.workwondersJwt, userPreferences?.language);
}, [fetchUserDetails, updateUserDetails, dispatch]);
if (!document.getElementById("external-resources")) {
const $script = document.createElement("script");
$script.id = "external-resources";
$script.src = `${import.meta.env.RENDERER_VITE_EXTERNAL_RESOURCES_URL}/bundle.js?t=${Date.now()}`;
document.head.appendChild($script);
}
}, [fetchUserDetails, updateUserDetails, dispatch, setupWorkWonders]);
useEffect(() => {
setupExternalResources();
}, [setupExternalResources]);
const onSignIn = useCallback(() => { const onSignIn = useCallback(() => {
fetchUserDetails().then((response) => { fetchUserDetails().then((response) => {
@@ -203,6 +233,7 @@ export function App() {
useEffect(() => { useEffect(() => {
if (contentRef.current) contentRef.current.scrollTop = 0; if (contentRef.current) contentRef.current.scrollTop = 0;
workwondersRef.current?.notifyUrlChange();
}, [location.pathname, location.search]); }, [location.pathname, location.search]);
useEffect(() => { useEffect(() => {

View File

@@ -8,6 +8,7 @@
min-width: 200px; min-width: 200px;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
animation: dropdown-menu-fade-in 0.2s ease-out;
} }
&__group { &__group {
@@ -66,3 +67,14 @@
justify-content: center; justify-content: center;
} }
} }
@keyframes dropdown-menu-fade-in {
0% {
opacity: 0;
transform: translateY(-8px);
}
100% {
opacity: 1;
transform: translateY(0);
}
}

View File

@@ -224,21 +224,6 @@ export function Header() {
setActiveIndex(-1); setActiveIndex(-1);
}; };
useEffect(() => {
const prevPath = sessionStorage.getItem("prevPath");
const currentPath = location.pathname;
if (
prevPath?.startsWith("/catalogue") &&
!currentPath.startsWith("/catalogue") &&
catalogueSearchValue
) {
dispatch(setFilters({ title: "" }));
}
sessionStorage.setItem("prevPath", currentPath);
}, [location.pathname, catalogueSearchValue, dispatch]);
useEffect(() => { useEffect(() => {
if (!isDropdownVisible) return; if (!isDropdownVisible) return;

View File

@@ -1,19 +1,20 @@
import { Downloader } from "@shared"; import { Downloader } from "@shared";
export const VERSION_CODENAME = "Supernova"; export const VERSION_CODENAME = "Harbinger";
export const DOWNLOADER_NAME = { export const DOWNLOADER_NAME = {
[Downloader.RealDebrid]: "Real-Debrid", [Downloader.RealDebrid]: "Real-Debrid",
[Downloader.Torrent]: "Torrent", [Downloader.Torrent]: "Torrent",
[Downloader.Gofile]: "Gofile", [Downloader.Gofile]: "Gofile",
[Downloader.PixelDrain]: "PixelDrain", [Downloader.PixelDrain]: "PixelDrain",
[Downloader.Qiwi]: "Qiwi",
[Downloader.Datanodes]: "Datanodes", [Downloader.Datanodes]: "Datanodes",
[Downloader.Mediafire]: "Mediafire", [Downloader.Mediafire]: "Mediafire",
[Downloader.Buzzheavier]: "Buzzheavier", [Downloader.Buzzheavier]: "Buzzheavier",
[Downloader.FuckingFast]: "FuckingFast", [Downloader.FuckingFast]: "FuckingFast",
[Downloader.TorBox]: "TorBox", [Downloader.TorBox]: "TorBox",
[Downloader.Hydra]: "Nimbus", [Downloader.Hydra]: "Nimbus",
[Downloader.VikingFile]: "VikingFile",
[Downloader.Rootz]: "Rootz",
}; };
export const MAX_MINUTES_TO_SHOW_IN_PLAYTIME = 120; export const MAX_MINUTES_TO_SHOW_IN_PLAYTIME = 120;

View File

@@ -167,6 +167,10 @@ declare global {
getLibrary: () => Promise<LibraryGame[]>; getLibrary: () => Promise<LibraryGame[]>;
refreshLibraryAssets: () => Promise<void>; refreshLibraryAssets: () => Promise<void>;
openGameInstaller: (shop: GameShop, objectId: string) => Promise<boolean>; openGameInstaller: (shop: GameShop, objectId: string) => Promise<boolean>;
getGameInstallerActionType: (
shop: GameShop,
objectId: string
) => Promise<"install" | "open-folder">;
openGameInstallerPath: (shop: GameShop, objectId: string) => Promise<void>; openGameInstallerPath: (shop: GameShop, objectId: string) => Promise<void>;
openGameExecutablePath: (shop: GameShop, objectId: string) => Promise<void>; openGameExecutablePath: (shop: GameShop, objectId: string) => Promise<void>;
openGame: ( openGame: (

View File

@@ -31,11 +31,16 @@ export const downloadSlice = createSlice({
reducers: { reducers: {
setLastPacket: (state, action: PayloadAction<DownloadProgress | null>) => { setLastPacket: (state, action: PayloadAction<DownloadProgress | null>) => {
state.lastPacket = action.payload; state.lastPacket = action.payload;
if (!state.gameId && action.payload) state.gameId = action.payload.gameId;
// Ensure payload exists and has a valid gameId before accessing
const payload = action.payload;
if (!state.gameId && payload?.gameId) {
state.gameId = payload.gameId;
}
// Track peak speed and speed history atomically when packet arrives // Track peak speed and speed history atomically when packet arrives
if (action.payload?.gameId && action.payload.downloadSpeed != null) { if (payload?.gameId && payload.downloadSpeed != null) {
const { gameId, downloadSpeed } = action.payload; const { gameId, downloadSpeed } = payload;
// Update peak speed if this is higher // Update peak speed if this is higher
const currentPeak = state.peakSpeeds[gameId] || 0; const currentPeak = state.peakSpeeds[gameId] || 0;

View File

@@ -59,6 +59,7 @@ export function useUserDetails() {
username: userDetails?.username || "", username: userDetails?.username || "",
subscription: userDetails?.subscription || null, subscription: userDetails?.subscription || null,
featurebaseJwt: userDetails?.featurebaseJwt || "", featurebaseJwt: userDetails?.featurebaseJwt || "",
workwondersJwt: userDetails?.workwondersJwt || "",
karma: userDetails?.karma || 0, karma: userDetails?.karma || 0,
}); });
}, },
@@ -111,7 +112,7 @@ export function useUserDetails() {
); );
const undoFriendship = (userId: string) => const undoFriendship = (userId: string) =>
window.electron.hydraApi.delete(`/profile/friends/${userId}`); window.electron.hydraApi.delete(`/profile/friend-requests/${userId}`);
const blockUser = (userId: string) => const blockUser = (userId: string) =>
window.electron.hydraApi.post(`/users/${userId}/block`); window.electron.hydraApi.post(`/users/${userId}/block`);

View File

@@ -511,6 +511,13 @@
min-height: unset; min-height: unset;
} }
&__simple-action-btn {
padding: calc(globals.$spacing-unit);
min-height: unset;
gap: calc(globals.$spacing-unit);
min-width: 120px;
}
&__progress-wrapper { &__progress-wrapper {
flex: 1; flex: 1;
display: flex; display: flex;

View File

@@ -1,6 +1,6 @@
import type { GameShop, LibraryGame, SeedingStatus } from "@types"; import type { GameShop, LibraryGame, SeedingStatus } from "@types";
import { Badge, Button } from "@renderer/components"; import { Badge, Button, ConfirmationModal } from "@renderer/components";
import { import {
formatDownloadProgress, formatDownloadProgress,
buildGameDetailsPath, buildGameDetailsPath,
@@ -32,12 +32,12 @@ import {
FileDirectoryIcon, FileDirectoryIcon,
LinkIcon, LinkIcon,
PlayIcon, PlayIcon,
ThreeBarsIcon,
TrashIcon, TrashIcon,
UnlinkIcon, UnlinkIcon,
XCircleIcon, XCircleIcon,
GraphIcon, GraphIcon,
} from "@primer/octicons-react"; } from "@primer/octicons-react";
import { MoreVertical, Folder } from "lucide-react";
import { average } from "color.js"; import { average } from "color.js";
interface AnimatedPercentageProps { interface AnimatedPercentageProps {
@@ -219,7 +219,7 @@ interface HeroDownloadViewProps {
calculateETA: () => string; calculateETA: () => string;
pauseDownload: (shop: GameShop, objectId: string) => void; pauseDownload: (shop: GameShop, objectId: string) => void;
resumeDownload: (shop: GameShop, objectId: string) => void; resumeDownload: (shop: GameShop, objectId: string) => void;
cancelDownload: (shop: GameShop, objectId: string) => void; onCancelClick: (shop: GameShop, objectId: string) => void;
t: (key: string) => string; t: (key: string) => string;
} }
@@ -238,7 +238,7 @@ function HeroDownloadView({
calculateETA, calculateETA,
pauseDownload, pauseDownload,
resumeDownload, resumeDownload,
cancelDownload, onCancelClick,
t, t,
}: Readonly<HeroDownloadViewProps>) { }: Readonly<HeroDownloadViewProps>) {
const navigate = useNavigate(); const navigate = useNavigate();
@@ -353,7 +353,7 @@ function HeroDownloadView({
)} )}
<button <button
type="button" type="button"
onClick={() => cancelDownload(game.shop, game.objectId)} onClick={() => onCancelClick(game.shop, game.objectId)}
className="download-group__glass-btn" className="download-group__glass-btn"
> >
<XCircleIcon size={14} /> <XCircleIcon size={14} />
@@ -452,6 +452,7 @@ export function DownloadGroup({
seedingStatus, seedingStatus,
}: Readonly<DownloadGroupProps>) { }: Readonly<DownloadGroupProps>) {
const { t } = useTranslation("downloads"); const { t } = useTranslation("downloads");
const { t: tGameDetails } = useTranslation("game_details");
const navigate = useNavigate(); const navigate = useNavigate();
const userPreferences = useAppSelector( const userPreferences = useAppSelector(
@@ -523,6 +524,16 @@ export function DownloadGroup({
const [optimisticallyResumed, setOptimisticallyResumed] = useState< const [optimisticallyResumed, setOptimisticallyResumed] = useState<
Record<string, boolean> Record<string, boolean>
>({}); >({});
const [cancelModalVisible, setCancelModalVisible] = useState(false);
const [gameToCancelShop, setGameToCancelShop] = useState<GameShop | null>(
null
);
const [gameToCancelObjectId, setGameToCancelObjectId] = useState<
string | null
>(null);
const [gameActionTypes, setGameActionTypes] = useState<
Record<string, "install" | "open-folder">
>({});
const extractDominantColor = useCallback( const extractDominantColor = useCallback(
async (imageUrl: string, gameId: string) => { async (imageUrl: string, gameId: string) => {
@@ -613,11 +624,18 @@ export function DownloadGroup({
const download = game.download!; const download = game.download!;
const isGameDownloading = isGameDownloadingMap[game.id]; const isGameDownloading = isGameDownloadingMap[game.id];
if (download.fileSize != null) return formatBytes(download.fileSize); // Check lastPacket first for most up-to-date size during active downloads
if (
if (lastPacket?.download.fileSize && isGameDownloading) isGameDownloading &&
lastPacket?.download.fileSize &&
lastPacket.download.fileSize > 0
)
return formatBytes(lastPacket.download.fileSize); return formatBytes(lastPacket.download.fileSize);
// Then check the stored download size (must be > 0 to be valid)
if (download.fileSize != null && download.fileSize > 0)
return formatBytes(download.fileSize);
return "N/A"; return "N/A";
}; };
@@ -651,6 +669,27 @@ export function DownloadGroup({
[updateLibrary] [updateLibrary]
); );
const handleCancelClick = useCallback((shop: GameShop, objectId: string) => {
setGameToCancelShop(shop);
setGameToCancelObjectId(objectId);
setCancelModalVisible(true);
}, []);
const handleConfirmCancel = useCallback(async () => {
if (gameToCancelShop && gameToCancelObjectId) {
await cancelDownload(gameToCancelShop, gameToCancelObjectId);
}
setCancelModalVisible(false);
setGameToCancelShop(null);
setGameToCancelObjectId(null);
}, [gameToCancelShop, gameToCancelObjectId, cancelDownload]);
const handleCancelModalClose = useCallback(() => {
setCancelModalVisible(false);
setGameToCancelShop(null);
setGameToCancelObjectId(null);
}, []);
const getGameActions = (game: LibraryGame): DropdownMenuItem[] => { const getGameActions = (game: LibraryGame): DropdownMenuItem[] => {
const download = lastPacket?.download; const download = lastPacket?.download;
const isGameDownloading = isGameDownloadingMap[game.id]; const isGameDownloading = isGameDownloadingMap[game.id];
@@ -721,7 +760,7 @@ export function DownloadGroup({
{ {
label: t("cancel"), label: t("cancel"),
onClick: () => { onClick: () => {
cancelDownload(game.shop, game.objectId); handleCancelClick(game.shop, game.objectId);
}, },
icon: <XCircleIcon />, icon: <XCircleIcon />,
}, },
@@ -746,7 +785,7 @@ export function DownloadGroup({
{ {
label: t("cancel"), label: t("cancel"),
onClick: () => { onClick: () => {
cancelDownload(game.shop, game.objectId); handleCancelClick(game.shop, game.objectId);
}, },
icon: <XCircleIcon />, icon: <XCircleIcon />,
}, },
@@ -770,6 +809,37 @@ export function DownloadGroup({
] ]
); );
// Fetch action types for completed games
useEffect(() => {
const fetchActionTypes = async () => {
const completedGames = library.filter(
(game) => game.download?.progress === 1
);
const actionTypesPromises = completedGames.map(async (game) => {
try {
const actionType = await window.electron.getGameInstallerActionType(
game.shop,
game.objectId
);
return { gameId: game.id, actionType };
} catch {
return { gameId: game.id, actionType: "open-folder" as const };
}
});
const results = await Promise.all(actionTypesPromises);
const newActionTypes: Record<string, "install" | "open-folder"> = {};
results.forEach(({ gameId, actionType }) => {
newActionTypes[gameId] = actionType;
});
setGameActionTypes((prev) => ({ ...prev, ...newActionTypes }));
};
fetchActionTypes();
}, [library]);
if (!library.length) return null; if (!library.length) return null;
const isDownloadingGroup = title === t("download_in_progress"); const isDownloadingGroup = title === t("download_in_progress");
@@ -804,136 +874,179 @@ export function DownloadGroup({
const dominantColor = dominantColors[game.id] || "#fff"; const dominantColor = dominantColors[game.id] || "#fff";
return ( return (
<HeroDownloadView <>
game={game} <ConfirmationModal
isGameDownloading={isGameDownloading} visible={cancelModalVisible}
isGameExtracting={isGameExtracting} title={t("cancel_download")}
downloadSpeed={downloadSpeed} descriptionText={t("cancel_download_description")}
finalDownloadSize={finalDownloadSize} confirmButtonLabel={t("yes_cancel")}
peakSpeed={peakSpeed} cancelButtonLabel={t("keep_downloading")}
currentProgress={currentProgress} onConfirm={handleConfirmCancel}
dominantColor={dominantColor} onClose={handleCancelModalClose}
lastPacket={lastPacket} />
speedHistory={gameSpeedHistory} <HeroDownloadView
formatSpeed={formatSpeed} game={game}
calculateETA={calculateETA} isGameDownloading={isGameDownloading}
pauseDownload={pauseDownload} isGameExtracting={isGameExtracting}
resumeDownload={resumeDownload} downloadSpeed={downloadSpeed}
cancelDownload={cancelDownload} finalDownloadSize={finalDownloadSize}
t={t} peakSpeed={peakSpeed}
/> currentProgress={currentProgress}
dominantColor={dominantColor}
lastPacket={lastPacket}
speedHistory={gameSpeedHistory}
formatSpeed={formatSpeed}
calculateETA={calculateETA}
pauseDownload={pauseDownload}
resumeDownload={resumeDownload}
onCancelClick={handleCancelClick}
t={t}
/>
</>
); );
} }
return ( return (
<div <>
className={`download-group ${isQueuedGroup ? "download-group--queued" : ""} ${isCompletedGroup ? "download-group--completed" : ""}`} <ConfirmationModal
> visible={cancelModalVisible}
<div className="download-group__header"> title={t("cancel_download")}
<div className="download-group__header-title-group"> descriptionText={t("cancel_download_description")}
<h2>{title}</h2> confirmButtonLabel={t("yes_cancel")}
<h3 className="download-group__header-count">{library.length}</h3> cancelButtonLabel={t("keep_downloading")}
onConfirm={handleConfirmCancel}
onClose={handleCancelModalClose}
/>
<div
className={`download-group ${isQueuedGroup ? "download-group--queued" : ""} ${isCompletedGroup ? "download-group--completed" : ""}`}
>
<div className="download-group__header">
<div className="download-group__header-title-group">
<h2>{title}</h2>
<h3 className="download-group__header-count">{library.length}</h3>
</div>
</div> </div>
</div>
<ul className="download-group__simple-list"> <ul className="download-group__simple-list">
{downloadInfo.map(({ game, size, progress, isSeeding: seeding }) => { {downloadInfo.map(({ game, size, progress, isSeeding: seeding }) => {
return ( return (
<li key={game.id} className="download-group__simple-card"> <li key={game.id} className="download-group__simple-card">
<button
type="button"
onClick={() => navigate(buildGameDetailsPath(game))}
className="download-group__simple-thumbnail"
>
<img src={game.libraryImageUrl || ""} alt={game.title} />
</button>
<div className="download-group__simple-info">
<button <button
type="button" type="button"
onClick={() => navigate(buildGameDetailsPath(game))} onClick={() => navigate(buildGameDetailsPath(game))}
className="download-group__simple-title-button" className="download-group__simple-thumbnail"
> >
<h3 className="download-group__simple-title">{game.title}</h3> <img src={game.libraryImageUrl || ""} alt={game.title} />
</button> </button>
<div className="download-group__simple-meta">
<div className="download-group__simple-meta-row"> <div className="download-group__simple-info">
<Badge> <button
{DOWNLOADER_NAME[Number(game.download!.downloader)]} type="button"
</Badge> onClick={() => navigate(buildGameDetailsPath(game))}
</div> className="download-group__simple-title-button"
<div className="download-group__simple-meta-row"> >
{extraction?.visibleId === game.id ? ( <h3 className="download-group__simple-title">
<span className="download-group__simple-extracting"> {game.title}
{t("extracting")} ( </h3>
{Math.round(extraction.progress * 100)}%) </button>
</span> <div className="download-group__simple-meta">
) : ( <div className="download-group__simple-meta-row">
<span className="download-group__simple-size"> <Badge>
<DownloadIcon size={14} /> {DOWNLOADER_NAME[Number(game.download!.downloader)]}
{size} </Badge>
</span> </div>
)} <div className="download-group__simple-meta-row">
{game.download?.progress === 1 && seeding && ( {extraction?.visibleId === game.id ? (
<span className="download-group__simple-seeding"> <span className="download-group__simple-extracting">
{t("seeding")} {t("extracting")} (
</span> {Math.round(extraction.progress * 100)}%)
)} </span>
) : (
<span className="download-group__simple-size">
<DownloadIcon size={14} />
{size}
</span>
)}
{game.download?.progress === 1 && seeding && (
<span className="download-group__simple-seeding">
{t("seeding")}
</span>
)}
</div>
</div> </div>
</div> </div>
</div>
{isQueuedGroup && ( {isQueuedGroup && (
<div className="download-group__simple-progress"> <div className="download-group__simple-progress">
<span className="download-group__simple-progress-text"> <span className="download-group__simple-progress-text">
{formatDownloadProgress(progress)} {formatDownloadProgress(progress)}
</span> </span>
<div className="download-group__progress-bar download-group__progress-bar--small"> <div className="download-group__progress-bar download-group__progress-bar--small">
<div <div
className="download-group__progress-fill" className="download-group__progress-fill"
style={{ style={{
width: `${progress * 100}%`, width: `${progress * 100}%`,
backgroundColor: "#fff", backgroundColor: "#fff",
}} }}
/> />
</div>
</div> </div>
</div> )}
)}
<div className="download-group__simple-actions"> <div className="download-group__simple-actions">
{game.download?.progress === 1 && ( {game.download?.progress === 1 &&
<Button (() => {
theme="primary" const actionType =
onClick={() => openGameInstaller(game.shop, game.objectId)} gameActionTypes[game.id] || "open-folder";
disabled={isGameDeleting(game.id)} const isInstall = actionType === "install";
className="download-group__simple-menu-btn"
> return (
<PlayIcon size={16} /> <Button
</Button> theme="primary"
)} onClick={() =>
{isQueuedGroup && game.download?.progress !== 1 && ( openGameInstaller(game.shop, game.objectId)
<Button }
theme="primary" disabled={isGameDeleting(game.id)}
onClick={() => resumeDownload(game.shop, game.objectId)} className="download-group__simple-action-btn"
className="download-group__simple-menu-btn" >
tooltip={t("resume")} {isInstall ? (
> <>
<DownloadIcon size={16} /> <DownloadIcon size={16} />
</Button> {t("install")}
)} </>
<DropdownMenu align="end" items={getGameActions(game)}> ) : (
<Button <>
theme="outline" <Folder size={16} />
className="download-group__simple-menu-btn" {tGameDetails("open_folder")}
> </>
<ThreeBarsIcon /> )}
</Button> </Button>
</DropdownMenu> );
</div> })()}
</li> {isQueuedGroup && game.download?.progress !== 1 && (
); <Button
})} theme="primary"
</ul> onClick={() => resumeDownload(game.shop, game.objectId)}
</div> className="download-group__simple-menu-btn"
tooltip={t("resume")}
>
<DownloadIcon size={16} />
</Button>
)}
<DropdownMenu align="end" items={getGameActions(game)}>
<Button
theme="outline"
className="download-group__simple-menu-btn"
>
<MoreVertical size={16} />
</Button>
</DropdownMenu>
</div>
</li>
);
})}
</ul>
</div>
</>
); );
} }

View File

@@ -19,23 +19,173 @@
color: globals.$body-color; color: globals.$body-color;
} }
&__downloaders { &__downloaders-list-wrapper {
display: grid; border: 1px solid globals.$border-color;
gap: globals.$spacing-unit; overflow: hidden;
grid-template-columns: repeat(2, 1fr); background-color: globals.$dark-background-color;
} }
&__downloader-option { &__downloaders-list {
position: relative; display: flex;
flex-direction: column;
gap: 0;
max-height: 200px;
overflow-y: auto;
overflow-x: hidden;
padding: 0;
&:only-child { &::-webkit-scrollbar {
grid-column: 1 / -1; width: 8px;
}
&::-webkit-scrollbar-track {
background: transparent;
}
&::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.2);
border-radius: 12px;
}
&::-webkit-scrollbar-thumb:hover {
background: rgba(255, 255, 255, 0.3);
} }
} }
&__downloader-icon { &__downloader-item {
position: absolute; display: flex;
left: calc(globals.$spacing-unit * 2); align-items: center;
gap: 8px;
padding: calc(globals.$spacing-unit * 1.5) calc(globals.$spacing-unit * 2);
border: 1px solid transparent;
border-bottom: 1px solid globals.$border-color;
border-radius: 0;
background-color: transparent;
cursor: pointer;
transition:
background-color 0.15s ease,
border-color 0.15s ease;
color: globals.$body-color;
font-size: 14px;
text-align: left;
height: 48px;
box-sizing: border-box;
&:hover {
background-color: rgba(255, 255, 255, 0.05);
}
&--selected {
background-color: rgba(255, 255, 255, 0.08);
}
&--last {
border-bottom: none;
}
&:disabled {
cursor: default;
&:hover {
background-color: transparent;
}
.download-settings-modal__downloader-name {
opacity: 0.5;
}
.download-settings-modal__availability-indicator-wrapper {
opacity: 0.5;
}
}
}
&__downloader-item-wrapper {
display: flex;
flex-direction: column;
}
&__check-icon {
color: white;
flex-shrink: 0;
}
&__check-icon-wrapper {
margin-left: auto;
display: flex;
align-items: center;
width: 20px;
height: 20px;
justify-content: center;
flex-shrink: 0;
}
&__recommendation-badge {
margin-left: auto;
display: flex;
align-items: center;
height: 20px;
justify-content: center;
flex-shrink: 0;
.badge {
padding: 2px 6px;
font-size: 10px;
line-height: 1.2;
height: 16px;
display: flex;
align-items: center;
white-space: nowrap;
}
}
&__availability-indicator-wrapper {
display: flex;
align-items: center;
flex-shrink: 0;
}
&__availability-indicator {
width: 8px;
height: 8px;
border-radius: 50%;
flex-shrink: 0;
&--available {
background-color: #22c55e;
box-shadow: 0 0 6px rgba(34, 197, 94, 0.5);
}
&--unavailable {
background-color: #ef4444;
box-shadow: 0 0 6px rgba(239, 68, 68, 0.5);
}
&--not-present {
background-color: #6b7280;
box-shadow: 0 0 6px rgba(107, 114, 128, 0.5);
}
&--warning {
background-color: #eab308;
box-shadow: 0 0 6px rgba(234, 179, 8, 0.5);
}
}
@keyframes pulse {
0%,
100% {
opacity: 1;
transform: scale(1);
}
50% {
opacity: 0.7;
transform: scale(1.1);
}
}
&__availability-indicator--pulsating {
animation: pulse 2s ease-in-out infinite;
} }
&__path-error { &__path-error {
@@ -49,4 +199,17 @@
&__change-path-button { &__change-path-button {
align-self: flex-end; align-self: flex-end;
} }
&__loading-spinner {
animation: spin 1s linear infinite;
}
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
} }

View File

@@ -1,17 +1,25 @@
import { useCallback, useEffect, useMemo, useState } from "react"; import { useCallback, useEffect, useMemo, useState } from "react";
import { Trans, useTranslation } from "react-i18next"; import { Trans, useTranslation } from "react-i18next";
import { import {
Badge,
Button, Button,
CheckboxField, CheckboxField,
Link, Link,
Modal, Modal,
TextField, TextField,
} from "@renderer/components"; } from "@renderer/components";
import { CheckCircleFillIcon, DownloadIcon } from "@primer/octicons-react"; import {
import { Downloader, formatBytes, getDownloadersForUris } from "@shared"; DownloadIcon,
SyncIcon,
CheckCircleFillIcon,
} from "@primer/octicons-react";
import { Downloader, formatBytes, getDownloadersForUri } from "@shared";
import type { GameRepack } from "@types"; import type { GameRepack } from "@types";
import { DOWNLOADER_NAME } from "@renderer/constants"; import { DOWNLOADER_NAME } from "@renderer/constants";
import { useAppSelector, useFeature, useToast } from "@renderer/hooks"; import { useAppSelector, useFeature, useToast } from "@renderer/hooks";
import { motion } from "framer-motion";
import { Tooltip } from "react-tooltip";
import { RealDebridInfoModal } from "./real-debrid-info-modal";
import "./download-settings-modal.scss"; import "./download-settings-modal.scss";
export interface DownloadSettingsModalProps { export interface DownloadSettingsModalProps {
@@ -51,6 +59,7 @@ export function DownloadSettingsModal({
const [hasWritePermission, setHasWritePermission] = useState<boolean | null>( const [hasWritePermission, setHasWritePermission] = useState<boolean | null>(
null null
); );
const [showRealDebridModal, setShowRealDebridModal] = useState(false);
const { isFeatureEnabled, Feature } = useFeature(); const { isFeatureEnabled, Feature } = useFeature();
@@ -78,18 +87,89 @@ export function DownloadSettingsModal({
} }
}, [visible, checkFolderWritePermission, selectedPath]); }, [visible, checkFolderWritePermission, selectedPath]);
const downloaders = useMemo(() => { const downloadOptions = useMemo(() => {
return getDownloadersForUris(repack?.uris ?? []); const unavailableUrisSet = new Set(repack?.unavailableUris ?? []);
}, [repack?.uris]);
const downloaderMap = new Map<
Downloader,
{ hasAvailable: boolean; hasUnavailable: boolean }
>();
if (repack) {
for (const uri of repack.uris) {
const uriDownloaders = getDownloadersForUri(uri);
const isAvailable = !unavailableUrisSet.has(uri);
for (const downloader of uriDownloaders) {
const existing = downloaderMap.get(downloader);
if (existing) {
existing.hasAvailable = existing.hasAvailable || isAvailable;
existing.hasUnavailable = existing.hasUnavailable || !isAvailable;
} else {
downloaderMap.set(downloader, {
hasAvailable: isAvailable,
hasUnavailable: !isAvailable,
});
}
}
}
}
const allDownloaders = Object.values(Downloader).filter(
(value) => typeof value === "number"
) as Downloader[];
const getDownloaderPriority = (option: {
isAvailable: boolean;
canHandle: boolean;
isAvailableButNotConfigured: boolean;
}) => {
if (option.isAvailable) return 0;
if (option.canHandle && !option.isAvailableButNotConfigured) return 1;
if (option.isAvailableButNotConfigured) return 2;
return 3;
};
return allDownloaders
.filter((downloader) => downloader !== Downloader.Hydra) // Temporarily comment out Nimbus
.map((downloader) => {
const status = downloaderMap.get(downloader);
const canHandle = status !== undefined;
const isAvailable = status?.hasAvailable ?? false;
let isConfigured = true;
if (downloader === Downloader.RealDebrid) {
isConfigured = !!userPreferences?.realDebridApiToken;
} else if (downloader === Downloader.TorBox) {
isConfigured = !!userPreferences?.torBoxApiToken;
}
// } else if (downloader === Downloader.Hydra) {
// isConfigured = isFeatureEnabled(Feature.Nimbus);
// }
const isAvailableButNotConfigured =
isAvailable && !isConfigured && canHandle;
return {
downloader,
isAvailable: isAvailable && isConfigured,
canHandle,
isAvailableButNotConfigured,
};
})
.sort((a, b) => getDownloaderPriority(a) - getDownloaderPriority(b));
}, [
repack,
userPreferences?.realDebridApiToken,
userPreferences?.torBoxApiToken,
isFeatureEnabled,
Feature,
]);
const getDefaultDownloader = useCallback( const getDefaultDownloader = useCallback(
(availableDownloaders: Downloader[]) => { (availableDownloaders: Downloader[]) => {
if (availableDownloaders.length === 0) return null; if (availableDownloaders.length === 0) return null;
if (availableDownloaders.includes(Downloader.Hydra)) {
return Downloader.Hydra;
}
if (availableDownloaders.includes(Downloader.RealDebrid)) { if (availableDownloaders.includes(Downloader.RealDebrid)) {
return Downloader.RealDebrid; return Downloader.RealDebrid;
} }
@@ -112,26 +192,12 @@ export function DownloadSettingsModal({
.then((defaultDownloadsPath) => setSelectedPath(defaultDownloadsPath)); .then((defaultDownloadsPath) => setSelectedPath(defaultDownloadsPath));
} }
const filteredDownloaders = downloaders.filter((downloader) => { const availableDownloaders = downloadOptions
if (downloader === Downloader.RealDebrid) .filter((option) => option.isAvailable)
return userPreferences?.realDebridApiToken; .map((option) => option.downloader);
if (downloader === Downloader.TorBox)
return userPreferences?.torBoxApiToken;
if (downloader === Downloader.Hydra)
return isFeatureEnabled(Feature.Nimbus);
return true;
});
setSelectedDownloader(getDefaultDownloader(filteredDownloaders)); setSelectedDownloader(getDefaultDownloader(availableDownloaders));
}, [ }, [getDefaultDownloader, userPreferences?.downloadsPath, downloadOptions]);
Feature,
isFeatureEnabled,
getDefaultDownloader,
userPreferences?.downloadsPath,
downloaders,
userPreferences?.realDebridApiToken,
userPreferences?.torBoxApiToken,
]);
const handleChooseDownloadsPath = async () => { const handleChooseDownloadsPath = async () => {
const { filePaths } = await window.electron.showOpenDialog({ const { filePaths } = await window.electron.showOpenDialog({
@@ -186,33 +252,144 @@ export function DownloadSettingsModal({
<div className="download-settings-modal__downloads-path-field"> <div className="download-settings-modal__downloads-path-field">
<span>{t("downloader")}</span> <span>{t("downloader")}</span>
<div className="download-settings-modal__downloaders"> <div className="download-settings-modal__downloaders-list-wrapper">
{downloaders.map((downloader) => { <div className="download-settings-modal__downloaders-list">
const shouldDisableButton = {downloadOptions.map((option, index) => {
(downloader === Downloader.RealDebrid && const isSelected = selectedDownloader === option.downloader;
!userPreferences?.realDebridApiToken) || const tooltipId = `availability-indicator-${option.downloader}`;
(downloader === Downloader.TorBox && const isLastItem = index === downloadOptions.length - 1;
!userPreferences?.torBoxApiToken) ||
(downloader === Downloader.Hydra &&
!isFeatureEnabled(Feature.Nimbus));
return ( const Indicator = option.isAvailable ? motion.span : "span";
<Button
key={downloader} const isDisabled =
className="download-settings-modal__downloader-option" !option.canHandle ||
theme={ (!option.isAvailable && !option.isAvailableButNotConfigured);
selectedDownloader === downloader ? "primary" : "outline"
const getAvailabilityIndicator = () => {
if (option.isAvailable) {
return (
<Indicator
className={`download-settings-modal__availability-indicator download-settings-modal__availability-indicator--available download-settings-modal__availability-indicator--pulsating`}
animate={{
scale: [1, 1.1, 1],
opacity: [1, 0.7, 1],
}}
transition={{
duration: 2,
repeat: Infinity,
ease: "easeInOut",
}}
data-tooltip-id={tooltipId}
data-tooltip-content={t("downloader_online")}
/>
);
} }
disabled={shouldDisableButton}
onClick={() => setSelectedDownloader(downloader)} if (option.isAvailableButNotConfigured) {
> return (
{selectedDownloader === downloader && ( <span
<CheckCircleFillIcon className="download-settings-modal__downloader-icon" /> className={`download-settings-modal__availability-indicator download-settings-modal__availability-indicator--warning`}
)} data-tooltip-id={tooltipId}
{DOWNLOADER_NAME[downloader]} data-tooltip-content={t("downloader_not_configured")}
</Button> />
); );
})} }
if (option.canHandle) {
return (
<span
className={`download-settings-modal__availability-indicator download-settings-modal__availability-indicator--unavailable`}
data-tooltip-id={tooltipId}
data-tooltip-content={t("downloader_offline")}
/>
);
}
return (
<span
className={`download-settings-modal__availability-indicator download-settings-modal__availability-indicator--not-present`}
data-tooltip-id={tooltipId}
data-tooltip-content={t("downloader_not_available")}
/>
);
};
const getRightContent = () => {
if (isSelected) {
return (
<motion.div
className="download-settings-modal__check-icon-wrapper"
initial={{ scale: 0 }}
animate={{ scale: 1 }}
transition={{
type: "spring",
stiffness: 300,
damping: 20,
}}
>
<CheckCircleFillIcon
size={16}
className="download-settings-modal__check-icon"
/>
</motion.div>
);
}
if (
option.downloader === Downloader.RealDebrid &&
option.canHandle
) {
return (
<div className="download-settings-modal__recommendation-badge">
<Badge>{t("recommended")}</Badge>
</div>
);
}
return null;
};
return (
<div
key={option.downloader}
className="download-settings-modal__downloader-item-wrapper"
>
<button
type="button"
className={`download-settings-modal__downloader-item ${
isSelected
? "download-settings-modal__downloader-item--selected"
: ""
} ${
isLastItem
? "download-settings-modal__downloader-item--last"
: ""
}`}
disabled={isDisabled}
onClick={() => {
if (
option.downloader === Downloader.RealDebrid &&
option.isAvailableButNotConfigured
) {
setShowRealDebridModal(true);
} else {
setSelectedDownloader(option.downloader);
}
}}
>
<span className="download-settings-modal__downloader-name">
{DOWNLOADER_NAME[option.downloader]}
</span>
<div className="download-settings-modal__availability-indicator-wrapper">
{getAvailabilityIndicator()}
</div>
<Tooltip id={tooltipId} />
{getRightContent()}
</button>
</div>
);
})}
</div>
</div> </div>
</div> </div>
@@ -264,13 +441,34 @@ export function DownloadSettingsModal({
disabled={ disabled={
downloadStarting || downloadStarting ||
selectedDownloader === null || selectedDownloader === null ||
!hasWritePermission !hasWritePermission ||
downloadOptions.some(
(option) =>
option.downloader === selectedDownloader &&
(option.isAvailableButNotConfigured ||
(!option.isAvailable && option.canHandle) ||
!option.canHandle)
)
} }
> >
<DownloadIcon /> {downloadStarting ? (
{t("download_now")} <>
<SyncIcon className="download-settings-modal__loading-spinner" />
{t("loading")}
</>
) : (
<>
<DownloadIcon />
{t("download_now")}
</>
)}
</Button> </Button>
</div> </div>
<RealDebridInfoModal
visible={showRealDebridModal}
onClose={() => setShowRealDebridModal(false)}
/>
</Modal> </Modal>
); );
} }

View File

@@ -0,0 +1,36 @@
@use "../../../scss/globals.scss";
.real-debrid-info-modal {
&__content {
display: flex;
flex-direction: column;
gap: calc(globals.$spacing-unit * 2.5);
width: 100%;
max-width: 500px;
}
&__description-container {
display: flex;
flex-direction: column;
gap: calc(globals.$spacing-unit * 1.5);
}
&__description {
margin: 0;
color: globals.$body-color;
line-height: 1.6;
}
&__create-account {
display: flex;
align-items: center;
gap: calc(globals.$spacing-unit / 2);
color: #c0c1c7;
text-decoration: underline;
font-size: 14px;
&:hover {
text-decoration: underline;
}
}
}

View File

@@ -0,0 +1,58 @@
import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom";
import { Button, Link, Modal } from "@renderer/components";
import { LinkExternalIcon } from "@primer/octicons-react";
import "./real-debrid-info-modal.scss";
const realDebridReferralId = import.meta.env
.RENDERER_VITE_REAL_DEBRID_REFERRAL_ID;
const REAL_DEBRID_URL = realDebridReferralId
? `https://real-debrid.com/?id=${realDebridReferralId}`
: "https://real-debrid.com";
export interface RealDebridInfoModalProps {
visible: boolean;
onClose: () => void;
}
export function RealDebridInfoModal({
visible,
onClose,
}: Readonly<RealDebridInfoModalProps>) {
const { t } = useTranslation("game_details");
const { t: tSettings } = useTranslation("settings");
const navigate = useNavigate();
return (
<Modal
visible={visible}
title={tSettings("enable_real_debrid")}
onClose={onClose}
>
<div className="real-debrid-info-modal__content">
<div className="real-debrid-info-modal__description-container">
<p className="real-debrid-info-modal__description">
{tSettings("real_debrid_description")}
</p>
<Link
to={REAL_DEBRID_URL}
className="real-debrid-info-modal__create-account"
>
<LinkExternalIcon />
{tSettings("create_real_debrid_account")}
</Link>
</div>
<Button
onClick={() => {
onClose();
navigate("/settings?tab=4");
}}
>
{t("go_to_settings")}
</Button>
</div>
</Modal>
);
}

View File

@@ -221,6 +221,26 @@
left: 0; left: 0;
z-index: 0; z-index: 0;
} }
&__cover-placeholder {
position: relative;
width: 100%;
height: 100%;
min-width: 100%;
min-height: 100%;
background: linear-gradient(
90deg,
rgba(255, 255, 255, 0.08) 0%,
rgba(255, 255, 255, 0.04) 50%,
rgba(255, 255, 255, 0.08) 100%
);
border-radius: 4px;
color: rgba(255, 255, 255, 0.3);
display: flex;
align-items: center;
justify-content: center;
z-index: 0;
}
} }
@keyframes pulse { @keyframes pulse {

View File

@@ -1,7 +1,12 @@
import { LibraryGame } from "@types"; import { LibraryGame } from "@types";
import { useGameCard } from "@renderer/hooks"; import { useGameCard } from "@renderer/hooks";
import { memo } from "react"; import { memo, useState } from "react";
import { ClockIcon, AlertFillIcon, TrophyIcon } from "@primer/octicons-react"; import {
ClockIcon,
AlertFillIcon,
TrophyIcon,
ImageIcon,
} from "@primer/octicons-react";
import "./library-game-card.scss"; import "./library-game-card.scss";
interface LibraryGameCardProps { interface LibraryGameCardProps {
@@ -25,14 +30,9 @@ export const LibraryGameCard = memo(function LibraryGameCard({
const { formatPlayTime, handleCardClick, handleContextMenuClick } = const { formatPlayTime, handleCardClick, handleContextMenuClick } =
useGameCard(game, onContextMenu); useGameCard(game, onContextMenu);
const coverImage = ( const coverImage = game.coverImageUrl?.replaceAll("\\", "/") ?? "";
game.customIconUrl ??
game.coverImageUrl ?? const [imageError, setImageError] = useState(false);
game.libraryImageUrl ??
game.libraryHeroImageUrl ??
game.iconUrl ??
""
).replaceAll("\\", "/");
return ( return (
<button <button
@@ -98,12 +98,19 @@ export const LibraryGameCard = memo(function LibraryGameCard({
)} )}
</div> </div>
<img {imageError || !coverImage ? (
src={coverImage ?? undefined} <div className="library-game-card__cover-placeholder">
alt={game.title} <ImageIcon size={48} />
className="library-game-card__game-image" </div>
loading="lazy" ) : (
/> <img
src={coverImage}
alt={game.title}
className="library-game-card__game-image"
loading="lazy"
onError={() => setImageError(true)}
/>
)}
</button> </button>
); );
}); });

View File

@@ -8,6 +8,72 @@
width: 100%; width: 100%;
max-width: 800px; max-width: 800px;
margin: 0 auto; margin: 0 auto;
min-height: calc(100vh - 200px);
&__header {
display: flex;
justify-content: space-between;
align-items: center;
gap: calc(globals.$spacing-unit * 2);
}
&__filter-tabs {
display: flex;
gap: globals.$spacing-unit;
position: relative;
flex: 1;
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
}
&__tab-wrapper {
position: relative;
}
&__tab {
background: none;
border: none;
color: rgba(255, 255, 255, 0.6);
padding: calc(globals.$spacing-unit) calc(globals.$spacing-unit * 2);
cursor: pointer;
font-size: 14px;
font-weight: 500;
transition: color ease 0.2s;
display: flex;
align-items: center;
gap: calc(globals.$spacing-unit * 0.5);
&:hover {
color: rgba(255, 255, 255, 0.8);
}
&--active {
color: white;
}
}
&__tab-badge {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 20px;
height: 20px;
padding: 0 6px;
background-color: rgba(255, 255, 255, 0.15);
border-radius: 6px;
font-size: 11px;
font-weight: 600;
color: rgba(255, 255, 255, 0.9);
line-height: 20px;
}
&__tab-underline {
position: absolute;
bottom: -1px;
left: 0;
right: 0;
height: 2px;
background: white;
}
&__actions { &__actions {
display: flex; display: flex;
@@ -15,22 +81,37 @@
justify-content: flex-end; justify-content: flex-end;
} }
&__content-wrapper {
display: flex;
flex-direction: column;
flex: 1;
}
&__list { &__list {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: globals.$spacing-unit; gap: globals.$spacing-unit;
padding-bottom: calc(globals.$spacing-unit * 3);
} }
&__empty { &__empty {
display: flex; display: flex;
flex: 1;
width: 100%; width: 100%;
height: 100%;
justify-content: center; justify-content: center;
align-items: center; align-items: center;
flex-direction: column; flex-direction: column;
gap: globals.$spacing-unit; gap: globals.$spacing-unit;
} }
&__empty-filter {
display: flex;
justify-content: center;
align-items: center;
padding: calc(globals.$spacing-unit * 6);
color: globals.$body-color;
}
&__icon-container { &__icon-container {
width: 60px; width: 60px;
height: 60px; height: 60px;
@@ -54,5 +135,6 @@
display: flex; display: flex;
justify-content: center; justify-content: center;
padding: calc(globals.$spacing-unit * 2); padding: calc(globals.$spacing-unit * 2);
padding-bottom: calc(globals.$spacing-unit * 3);
} }
} }

View File

@@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { BellIcon } from "@primer/octicons-react"; import { BellIcon } from "@primer/octicons-react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { AnimatePresence, motion } from "framer-motion"; import { AnimatePresence, motion } from "framer-motion";
@@ -18,6 +18,11 @@ import type {
} from "@types"; } from "@types";
import "./notifications.scss"; import "./notifications.scss";
type NotificationFilter = "all" | "unread";
const STAGGER_DELAY_MS = 70;
const EXIT_DURATION_MS = 250;
export default function Notifications() { export default function Notifications() {
const { t, i18n } = useTranslation("notifications_page"); const { t, i18n } = useTranslation("notifications_page");
const { showSuccessToast, showErrorToast } = useToast(); const { showSuccessToast, showErrorToast } = useToast();
@@ -34,12 +39,14 @@ export default function Notifications() {
>([]); >([]);
const [badges, setBadges] = useState<Badge[]>([]); const [badges, setBadges] = useState<Badge[]>([]);
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
const [clearingIds, setClearingIds] = useState<Set<string>>(new Set()); const [isClearing, setIsClearing] = useState(false);
const [filter, setFilter] = useState<NotificationFilter>("all");
const [pagination, setPagination] = useState({ const [pagination, setPagination] = useState({
total: 0, total: 0,
hasMore: false, hasMore: false,
skip: 0, skip: 0,
}); });
const clearingTimeoutsRef = useRef<NodeJS.Timeout[]>([]);
const fetchLocalNotifications = useCallback(async () => { const fetchLocalNotifications = useCallback(async () => {
try { try {
@@ -65,7 +72,11 @@ export default function Notifications() {
}, [i18n.language]); }, [i18n.language]);
const fetchApiNotifications = useCallback( const fetchApiNotifications = useCallback(
async (skip = 0, append = false) => { async (
skip = 0,
append = false,
filterParam: NotificationFilter = "all"
) => {
if (!userDetails) return; if (!userDetails) return;
try { try {
@@ -74,7 +85,7 @@ export default function Notifications() {
await window.electron.hydraApi.get<NotificationsResponse>( await window.electron.hydraApi.get<NotificationsResponse>(
"/profile/notifications", "/profile/notifications",
{ {
params: { filter: "all", take: 20, skip }, params: { filter: filterParam, take: 20, skip },
needsAuth: true, needsAuth: true,
} }
); );
@@ -101,24 +112,24 @@ export default function Notifications() {
[userDetails] [userDetails]
); );
const fetchAllNotifications = useCallback(async () => { const fetchAllNotifications = useCallback(
setIsLoading(true); async (filterParam: NotificationFilter = "all") => {
await Promise.all([ setIsLoading(true);
fetchLocalNotifications(), await Promise.all([
fetchBadges(), fetchLocalNotifications(),
userDetails ? fetchApiNotifications(0, false) : Promise.resolve(), fetchBadges(),
]); userDetails
setIsLoading(false); ? fetchApiNotifications(0, false, filterParam)
}, [ : Promise.resolve(),
fetchLocalNotifications, ]);
fetchBadges, setIsLoading(false);
fetchApiNotifications, },
userDetails, [fetchLocalNotifications, fetchBadges, fetchApiNotifications, userDetails]
]); );
useEffect(() => { useEffect(() => {
fetchAllNotifications(); fetchAllNotifications(filter);
}, [fetchAllNotifications]); }, [fetchAllNotifications, filter]);
useEffect(() => { useEffect(() => {
const unsubscribe = window.electron.onLocalNotificationCreated( const unsubscribe = window.electron.onLocalNotificationCreated(
@@ -130,6 +141,13 @@ export default function Notifications() {
return () => unsubscribe(); return () => unsubscribe();
}, []); }, []);
// Cleanup timeouts on unmount
useEffect(() => {
return () => {
clearingTimeoutsRef.current.forEach(clearTimeout);
};
}, []);
const mergedNotifications = useMemo<MergedNotification[]>(() => { const mergedNotifications = useMemo<MergedNotification[]>(() => {
const sortByDate = (a: MergedNotification, b: MergedNotification) => const sortByDate = (a: MergedNotification, b: MergedNotification) =>
new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(); new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime();
@@ -144,23 +162,28 @@ export default function Notifications() {
.filter((n) => n.priority !== 1) .filter((n) => n.priority !== 1)
.map((n) => ({ ...n, source: "api" as const })); .map((n) => ({ ...n, source: "api" as const }));
const localWithSource: MergedNotification[] = localNotifications.map( // Filter local notifications based on current filter
(n) => ({ const filteredLocalNotifications =
filter === "unread"
? localNotifications.filter((n) => !n.isRead)
: localNotifications;
const localWithSource: MergedNotification[] =
filteredLocalNotifications.map((n) => ({
...n, ...n,
source: "local" as const, source: "local" as const,
}) }));
);
const lowPriority = [...lowPriorityApi, ...localWithSource].sort( const lowPriority = [...lowPriorityApi, ...localWithSource].sort(
sortByDate sortByDate
); );
return [...highPriority, ...lowPriority]; return [...highPriority, ...lowPriority];
}, [apiNotifications, localNotifications]); }, [apiNotifications, localNotifications, filter]);
const displayedNotifications = useMemo(() => { const displayedNotifications = useMemo(() => {
return mergedNotifications.filter((n) => !clearingIds.has(n.id)); return mergedNotifications;
}, [mergedNotifications, clearingIds]); }, [mergedNotifications]);
const notifyCountChange = useCallback(() => { const notifyCountChange = useCallback(() => {
window.dispatchEvent(new CustomEvent("notificationsChanged")); window.dispatchEvent(new CustomEvent("notificationsChanged"));
@@ -251,42 +274,86 @@ export default function Notifications() {
[showErrorToast, t, notifyCountChange] [showErrorToast, t, notifyCountChange]
); );
const removeNotificationFromState = useCallback(
(notification: MergedNotification) => {
if (notification.source === "api") {
setApiNotifications((prev) =>
prev.filter((n) => n.id !== notification.id)
);
} else {
setLocalNotifications((prev) =>
prev.filter((n) => n.id !== notification.id)
);
}
},
[]
);
const removeNotificationWithDelay = useCallback(
(notification: MergedNotification, delayMs: number): Promise<void> => {
return new Promise<void>((resolve) => {
const timeout = setTimeout(() => {
removeNotificationFromState(notification);
resolve();
}, delayMs);
clearingTimeoutsRef.current.push(timeout);
});
},
[removeNotificationFromState]
);
const handleClearAll = useCallback(async () => { const handleClearAll = useCallback(async () => {
if (isClearing) return;
try { try {
// Mark all as clearing for animation setIsClearing(true);
const allIds = new Set([
...apiNotifications.map((n) => n.id),
...localNotifications.map((n) => n.id),
]);
setClearingIds(allIds);
// Wait for exit animation // Clear any existing timeouts
await new Promise((resolve) => setTimeout(resolve, 300)); clearingTimeoutsRef.current.forEach(clearTimeout);
clearingTimeoutsRef.current = [];
// Clear all API notifications // Snapshot current notifications for staggered removal
if (userDetails && apiNotifications.length > 0) { const notificationsToRemove = [...displayedNotifications];
const totalNotifications = notificationsToRemove.length;
if (totalNotifications === 0) {
setIsClearing(false);
return;
}
// Remove items one by one with staggered delays for visual effect
const removalPromises = notificationsToRemove.map((notification, index) =>
removeNotificationWithDelay(notification, index * STAGGER_DELAY_MS)
);
// Wait for all items to be removed from state
await Promise.all(removalPromises);
// Wait for the last exit animation to complete
await new Promise((resolve) => setTimeout(resolve, EXIT_DURATION_MS));
// Perform actual backend deletions (state is already cleared by staggered removal)
if (userDetails) {
await window.electron.hydraApi.delete(`/profile/notifications/all`, { await window.electron.hydraApi.delete(`/profile/notifications/all`, {
needsAuth: true, needsAuth: true,
}); });
setApiNotifications([]);
} }
// Clear all local notifications
await window.electron.clearAllLocalNotifications(); await window.electron.clearAllLocalNotifications();
setLocalNotifications([]);
setClearingIds(new Set());
setPagination({ total: 0, hasMore: false, skip: 0 }); setPagination({ total: 0, hasMore: false, skip: 0 });
notifyCountChange(); notifyCountChange();
showSuccessToast(t("cleared_all")); showSuccessToast(t("cleared_all"));
} catch (error) { } catch (error) {
logger.error("Failed to clear all notifications", error); logger.error("Failed to clear all notifications", error);
setClearingIds(new Set());
showErrorToast(t("failed_to_clear")); showErrorToast(t("failed_to_clear"));
} finally {
setIsClearing(false);
clearingTimeoutsRef.current = [];
} }
}, [ }, [
apiNotifications, displayedNotifications,
localNotifications, isClearing,
removeNotificationWithDelay,
userDetails, userDetails,
showSuccessToast, showSuccessToast,
showErrorToast, showErrorToast,
@@ -296,9 +363,19 @@ export default function Notifications() {
const handleLoadMore = useCallback(() => { const handleLoadMore = useCallback(() => {
if (pagination.hasMore && !isLoading) { if (pagination.hasMore && !isLoading) {
fetchApiNotifications(pagination.skip, true); fetchApiNotifications(pagination.skip, true, filter);
} }
}, [pagination, isLoading, fetchApiNotifications]); }, [pagination, isLoading, fetchApiNotifications, filter]);
const handleFilterChange = useCallback(
(newFilter: NotificationFilter) => {
if (newFilter !== filter) {
setFilter(newFilter);
setPagination({ total: 0, hasMore: false, skip: 0 });
}
},
[filter]
);
const handleAcceptFriendRequest = useCallback(() => { const handleAcceptFriendRequest = useCallback(() => {
showSuccessToast(t("friend_request_accepted")); showSuccessToast(t("friend_request_accepted"));
@@ -317,10 +394,13 @@ export default function Notifications() {
return ( return (
<motion.div <motion.div
key={key} key={key}
layout
initial={{ opacity: 0, x: -20 }} initial={{ opacity: 0, x: -20 }}
animate={{ opacity: 1, x: 0 }} animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: 100, transition: { duration: 0.2 } }} exit={{
opacity: 0,
x: 80,
transition: { duration: EXIT_DURATION_MS / 1000 },
}}
transition={{ duration: 0.2 }} transition={{ duration: 0.2 }}
> >
{notification.source === "local" ? ( {notification.source === "local" ? (
@@ -343,8 +423,57 @@ export default function Notifications() {
); );
}; };
const unreadCount = useMemo(() => {
const apiUnread = apiNotifications.filter((n) => !n.isRead).length;
const localUnread = localNotifications.filter((n) => !n.isRead).length;
return apiUnread + localUnread;
}, [apiNotifications, localNotifications]);
const renderFilterTabs = () => (
<div className="notifications__filter-tabs">
<div className="notifications__tab-wrapper">
<button
type="button"
className={`notifications__tab ${filter === "all" ? "notifications__tab--active" : ""}`}
onClick={() => handleFilterChange("all")}
>
{t("filter_all")}
</button>
{filter === "all" && (
<motion.div
className="notifications__tab-underline"
layoutId="notifications-tab-underline"
transition={{ type: "spring", stiffness: 300, damping: 30 }}
/>
)}
</div>
<div className="notifications__tab-wrapper">
<button
type="button"
className={`notifications__tab ${filter === "unread" ? "notifications__tab--active" : ""}`}
onClick={() => handleFilterChange("unread")}
>
{t("filter_unread")}
{unreadCount > 0 && (
<span className="notifications__tab-badge">{unreadCount}</span>
)}
</button>
{filter === "unread" && (
<motion.div
className="notifications__tab-underline"
layoutId="notifications-tab-underline"
transition={{ type: "spring", stiffness: 300, damping: 30 }}
/>
)}
</div>
</div>
);
const hasNoNotifications = mergedNotifications.length === 0;
const shouldDisableActions = isClearing || hasNoNotifications;
const renderContent = () => { const renderContent = () => {
if (isLoading && mergedNotifications.length === 0) { if (isLoading && hasNoNotifications) {
return ( return (
<div className="notifications__loading"> <div className="notifications__loading">
<span>{t("loading")}</span> <span>{t("loading")}</span>
@@ -352,36 +481,61 @@ export default function Notifications() {
); );
} }
if (mergedNotifications.length === 0) {
return (
<div className="notifications__empty">
<div className="notifications__icon-container">
<BellIcon size={24} />
</div>
<h2>{t("empty_title")}</h2>
<p>{t("empty_description")}</p>
</div>
);
}
return ( return (
<div className="notifications"> <div className="notifications">
<div className="notifications__actions"> <div className="notifications__header">
<Button theme="outline" onClick={handleMarkAllAsRead}> {renderFilterTabs()}
{t("mark_all_as_read")} <div className="notifications__actions">
</Button> <Button
<Button theme="danger" onClick={handleClearAll}> theme="outline"
{t("clear_all")} onClick={handleMarkAllAsRead}
</Button> disabled={shouldDisableActions}
>
{t("mark_all_as_read")}
</Button>
<Button
theme="danger"
onClick={handleClearAll}
disabled={shouldDisableActions}
>
{t("clear_all")}
</Button>
</div>
</div> </div>
<div className="notifications__list"> {/* Keep AnimatePresence mounted during clearing to preserve exit animations */}
<AnimatePresence mode="popLayout"> <AnimatePresence mode="wait">
{displayedNotifications.map(renderNotification)} <motion.div
</AnimatePresence> key={filter}
</div> className="notifications__content-wrapper"
initial={{ opacity: 0, x: -10 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: 10 }}
transition={{ duration: 0.2 }}
>
{hasNoNotifications && !isClearing ? (
<div className="notifications__empty">
<div className="notifications__icon-container">
<BellIcon size={24} />
</div>
<h2>{t("empty_title")}</h2>
<p>
{filter === "unread"
? t("empty_filter_description")
: t("empty_description")}
</p>
</div>
) : (
<div className="notifications__list">
<AnimatePresence>
{displayedNotifications.map(renderNotification)}
</AnimatePresence>
</div>
)}
</motion.div>
</AnimatePresence>
{pagination.hasMore && ( {pagination.hasMore && !isClearing && (
<div className="notifications__load-more"> <div className="notifications__load-more">
<Button <Button
theme="outline" theme="outline"

View File

@@ -100,8 +100,10 @@
padding: calc(globals.$spacing-unit * 1.5); padding: calc(globals.$spacing-unit * 1.5);
background-color: rgba(255, 255, 255, 0.05); background-color: rgba(255, 255, 255, 0.05);
border-radius: 8px; border-radius: 8px;
border: none;
cursor: pointer; cursor: pointer;
transition: all ease 0.2s; transition: all ease 0.2s;
text-align: left;
&:hover { &:hover {
background-color: rgba(255, 255, 255, 0.1); background-color: rgba(255, 255, 255, 0.1);

View File

@@ -4,6 +4,20 @@
&__box { &__box {
padding: calc(globals.$spacing-unit * 2); padding: calc(globals.$spacing-unit * 2);
position: relative; position: relative;
&--empty {
display: flex;
flex-direction: column;
align-items: center;
gap: calc(globals.$spacing-unit * 2);
}
}
&__empty-text {
color: globals.$muted-color;
font-size: globals.$small-font-size;
margin: 0;
text-align: center;
} }
&__add-friend-button { &__add-friend-button {

View File

@@ -19,6 +19,7 @@ export function FriendsBox() {
const [showAddFriendModal, setShowAddFriendModal] = useState(false); const [showAddFriendModal, setShowAddFriendModal] = useState(false);
const isMe = userDetails?.id === userProfile?.id; const isMe = userDetails?.id === userProfile?.id;
const hasFriends = userProfile?.friends && userProfile.friends.length > 0;
const getGameImage = (game: { iconUrl: string | null; title: string }) => { const getGameImage = (game: { iconUrl: string | null; title: string }) => {
if (game.iconUrl) { if (game.iconUrl) {
@@ -35,7 +36,15 @@ export function FriendsBox() {
return <SteamLogo width={16} height={16} />; return <SteamLogo width={16} height={16} />;
}; };
if (!userProfile?.friends.length) return null; if (!hasFriends) {
if (!isMe) return null;
return (
<div className="friends-box__box friends-box__box--empty">
<p className="friends-box__empty-text">{t("no_friends_yet")}</p>
</div>
);
}
const visibleFriends = userProfile.friends.slice(0, MAX_VISIBLE_FRIENDS); const visibleFriends = userProfile.friends.slice(0, MAX_VISIBLE_FRIENDS);
const totalFriends = userProfile.friends.length; const totalFriends = userProfile.friends.length;

View File

@@ -376,7 +376,7 @@ export function ProfileContent() {
const hasAnyGames = hasGames || hasPinnedGames; const hasAnyGames = hasGames || hasPinnedGames;
const shouldShowRightContent = const shouldShowRightContent =
hasAnyGames || userProfile.friends.length > 0; hasAnyGames || userProfile.friends.length > 0 || isMe;
return ( return (
<section className="profile-content__section"> <section className="profile-content__section">
@@ -444,7 +444,7 @@ export function ProfileContent() {
<RecentGamesBox /> <RecentGamesBox />
</ProfileSection> </ProfileSection>
)} )}
{userProfile?.friends.length > 0 && ( {(userProfile?.friends.length > 0 || isMe) && (
<ProfileSection <ProfileSection
title={t("friends")} title={t("friends")}
count={userStats?.friendsCount || userProfile.friends.length} count={userStats?.friendsCount || userProfile.friends.length}

View File

@@ -29,6 +29,12 @@
background-color: rgba(255, 255, 255, 0.15); background-color: rgba(255, 255, 255, 0.15);
text-decoration: none; text-decoration: none;
} }
&--wrapped {
&:hover {
background-color: transparent;
}
}
} }
&__list-title { &__list-title {
@@ -70,4 +76,15 @@
font-size: 0.75rem; font-size: 0.75rem;
line-height: 1.4; line-height: 1.4;
} }
&__wrapped-link {
background: none;
border: none;
padding: 0;
text-align: start;
color: globals.$body-color;
font-size: 0.875rem;
cursor: pointer;
text-decoration: underline;
}
} }

View File

@@ -1,4 +1,4 @@
import { useCallback, useContext } from "react"; import { useCallback, useContext, useState } from "react";
import { userProfileContext } from "@renderer/context"; import { userProfileContext } from "@renderer/context";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useFormat, useUserDetails } from "@renderer/hooks"; import { useFormat, useUserDetails } from "@renderer/hooks";
@@ -7,9 +7,11 @@ import HydraIcon from "@renderer/assets/icons/hydra.svg?react";
import { useSubscription } from "@renderer/hooks/use-subscription"; import { useSubscription } from "@renderer/hooks/use-subscription";
import { ClockIcon, TrophyIcon } from "@primer/octicons-react"; import { ClockIcon, TrophyIcon } from "@primer/octicons-react";
import { Award } from "lucide-react"; import { Award } from "lucide-react";
import { WrappedFullscreenModal } from "./wrapped-tab";
import "./user-stats-box.scss"; import "./user-stats-box.scss";
export function UserStatsBox() { export function UserStatsBox() {
const [showWrappedModal, setShowWrappedModal] = useState(false);
const { showHydraCloudModal } = useSubscription(); const { showHydraCloudModal } = useSubscription();
const { userStats, isMe, userProfile } = useContext(userProfileContext); const { userStats, isMe, userProfile } = useContext(userProfileContext);
const { userDetails } = useUserDetails(); const { userDetails } = useUserDetails();
@@ -41,6 +43,18 @@ export function UserStatsBox() {
return ( return (
<div className="user-stats__box"> <div className="user-stats__box">
<ul className="user-stats__list"> <ul className="user-stats__list">
{userProfile?.hasCompletedWrapped2025 && (
<li className="user-stats__list-item user-stats__list-item--wrapped">
<button
type="button"
onClick={() => setShowWrappedModal(true)}
className="user-stats__wrapped-link"
>
Wrapped 2025
</button>
</li>
)}
{(isMe || userStats.unlockedAchievementSum !== undefined) && ( {(isMe || userStats.unlockedAchievementSum !== undefined) && (
<li className="user-stats__list-item"> <li className="user-stats__list-item">
<h3 className="user-stats__list-title"> <h3 className="user-stats__list-title">
@@ -123,14 +137,17 @@ export function UserStatsBox() {
{t("karma_count")} {t("karma_count")}
</p> </p>
</div> </div>
<div className="user-stats__karma-info">
<small className="user-stats__karma-info-text">
{t("karma_description")}
</small>
</div>
</li> </li>
)} )}
</ul> </ul>
{userProfile && (
<WrappedFullscreenModal
userId={userProfile.id}
isOpen={showWrappedModal}
onClose={() => setShowWrappedModal(false)}
/>
)}
</div> </div>
); );
} }

View File

@@ -144,11 +144,6 @@
} }
} }
&__left-actions {
display: flex;
gap: globals.$spacing-unit;
}
&__actions { &__actions {
display: flex; display: flex;
gap: globals.$spacing-unit; gap: globals.$spacing-unit;
@@ -160,35 +155,5 @@
&--outline { &--outline {
border-color: globals.$body-color; border-color: globals.$body-color;
} }
&--wrapped {
background: linear-gradient(
120deg,
#2a57ff 0%,
#2951e6 11%,
#2f5bff 16%,
#2c56e8 29%,
#244acc 34%,
#2245c2 40%,
#3a6bff 45%,
#3766f2 50%,
#2444b8 56%,
#122a73 82%,
#2348b3 86%,
#1f429e 87%,
#10286a 93%,
#0e2a63 100%
);
background-color: #2a57ff;
background-size: 105% 100%;
background-position: 100% 50%;
border: none;
color: white;
transition: background-position 0.4s ease;
&:hover {
background-position: 0% 50%;
}
}
} }
} }

View File

@@ -7,7 +7,6 @@ import {
PencilIcon, PencilIcon,
PersonAddIcon, PersonAddIcon,
SignOutIcon, SignOutIcon,
TrophyIcon,
XCircleFillIcon, XCircleFillIcon,
} from "@primer/octicons-react"; } from "@primer/octicons-react";
import { buildGameDetailsPath } from "@renderer/helpers"; import { buildGameDetailsPath } from "@renderer/helpers";
@@ -30,7 +29,6 @@ import { motion } from "framer-motion";
import type { FriendRequestAction } from "@types"; import type { FriendRequestAction } from "@types";
import { EditProfileModal } from "../edit-profile-modal/edit-profile-modal"; import { EditProfileModal } from "../edit-profile-modal/edit-profile-modal";
import { WrappedFullscreenModal } from "../profile-content/wrapped-tab";
import Skeleton from "react-loading-skeleton"; import Skeleton from "react-loading-skeleton";
import { UploadBackgroundImageButton } from "../upload-background-image-button/upload-background-image-button"; import { UploadBackgroundImageButton } from "../upload-background-image-button/upload-background-image-button";
import "./profile-hero.scss"; import "./profile-hero.scss";
@@ -41,10 +39,10 @@ type FriendAction =
export function ProfileHero() { export function ProfileHero() {
const [showEditProfileModal, setShowEditProfileModal] = useState(false); const [showEditProfileModal, setShowEditProfileModal] = useState(false);
const [showWrappedModal, setShowWrappedModal] = useState(false);
const [showFullscreenAvatar, setShowFullscreenAvatar] = useState(false); const [showFullscreenAvatar, setShowFullscreenAvatar] = useState(false);
const [isPerformingAction, setIsPerformingAction] = useState(false); const [isPerformingAction, setIsPerformingAction] = useState(false);
const [isCopyButtonHovered, setIsCopyButtonHovered] = useState(false); const [isCopyButtonHovered, setIsCopyButtonHovered] = useState(false);
const [isCopied, setIsCopied] = useState(false);
const { isMe, getUserProfile, userProfile, heroBackground, backgroundImage } = const { isMe, getUserProfile, userProfile, heroBackground, backgroundImage } =
useContext(userProfileContext); useContext(userProfileContext);
@@ -261,9 +259,23 @@ export function ProfileHero() {
const copyFriendCode = useCallback(() => { const copyFriendCode = useCallback(() => {
if (userProfile?.id) { if (userProfile?.id) {
navigator.clipboard.writeText(userProfile.id); navigator.clipboard.writeText(userProfile.id);
showSuccessToast(t("friend_code_copied")); setIsCopied(true);
const startTime = performance.now();
const duration = 1200; // 1.2 seconds
const animate = (currentTime: number) => {
const elapsed = currentTime - startTime;
if (elapsed < duration) {
requestAnimationFrame(animate);
} else {
setIsCopied(false);
}
};
requestAnimationFrame(animate);
} }
}, [userProfile, showSuccessToast, t]); }, [userProfile]);
const currentGame = useMemo(() => { const currentGame = useMemo(() => {
if (isMe) { if (isMe) {
@@ -286,13 +298,6 @@ export function ProfileHero() {
onClose={() => setShowEditProfileModal(false)} onClose={() => setShowEditProfileModal(false)}
/> />
{userProfile && (
<WrappedFullscreenModal
userId={userProfile.id}
isOpen={showWrappedModal}
onClose={() => setShowWrappedModal(false)}
/>
)}
<FullscreenMediaModal <FullscreenMediaModal
visible={showFullscreenAvatar} visible={showFullscreenAvatar}
onClose={() => setShowFullscreenAvatar(false)} onClose={() => setShowFullscreenAvatar(false)}
@@ -348,7 +353,7 @@ export function ProfileHero() {
onMouseLeave={() => setIsCopyButtonHovered(false)} onMouseLeave={() => setIsCopyButtonHovered(false)}
initial={{ width: 28 }} initial={{ width: 28 }}
animate={{ animate={{
width: isCopyButtonHovered ? 105 : 28, width: isCopyButtonHovered || isCopied ? 105 : 28,
}} }}
transition={{ duration: 0.2, ease: "easeInOut" }} transition={{ duration: 0.2, ease: "easeInOut" }}
> >
@@ -356,12 +361,12 @@ export function ProfileHero() {
className="profile-hero__friend-code" className="profile-hero__friend-code"
initial={{ opacity: 0, marginRight: 0 }} initial={{ opacity: 0, marginRight: 0 }}
animate={{ animate={{
opacity: isCopyButtonHovered ? 1 : 0, opacity: isCopyButtonHovered || isCopied ? 1 : 0,
marginRight: isCopyButtonHovered ? 8 : 0, marginRight: isCopyButtonHovered || isCopied ? 8 : 0,
}} }}
transition={{ duration: 0.2, ease: "easeInOut" }} transition={{ duration: 0.2, ease: "easeInOut" }}
> >
{userProfile?.id} {isCopied ? t("copied") : userProfile?.id}
</motion.span> </motion.span>
<CopyIcon size={16} /> <CopyIcon size={16} />
</motion.button> </motion.button>
@@ -410,22 +415,6 @@ export function ProfileHero() {
background: !backgroundImage ? heroBackground : undefined, background: !backgroundImage ? heroBackground : undefined,
}} }}
> >
{userProfile?.hasCompletedWrapped2025 && (
<div className="profile-hero__left-actions">
<Button
theme="outline"
onClick={() => setShowWrappedModal(true)}
className="profile-hero__button--wrapped"
>
<TrophyIcon />
{isMe
? t("view_my_wrapped_button")
: t("view_wrapped_button", {
displayName: userProfile.displayName,
})}
</Button>
</div>
)}
<div className="profile-hero__actions">{profileActions}</div> <div className="profile-hero__actions">{profileActions}</div>
</div> </div>
</section> </section>

View File

@@ -1,11 +1,86 @@
@use "../../../scss/globals.scss"; @use "../../../scss/globals.scss";
.upload-background-image-button { .upload-background-image-button {
position: absolute; &__wrapper {
top: 16px; position: absolute;
right: 16px; top: 16px;
right: 16px;
}
border-color: globals.$body-color; border-color: globals.$body-color;
box-shadow: 0px 0px 10px 0px rgba(0, 0, 0, 0.8); box-shadow: 0px 0px 10px 0px rgba(0, 0, 0, 0.8);
background-color: rgba(0, 0, 0, 0.1); background-color: rgba(0, 0, 0, 0.1);
backdrop-filter: blur(20px); backdrop-filter: blur(20px);
&__menu {
background-color: rgba(0, 0, 0, 0.4);
backdrop-filter: blur(20px);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 6px;
min-width: 180px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4);
z-index: 1000;
padding: 4px;
display: flex;
flex-direction: column;
animation: menu-fade-in 0.2s ease-out;
&--closing {
animation: menu-fade-out 0.15s ease-in;
}
}
&__menu-item {
width: 100%;
display: flex;
flex-direction: row;
align-items: center;
justify-content: flex-start;
gap: 8px;
border-radius: 4px;
padding: 5px 12px;
cursor: pointer;
transition: background-color 0.1s ease-in-out;
font-size: 14px;
background: none;
border: none;
color: globals.$body-color;
text-align: left;
&:hover:not(:disabled) {
background-color: rgba(255, 255, 255, 0.1);
}
&:disabled {
cursor: default;
opacity: 0.6;
}
&:focus {
background-color: rgba(255, 255, 255, 0.1);
outline: none;
}
}
}
@keyframes menu-fade-in {
0% {
opacity: 0;
transform: translateY(-8px);
}
100% {
opacity: 1;
transform: translateY(0);
}
}
@keyframes menu-fade-out {
0% {
opacity: 1;
transform: translateY(0);
}
100% {
opacity: 0;
transform: translateY(-8px);
}
} }

View File

@@ -1,6 +1,8 @@
import { UploadIcon } from "@primer/octicons-react"; import { TrashIcon, UploadIcon } from "@primer/octicons-react";
import { Button } from "@renderer/components"; import { MoreVertical } from "lucide-react";
import { useContext, useState } from "react"; import { Button, ConfirmationModal } from "@renderer/components";
import { createPortal } from "react-dom";
import { useContext, useEffect, useRef, useState } from "react";
import { userProfileContext } from "@renderer/context"; import { userProfileContext } from "@renderer/context";
import { useToast, useUserDetails } from "@renderer/hooks"; import { useToast, useUserDetails } from "@renderer/hooks";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
@@ -9,16 +11,33 @@ import "./upload-background-image-button.scss";
export function UploadBackgroundImageButton() { export function UploadBackgroundImageButton() {
const [isUploadingBackgroundImage, setIsUploadingBackgorundImage] = const [isUploadingBackgroundImage, setIsUploadingBackgorundImage] =
useState(false); useState(false);
const [isMenuOpen, setIsMenuOpen] = useState(false);
const [isMenuClosing, setIsMenuClosing] = useState(false);
const [showRemoveBannerModal, setShowRemoveBannerModal] = useState(false);
const buttonRef = useRef<HTMLDivElement>(null);
const menuRef = useRef<HTMLDivElement>(null);
const { hasActiveSubscription } = useUserDetails(); const { hasActiveSubscription } = useUserDetails();
const { t } = useTranslation("user_profile"); const { t } = useTranslation("user_profile");
const { isMe, setSelectedBackgroundImage } = useContext(userProfileContext); const { isMe, setSelectedBackgroundImage, userProfile, getUserProfile } =
useContext(userProfileContext);
const { patchUser, fetchUserDetails } = useUserDetails(); const { patchUser, fetchUserDetails } = useUserDetails();
const { showSuccessToast } = useToast(); const { showSuccessToast } = useToast();
const handleChangeCoverClick = async () => { const hasBanner = !!userProfile?.backgroundImageUrl;
const closeMenu = () => {
setIsMenuClosing(true);
setTimeout(() => {
setIsMenuOpen(false);
setIsMenuClosing(false);
}, 150);
};
const handleReplaceBanner = async () => {
closeMenu();
try { try {
const { filePaths } = await window.electron.showOpenDialog({ const { filePaths } = await window.electron.showOpenDialog({
properties: ["openFile"], properties: ["openFile"],
@@ -40,23 +59,159 @@ export function UploadBackgroundImageButton() {
showSuccessToast(t("background_image_updated")); showSuccessToast(t("background_image_updated"));
await fetchUserDetails(); await fetchUserDetails();
await getUserProfile();
} }
} finally { } finally {
setIsUploadingBackgorundImage(false); setIsUploadingBackgorundImage(false);
} }
}; };
const handleRemoveBannerClick = () => {
closeMenu();
setShowRemoveBannerModal(true);
};
const handleRemoveBannerConfirm = async () => {
setShowRemoveBannerModal(false);
try {
setIsUploadingBackgorundImage(true);
setSelectedBackgroundImage("");
await patchUser({ backgroundImageUrl: null });
showSuccessToast(t("background_image_updated"));
await fetchUserDetails();
await getUserProfile();
} finally {
setIsUploadingBackgorundImage(false);
}
};
// Handle click outside, scroll, and escape key to close menu
useEffect(() => {
if (!isMenuOpen) return;
const handleClickOutside = (event: MouseEvent) => {
const target = event.target as Node;
if (
menuRef.current &&
!menuRef.current.contains(target) &&
buttonRef.current &&
!buttonRef.current.contains(target)
) {
closeMenu();
}
};
const handleEscape = (event: KeyboardEvent) => {
if (event.key === "Escape") {
closeMenu();
}
};
const handleScroll = () => {
closeMenu();
};
document.addEventListener("mousedown", handleClickOutside);
document.addEventListener("keydown", handleEscape);
window.addEventListener("scroll", handleScroll, true);
return () => {
document.removeEventListener("mousedown", handleClickOutside);
document.removeEventListener("keydown", handleEscape);
window.removeEventListener("scroll", handleScroll, true);
};
}, [isMenuOpen]);
if (!isMe || !hasActiveSubscription) return null; if (!isMe || !hasActiveSubscription) return null;
return ( // If no banner exists, show the original upload button
<Button if (!hasBanner) {
theme="outline" return (
className="upload-background-image-button" <div className="upload-background-image-button__wrapper">
onClick={handleChangeCoverClick} <Button
disabled={isUploadingBackgroundImage} theme="outline"
className="upload-background-image-button"
onClick={handleReplaceBanner}
disabled={isUploadingBackgroundImage}
>
<UploadIcon />
{isUploadingBackgroundImage
? t("uploading_banner")
: t("upload_banner")}
</Button>
</div>
);
}
// Calculate menu position
const getMenuPosition = () => {
if (!buttonRef.current) return { top: 0, right: 0 };
const rect = buttonRef.current.getBoundingClientRect();
return {
top: rect.bottom + 5,
right: window.innerWidth - rect.right,
};
};
const menuPosition = isMenuOpen ? getMenuPosition() : { top: 0, right: 0 };
const menuContent = isMenuOpen && (
<div
ref={menuRef}
className={`upload-background-image-button__menu ${
isMenuClosing ? "upload-background-image-button__menu--closing" : ""
}`}
style={{
position: "fixed",
top: `${menuPosition.top}px`,
right: `${menuPosition.right}px`,
}}
> >
<UploadIcon /> <button
{isUploadingBackgroundImage ? t("uploading_banner") : t("upload_banner")} type="button"
</Button> className="upload-background-image-button__menu-item"
onClick={handleReplaceBanner}
disabled={isUploadingBackgroundImage}
>
<UploadIcon size={16} />
{t("replace_banner")}
</button>
<button
type="button"
className="upload-background-image-button__menu-item"
onClick={handleRemoveBannerClick}
disabled={isUploadingBackgroundImage}
>
<TrashIcon size={16} />
{t("remove_banner")}
</button>
</div>
);
return (
<>
<div ref={buttonRef} className="upload-background-image-button__wrapper">
<Button
theme="outline"
className="upload-background-image-button"
onClick={() => setIsMenuOpen(!isMenuOpen)}
disabled={isUploadingBackgroundImage}
>
{t("change_banner")}
<MoreVertical size={16} />
</Button>
</div>
{createPortal(menuContent, document.body)}
<ConfirmationModal
visible={showRemoveBannerModal}
title={t("remove_banner_modal_title")}
descriptionText={t("remove_banner_confirmation")}
onClose={() => setShowRemoveBannerModal(false)}
onConfirm={handleRemoveBannerConfirm}
cancelButtonLabel={t("cancel")}
confirmButtonLabel={t("remove")}
buttonsIsDisabled={isUploadingBackgroundImage}
/>
</>
); );
} }

View File

@@ -18,6 +18,13 @@
align-self: flex-start; align-self: flex-start;
} }
&__disabled-hint {
font-size: 13px;
color: globals.$muted-color;
margin-top: calc(globals.$spacing-unit * -1);
font-style: italic;
}
&__volume-control { &__volume-control {
display: flex; display: flex;
flex-direction: column; flex-direction: column;

View File

@@ -37,6 +37,12 @@ export function SettingsGeneral() {
(state) => state.userPreferences.value (state) => state.userPreferences.value
); );
const lastPacket = useAppSelector((state) => state.download.lastPacket);
const hasActiveDownload =
lastPacket !== null &&
lastPacket.progress < 1 &&
!lastPacket.isDownloadingMetadata;
const [canInstallCommonRedist, setCanInstallCommonRedist] = useState(false); const [canInstallCommonRedist, setCanInstallCommonRedist] = useState(false);
const [installingCommonRedist, setInstallingCommonRedist] = useState(false); const [installingCommonRedist, setInstallingCommonRedist] = useState(false);
@@ -53,6 +59,7 @@ export function SettingsGeneral() {
achievementSoundVolume: 15, achievementSoundVolume: 15,
language: "", language: "",
customStyles: window.localStorage.getItem("customStyles") || "", customStyles: window.localStorage.getItem("customStyles") || "",
useNativeHttpDownloader: true,
}); });
const [languageOptions, setLanguageOptions] = useState<LanguageOption[]>([]); const [languageOptions, setLanguageOptions] = useState<LanguageOption[]>([]);
@@ -131,6 +138,8 @@ export function SettingsGeneral() {
friendStartGameNotificationsEnabled: friendStartGameNotificationsEnabled:
userPreferences.friendStartGameNotificationsEnabled ?? true, userPreferences.friendStartGameNotificationsEnabled ?? true,
language: language ?? "en", language: language ?? "en",
useNativeHttpDownloader:
userPreferences.useNativeHttpDownloader ?? true,
})); }));
} }
}, [userPreferences, defaultDownloadsPath]); }, [userPreferences, defaultDownloadsPath]);
@@ -248,6 +257,25 @@ export function SettingsGeneral() {
}))} }))}
/> />
<h2 className="settings-general__section-title">{t("downloads")}</h2>
<CheckboxField
label={t("use_native_http_downloader")}
checked={form.useNativeHttpDownloader}
disabled={hasActiveDownload}
onChange={() =>
handleChange({
useNativeHttpDownloader: !form.useNativeHttpDownloader,
})
}
/>
{hasActiveDownload && (
<p className="settings-general__disabled-hint">
{t("cannot_change_downloader_while_downloading")}
</p>
)}
<h2 className="settings-general__section-title">{t("notifications")}</h2> <h2 className="settings-general__section-title">{t("notifications")}</h2>
<CheckboxField <CheckboxField

View File

@@ -18,7 +18,7 @@ $active-opacity: 0.7;
$spacing-unit: 8px; $spacing-unit: 8px;
$toast-z-index: 5; $toast-z-index: 150;
$bottom-panel-z-index: 3; $bottom-panel-z-index: 3;
$title-bar-z-index: 4; $title-bar-z-index: 4;
$backdrop-z-index: 4; $backdrop-z-index: 4;

View File

@@ -3,13 +3,14 @@ export enum Downloader {
Torrent, Torrent,
Gofile, Gofile,
PixelDrain, PixelDrain,
Qiwi,
Datanodes, Datanodes,
Mediafire, Mediafire,
TorBox, TorBox,
Hydra, Hydra,
Buzzheavier, Buzzheavier,
FuckingFast, FuckingFast,
VikingFile,
Rootz,
} }
export enum DownloadSourceStatus { export enum DownloadSourceStatus {

View File

@@ -110,7 +110,6 @@ export const getDownloadersForUri = (uri: string) => {
if (uri.startsWith("https://gofile.io")) return [Downloader.Gofile]; if (uri.startsWith("https://gofile.io")) return [Downloader.Gofile];
if (uri.startsWith("https://pixeldrain.com")) return [Downloader.PixelDrain]; if (uri.startsWith("https://pixeldrain.com")) return [Downloader.PixelDrain];
if (uri.startsWith("https://qiwi.gg")) return [Downloader.Qiwi];
if (uri.startsWith("https://datanodes.to")) return [Downloader.Datanodes]; if (uri.startsWith("https://datanodes.to")) return [Downloader.Datanodes];
if (uri.startsWith("https://www.mediafire.com")) if (uri.startsWith("https://www.mediafire.com"))
return [Downloader.Mediafire]; return [Downloader.Mediafire];
@@ -124,6 +123,12 @@ export const getDownloadersForUri = (uri: string) => {
if (uri.startsWith("https://fuckingfast.co")) { if (uri.startsWith("https://fuckingfast.co")) {
return [Downloader.FuckingFast]; return [Downloader.FuckingFast];
} }
if (uri.startsWith("https://vikingfile.com")) {
return [Downloader.VikingFile];
}
if (uri.startsWith("https://www.rootz.so")) {
return [Downloader.Rootz];
}
if (realDebridHosts.some((host) => uri.startsWith(host))) if (realDebridHosts.some((host) => uri.startsWith(host)))
return [Downloader.RealDebrid]; return [Downloader.RealDebrid];

View File

@@ -20,6 +20,7 @@ export interface GameRepack {
title: string; title: string;
fileSize: string | null; fileSize: string | null;
uris: string[]; uris: string[];
unavailableUris: string[];
uploadDate: string | null; uploadDate: string | null;
downloadSourceId: string; downloadSourceId: string;
downloadSourceName: string; downloadSourceName: string;
@@ -187,6 +188,7 @@ export interface UserDetails {
profileVisibility: ProfileVisibility; profileVisibility: ProfileVisibility;
bio: string; bio: string;
featurebaseJwt: string; featurebaseJwt: string;
workwondersJwt: string;
subscription: Subscription | null; subscription: Subscription | null;
karma: number; karma: number;
quirks?: { quirks?: {

View File

@@ -128,6 +128,7 @@ export interface UserPreferences {
autoplayGameTrailers?: boolean; autoplayGameTrailers?: boolean;
hideToTrayOnGameStart?: boolean; hideToTrayOnGameStart?: boolean;
enableNewDownloadOptionsBadges?: boolean; enableNewDownloadOptionsBadges?: boolean;
useNativeHttpDownloader?: boolean;
} }
export interface ScreenState { export interface ScreenState {

View File

@@ -2203,14 +2203,15 @@
tslib "^2.6.2" tslib "^2.6.2"
"@smithy/config-resolver@^4.3.0", "@smithy/config-resolver@^4.3.1": "@smithy/config-resolver@^4.3.0", "@smithy/config-resolver@^4.3.1":
version "4.3.1" version "4.4.5"
resolved "https://registry.yarnpkg.com/@smithy/config-resolver/-/config-resolver-4.3.1.tgz#f1a0ed6faa52377909440002e1632be9fc901840" resolved "https://registry.yarnpkg.com/@smithy/config-resolver/-/config-resolver-4.4.5.tgz#35e792b6db00887bdd029df9b41780ca005d064b"
integrity sha512-tWDwrWy37CDVGeaP8AIGZPFL2RoFtmd5Y+nTzLw5qroXNedT2S66EY2d+XzB1zxulCd6nfDXnAQu4auq90aj5Q== integrity sha512-HAGoUAFYsUkoSckuKbCPayECeMim8pOu+yLy1zOxt1sifzEbrsRpYa+mKcMdiHKMeiqOibyPG0sFJnmaV/OGEg==
dependencies: dependencies:
"@smithy/node-config-provider" "^4.3.1" "@smithy/node-config-provider" "^4.3.7"
"@smithy/types" "^4.7.0" "@smithy/types" "^4.11.0"
"@smithy/util-config-provider" "^4.2.0" "@smithy/util-config-provider" "^4.2.0"
"@smithy/util-middleware" "^4.2.1" "@smithy/util-endpoints" "^3.2.7"
"@smithy/util-middleware" "^4.2.7"
tslib "^2.6.2" tslib "^2.6.2"
"@smithy/core@^3.15.0", "@smithy/core@^3.16.0": "@smithy/core@^3.15.0", "@smithy/core@^3.16.0":
@@ -2421,6 +2422,16 @@
"@smithy/types" "^4.7.0" "@smithy/types" "^4.7.0"
tslib "^2.6.2" tslib "^2.6.2"
"@smithy/node-config-provider@^4.3.7":
version "4.3.7"
resolved "https://registry.yarnpkg.com/@smithy/node-config-provider/-/node-config-provider-4.3.7.tgz#c023fa857b008c314f621fb5b124724c157b2fd3"
integrity sha512-7r58wq8sdOcrwWe+klL9y3bc4GW1gnlfnFOuL7CXa7UzfhzhxKuzNdtqgzmTV+53lEp9NXh5hY/S4UgjLOzPfw==
dependencies:
"@smithy/property-provider" "^4.2.7"
"@smithy/shared-ini-file-loader" "^4.4.2"
"@smithy/types" "^4.11.0"
tslib "^2.6.2"
"@smithy/node-http-handler@^4.3.0", "@smithy/node-http-handler@^4.4.0": "@smithy/node-http-handler@^4.3.0", "@smithy/node-http-handler@^4.4.0":
version "4.4.0" version "4.4.0"
resolved "https://registry.yarnpkg.com/@smithy/node-http-handler/-/node-http-handler-4.4.0.tgz#e1f6ae4a90cd7257699263bf8e06e653ff0e5f83" resolved "https://registry.yarnpkg.com/@smithy/node-http-handler/-/node-http-handler-4.4.0.tgz#e1f6ae4a90cd7257699263bf8e06e653ff0e5f83"
@@ -2440,6 +2451,14 @@
"@smithy/types" "^4.7.0" "@smithy/types" "^4.7.0"
tslib "^2.6.2" tslib "^2.6.2"
"@smithy/property-provider@^4.2.7":
version "4.2.7"
resolved "https://registry.yarnpkg.com/@smithy/property-provider/-/property-provider-4.2.7.tgz#cd0044e13495cf4064b3a6ed3299e5f549ba7513"
integrity sha512-jmNYKe9MGGPoSl/D7JDDs1C8b3dC8f/w78LbaVfoTtWy4xAd5dfjaFG9c9PWPihY4ggMQNQSMtzU77CNgAJwmA==
dependencies:
"@smithy/types" "^4.11.0"
tslib "^2.6.2"
"@smithy/protocol-http@^5.3.0", "@smithy/protocol-http@^5.3.1": "@smithy/protocol-http@^5.3.0", "@smithy/protocol-http@^5.3.1":
version "5.3.1" version "5.3.1"
resolved "https://registry.yarnpkg.com/@smithy/protocol-http/-/protocol-http-5.3.1.tgz#add01f73290f1e8fd49d7102b63e3fe53a5e6e18" resolved "https://registry.yarnpkg.com/@smithy/protocol-http/-/protocol-http-5.3.1.tgz#add01f73290f1e8fd49d7102b63e3fe53a5e6e18"
@@ -2480,6 +2499,14 @@
"@smithy/types" "^4.7.0" "@smithy/types" "^4.7.0"
tslib "^2.6.2" tslib "^2.6.2"
"@smithy/shared-ini-file-loader@^4.4.2":
version "4.4.2"
resolved "https://registry.yarnpkg.com/@smithy/shared-ini-file-loader/-/shared-ini-file-loader-4.4.2.tgz#8fa1b459de485b11185fe8c64182e3205a280ba9"
integrity sha512-M7iUUff/KwfNunmrgtqBfvZSzh3bmFgv/j/t1Y1dQ+8dNo34br1cqVEqy6v0mYEgi0DkGO7Xig0AnuOaEGVlcg==
dependencies:
"@smithy/types" "^4.11.0"
tslib "^2.6.2"
"@smithy/signature-v4@^5.3.0": "@smithy/signature-v4@^5.3.0":
version "5.3.1" version "5.3.1"
resolved "https://registry.yarnpkg.com/@smithy/signature-v4/-/signature-v4-5.3.1.tgz#c3d711c29d37f3db4daf51750eea75204c4f51d4" resolved "https://registry.yarnpkg.com/@smithy/signature-v4/-/signature-v4-5.3.1.tgz#c3d711c29d37f3db4daf51750eea75204c4f51d4"
@@ -2507,6 +2534,13 @@
"@smithy/util-stream" "^4.5.1" "@smithy/util-stream" "^4.5.1"
tslib "^2.6.2" tslib "^2.6.2"
"@smithy/types@^4.11.0":
version "4.11.0"
resolved "https://registry.yarnpkg.com/@smithy/types/-/types-4.11.0.tgz#c02f6184dcb47c4f0b387a32a7eca47956cc09f1"
integrity sha512-mlrmL0DRDVe3mNrjTcVcZEgkFmufITfUAPBEA+AHYiIeYyJebso/He1qLbP3PssRe22KUzLRpQSdBPbXdgZ2VA==
dependencies:
tslib "^2.6.2"
"@smithy/types@^4.6.0", "@smithy/types@^4.7.0": "@smithy/types@^4.6.0", "@smithy/types@^4.7.0":
version "4.7.0" version "4.7.0"
resolved "https://registry.yarnpkg.com/@smithy/types/-/types-4.7.0.tgz#42d707276d9184aef705f04e04615cd1979d044f" resolved "https://registry.yarnpkg.com/@smithy/types/-/types-4.7.0.tgz#42d707276d9184aef705f04e04615cd1979d044f"
@@ -2601,6 +2635,15 @@
"@smithy/types" "^4.7.0" "@smithy/types" "^4.7.0"
tslib "^2.6.2" tslib "^2.6.2"
"@smithy/util-endpoints@^3.2.7":
version "3.2.7"
resolved "https://registry.yarnpkg.com/@smithy/util-endpoints/-/util-endpoints-3.2.7.tgz#78cd5dd4aac8d9977f49d256d1e3418a09cade72"
integrity sha512-s4ILhyAvVqhMDYREeTS68R43B1V5aenV5q/V1QpRQJkCXib5BPRo4s7uNdzGtIKxaPHCfU/8YkvPAEvTpxgspg==
dependencies:
"@smithy/node-config-provider" "^4.3.7"
"@smithy/types" "^4.11.0"
tslib "^2.6.2"
"@smithy/util-hex-encoding@^4.2.0": "@smithy/util-hex-encoding@^4.2.0":
version "4.2.0" version "4.2.0"
resolved "https://registry.yarnpkg.com/@smithy/util-hex-encoding/-/util-hex-encoding-4.2.0.tgz#1c22ea3d1e2c3a81ff81c0a4f9c056a175068a7b" resolved "https://registry.yarnpkg.com/@smithy/util-hex-encoding/-/util-hex-encoding-4.2.0.tgz#1c22ea3d1e2c3a81ff81c0a4f9c056a175068a7b"
@@ -2616,6 +2659,14 @@
"@smithy/types" "^4.7.0" "@smithy/types" "^4.7.0"
tslib "^2.6.2" tslib "^2.6.2"
"@smithy/util-middleware@^4.2.7":
version "4.2.7"
resolved "https://registry.yarnpkg.com/@smithy/util-middleware/-/util-middleware-4.2.7.tgz#1cae2c4fd0389ac858d29f7170c33b4443e83524"
integrity sha512-i1IkpbOae6NvIKsEeLLM9/2q4X+M90KV3oCFgWQI4q0Qz+yUZvsr+gZPdAEAtFhWQhAHpTsJO8DRJPuwVyln+w==
dependencies:
"@smithy/types" "^4.11.0"
tslib "^2.6.2"
"@smithy/util-retry@^4.2.0", "@smithy/util-retry@^4.2.1": "@smithy/util-retry@^4.2.0", "@smithy/util-retry@^4.2.1":
version "4.2.1" version "4.2.1"
resolved "https://registry.yarnpkg.com/@smithy/util-retry/-/util-retry-4.2.1.tgz#8336368586a458cdce86fc92d6fb11fd1db41521" resolved "https://registry.yarnpkg.com/@smithy/util-retry/-/util-retry-4.2.1.tgz#8336368586a458cdce86fc92d6fb11fd1db41521"
@@ -6354,6 +6405,11 @@ keyv@^4.0.0, keyv@^4.5.3:
dependencies: dependencies:
json-buffer "3.0.1" json-buffer "3.0.1"
ky@^1.11.0:
version "1.14.1"
resolved "https://registry.yarnpkg.com/ky/-/ky-1.14.1.tgz#16f20b3bf3939abcc04e2a9613f47360fe5f64c9"
integrity sha512-hYje4L9JCmpEQBtudo+v52X5X8tgWXUYyPcxKSuxQNboqufecl9VMWjGiucAFH060AwPXHZuH+WB2rrqfkmafw==
language-subtag-registry@^0.3.20: language-subtag-registry@^0.3.20:
version "0.3.23" version "0.3.23"
resolved "https://registry.yarnpkg.com/language-subtag-registry/-/language-subtag-registry-0.3.23.tgz#23529e04d9e3b74679d70142df3fd2eb6ec572e7" resolved "https://registry.yarnpkg.com/language-subtag-registry/-/language-subtag-registry-0.3.23.tgz#23529e04d9e3b74679d70142df3fd2eb6ec572e7"
@@ -9123,6 +9179,13 @@ word-wrap@^1.2.5:
resolved "https://registry.yarnpkg.com/word-wrap/-/word-wrap-1.2.5.tgz#d2c45c6dd4fbce621a66f136cbe328afd0410b34" resolved "https://registry.yarnpkg.com/word-wrap/-/word-wrap-1.2.5.tgz#d2c45c6dd4fbce621a66f136cbe328afd0410b34"
integrity sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA== integrity sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==
workwonders-sdk@0.0.10:
version "0.0.10"
resolved "https://registry.yarnpkg.com/workwonders-sdk/-/workwonders-sdk-0.0.10.tgz#377167370a39c905c5228f8972c37c19004b7b21"
integrity sha512-bnswhlLRz1TCiqGV8l+VEOBej7u1SAkzLMEv6A60Sp0+S4j4pnmSve92KeOts/GYtUeNDuNM7fLPwZwMKY3sAg==
dependencies:
ky "^1.11.0"
"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0": "wrap-ansi-cjs@npm:wrap-ansi@^7.0.0":
version "7.0.0" version "7.0.0"
resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43"