Compare commits

..

1 Commits

Author SHA1 Message Date
Moyasee
96b12cb27e feat: enhance notifications functionality with sync and visibility change handling 2025-12-27 04:15:15 +02:00
38 changed files with 238 additions and 1259 deletions

View File

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

View File

@@ -57,7 +57,6 @@ jobs:
MAIN_VITE_AUTH_URL: ${{ vars.MAIN_VITE_STAGING_AUTH_URL }}
MAIN_VITE_CHECKOUT_URL: ${{ vars.MAIN_VITE_STAGING_CHECKOUT_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 }}
MAIN_VITE_EXTERNAL_RESOURCES_URL: ${{ vars.EXTERNAL_RESOURCES_URL }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
@@ -74,7 +73,6 @@ jobs:
MAIN_VITE_AUTH_URL: ${{ vars.MAIN_VITE_STAGING_AUTH_URL }}
MAIN_VITE_CHECKOUT_URL: ${{ vars.MAIN_VITE_STAGING_CHECKOUT_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 }}
MAIN_VITE_EXTERNAL_RESOURCES_URL: ${{ vars.EXTERNAL_RESOURCES_URL }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

View File

@@ -54,10 +54,9 @@ jobs:
MAIN_VITE_API_URL: ${{ vars.MAIN_VITE_API_URL }}
MAIN_VITE_AUTH_URL: ${{ vars.MAIN_VITE_AUTH_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 }}
MAIN_VITE_EXTERNAL_RESOURCES_URL: ${{ vars.EXTERNAL_RESOURCES_URL }}
MAIN_VITE_WS_URL: ${{ vars.MAIN_VITE_WS_URL }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
RENDERER_VITE_SENTRY_DSN: ${{ vars.SENTRY_DSN }}
@@ -72,10 +71,9 @@ jobs:
MAIN_VITE_API_URL: ${{ vars.MAIN_VITE_API_URL }}
MAIN_VITE_AUTH_URL: ${{ vars.MAIN_VITE_AUTH_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 }}
MAIN_VITE_EXTERNAL_RESOURCES_URL: ${{ vars.EXTERNAL_RESOURCES_URL }}
MAIN_VITE_WS_URL: ${{ vars.MAIN_VITE_WS_URL }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
RENDERER_VITE_SENTRY_DSN: ${{ vars.SENTRY_DSN }}

View File

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

View File

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

View File

@@ -175,7 +175,6 @@
"repacks_modal_description": "Choose the repack you want to download",
"select_folder_hint": "To change the default folder, go to the <0>Settings</0>",
"download_now": "Download now",
"loading": "Loading...",
"no_shop_details": "Could not retrieve shop details.",
"download_options": "Download options",
"download_path": "Download path",
@@ -185,12 +184,6 @@
"open_screenshot": "Open screenshot {{number}}",
"download_settings": "Download settings",
"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",
"no_executable_selected": "No executable selected",
"open_folder": "Open folder",
@@ -735,6 +728,7 @@
"game_added_to_pinned": "Game added to pinned",
"karma": "Karma",
"karma_count": "karma",
"karma_description": "Earned from positive likes on reviews",
"user_reviews": "Reviews",
"delete_review": "Delete Review",
"loading_reviews": "Loading reviews...",
@@ -801,7 +795,6 @@
"empty_description": "You're all caught up! Check back later for new updates.",
"empty_filter_description": "No notifications match this filter.",
"filter_all": "All",
"filter_unread": "Unread",
"filter_friends": "Friends",
"filter_badges": "Badges",
"filter_upvotes": "Upvotes",

View File

@@ -182,12 +182,6 @@
"open_screenshot": "Abrir captura número {{number}}",
"download_settings": "Descargar ajustes",
"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",
"no_executable_selected": "Sin ejecutable seleccionado",
"open_folder": "Abrir carpeta",
@@ -657,7 +651,6 @@
"sending": "Enviando",
"friend_request_sent": "Solicitud de amistad enviada",
"friends": "Amistades",
"badges": "Insignias",
"friends_list": "Lista de amistades",
"user_not_found": "Usuario no encontrado",
"block_user": "Bloquear usuario",
@@ -668,16 +661,12 @@
"ignore_request": "Ignorar solicitud",
"cancel_request": "Cancelar solicitud",
"undo_friendship": "Deshacer amistad",
"friendship_removed": "Amigo eliminado",
"request_accepted": "Solicitud aceptada",
"user_blocked_successfully": "Usuario bloqueado exitosamente",
"user_block_modal_text": "Esto va a bloquear a {{displayName}}",
"blocked_users": "Usuarios bloqueados",
"unblock": "Desbloquear",
"no_friends_added": "No tenés amistades añadidas",
"view_all": "Ver todo",
"load_more": "Cargar más",
"loading": "Cargando",
"pending": "Pendiente",
"no_pending_invites": "No tenés invitaciones pendientes",
"no_blocked_users": "No has bloqueado a nadie",
@@ -701,7 +690,6 @@
"report_reason_other": "Otros",
"profile_reported": "Perfil reportado",
"your_friend_code": "Tu código de amistad:",
"copy_friend_code": "Copiar código de amistad",
"upload_banner": "Subir banner",
"uploading_banner": "Subiendo banner…",
"background_image_updated": "Imagen de fondo actualizada",
@@ -722,13 +710,11 @@
"amount_minutes_short": "{{amount}}m",
"karma": "Karma",
"karma_count": "karma",
"karma_description": "Conseguido por me gustas positivos en reseñas",
"sort_by": "Filtrar por:",
"game_added_to_pinned": "Juego añadido a fijados",
"user_reviews": "Reseñas",
"loading_reviews": "Cargando reseñas...",
"wrapped_2025": "Wrapped 2025",
"view_my_wrapped_button": "Ver Mi Wrapped 2025",
"view_wrapped_button": "Ver Wrapped 2025 de {{displayName}}",
"no_reviews": "Sin reseñas aún",
"delete_review": "Eliminar reseña"
},
@@ -781,41 +767,5 @@
"all_games": "Todos los Juegos",
"recently_played": "Jugados Recientemente",
"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,7 +673,8 @@
"game_removed_from_pinned": "Peli poistettu kiinnitetyistä",
"game_added_to_pinned": "Peli lisätty kiinnitettyihin",
"karma": "Karma",
"karma_count": "karmaa"
"karma_count": "karmaa",
"karma_description": "Ansittu positiivisilla arvosteluäänillä"
},
"achievement": {
"achievement_unlocked": "Saavutus avattu",

View File

@@ -718,6 +718,7 @@
"game_added_to_pinned": "Játék hozzáadva a kitűzöttekhez",
"karma": "Karma",
"karma_count": "karma",
"karma_description": "Pozitív értékelésekkel szerzett pontok",
"user_reviews": "Vélemények",
"delete_review": "Vélemény Törlése",
"loading_reviews": "Vélemények betöltése..."

View File

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

View File

@@ -172,12 +172,6 @@
"open_screenshot": "Ver captura de tela {{number}}",
"download_settings": "Ajustes do download",
"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",
"no_executable_selected": "Nenhum executável selecionado",
"open_folder": "Abrir pasta",
@@ -660,7 +654,6 @@
"see_profile": "Ver perfil",
"friend_request_sent": "Pedido de amizade enviado",
"friends": "Amigos",
"badges": "Insígnias",
"add": "Adicionar",
"sending": "Enviando",
"friends_list": "Lista de amigos",
@@ -673,16 +666,12 @@
"ignore_request": "Ignorar pedido",
"cancel_request": "Cancelar pedido",
"undo_friendship": "Desfazer amizade",
"friendship_removed": "Amigo removido",
"request_accepted": "Pedido de amizade aceito",
"user_blocked_successfully": "Usuário bloqueado com sucesso",
"user_block_modal_text": "Bloquear {{displayName}}",
"blocked_users": "Usuários bloqueados",
"unblock": "Desbloquear",
"no_friends_added": "Você ainda não possui amigos adicionados",
"view_all": "Ver todos",
"load_more": "Carregar mais",
"loading": "Carregando",
"pending": "Pendentes",
"no_pending_invites": "Você não possui convites de amizade pendentes",
"no_blocked_users": "Você não tem nenhum usuário bloqueado",
@@ -706,7 +695,6 @@
"report_reason_other": "Outro",
"profile_reported": "Perfil reportado",
"your_friend_code": "Seu código de amigo:",
"copy_friend_code": "Copiar código de amigo",
"upload_banner": "Carregar banner",
"uploading_banner": "Carregando banner…",
"background_image_updated": "Imagem de fundo salva",
@@ -732,12 +720,10 @@
"achievements_earned": "Conquistas recebidas",
"karma": "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",
"user_reviews": "Avaliações",
"loading_reviews": "Carregando avaliações...",
"wrapped_2025": "Wrapped 2025",
"view_my_wrapped_button": "Ver Meu Wrapped 2025",
"view_wrapped_button": "Ver Wrapped 2025 de {{displayName}}",
"no_reviews": "Ainda não há avaliações",
"delete_review": "Excluir avaliação"
},
@@ -790,41 +776,5 @@
"all_games": "Todos os Jogos",
"recently_played": "Jogados Recentemente",
"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

@@ -182,12 +182,6 @@
"open_screenshot": "Открыть скриншот {{number}}",
"download_settings": "Параметры загрузки",
"downloader": "Загрузчик",
"downloader_online": "Онлайн",
"downloader_not_configured": "Доступен, но не настроен",
"downloader_offline": "Ссылка недоступна",
"downloader_not_available": "Недоступно",
"recommended": "Рекомендуется",
"go_to_settings": "Перейти в настройки",
"select_executable": "Выбрать",
"no_executable_selected": "Файл не выбран",
"open_folder": "Открыть папку",
@@ -657,7 +651,6 @@
"sending": "Отправка",
"friend_request_sent": "Запрос в друзья отправлен",
"friends": "Друзья",
"badges": "Значки",
"friends_list": "Список друзей",
"user_not_found": "Пользователь не найден",
"block_user": "Заблокировать пользователя",
@@ -668,16 +661,12 @@
"ignore_request": "Игнорировать запрос",
"cancel_request": "Отменить запрос",
"undo_friendship": "Удалить друга",
"friendship_removed": "Друг удален",
"request_accepted": "Запрос принят",
"user_blocked_successfully": "Пользователь успешно заблокирован",
"user_block_modal_text": "{{displayName}} будет заблокирован",
"blocked_users": "Заблокированные пользователи",
"unblock": "Разблокировать",
"no_friends_added": "Вы ещё не добавили ни одного друга",
"view_all": "Показать все",
"load_more": "Загрузить еще",
"loading": "Загрузка",
"pending": "Ожидание",
"no_pending_invites": "У вас нет запросов ожидающих ответа",
"no_blocked_users": "Вы не заблокировали ни одного пользователя",
@@ -701,7 +690,6 @@
"report_reason_other": "Другое",
"profile_reported": "Жалоба на профиль отправлена",
"your_friend_code": "Код вашего друга:",
"copy_friend_code": "Копировать код друга",
"upload_banner": "Загрузить баннер",
"uploading_banner": "Загрузка баннера...",
"background_image_updated": "Фоновое изображение обновлено",
@@ -721,11 +709,9 @@
"game_added_to_pinned": "Игра добавлена в закрепленные",
"karma": "Карма",
"karma_count": "карма",
"karma_description": "Заработана положительными оценками отзывов",
"user_reviews": "Отзывы",
"loading_reviews": "Загрузка отзывов...",
"wrapped_2025": "Wrapped 2025",
"view_my_wrapped_button": "Просмотреть мой Wrapped 2025",
"view_wrapped_button": "Просмотреть Wrapped 2025 {{displayName}}",
"no_reviews": "Пока нет отзывов",
"delete_review": "Удалить отзыв"
},
@@ -778,41 +764,5 @@
"all_games": "Все игры",
"recently_played": "Недавно сыгранные",
"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,6 +706,7 @@
"game_added_to_pinned": "Oyun sabitlenmişlere eklendi",
"karma": "Karma",
"karma_count": "karma",
"karma_description": "İncelemelerdeki olumlu beğenilerden kazanılır",
"user_reviews": "İncelemeler",
"delete_review": "İncelemeyi Sil",
"loading_reviews": "İncelemeler yükleniyor..."

View File

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

View File

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

View File

@@ -8,7 +8,6 @@ import {
DatanodesApi,
MediafireApi,
PixelDrainApi,
VikingFileApi,
} from "../hosters";
import { PythonRPC } from "../python-rpc";
import {
@@ -36,27 +35,18 @@ export class DownloadManager {
): string | undefined {
if (originalUrl?.includes("#")) {
const hashPart = originalUrl.split("#")[1];
if (hashPart && !hashPart.startsWith("http") && hashPart.includes(".")) {
return hashPart;
}
if (hashPart && !hashPart.startsWith("http")) return hashPart;
}
if (url.includes("#")) {
const hashPart = url.split("#")[1];
if (hashPart && !hashPart.startsWith("http") && hashPart.includes(".")) {
return hashPart;
}
if (hashPart && !hashPart.startsWith("http")) return hashPart;
}
try {
const urlObj = new URL(url);
const pathname = urlObj.pathname;
const pathParts = pathname.split("/");
const filename = pathParts[pathParts.length - 1];
if (filename?.includes(".") && filename.length > 0) {
return decodeURIComponent(filename);
}
const filename = urlObj.pathname.split("/").pop();
if (filename?.length) return filename;
} catch {
// Invalid URL
}
@@ -65,7 +55,7 @@ export class DownloadManager {
}
private static sanitizeFilename(filename: string): string {
return filename.replaceAll(/[<>:"/\\|?*]/g, "_");
return filename.replace(/[<>:"/\\|?*]/g, "_");
}
private static createDownloadPayload(
@@ -74,19 +64,13 @@ export class DownloadManager {
downloadId: string,
savePath: string
) {
const filename =
this.extractFilename(originalUrl, directUrl) ||
this.extractFilename(directUrl);
const filename = this.extractFilename(directUrl, originalUrl);
const sanitizedFilename = filename
? this.sanitizeFilename(filename)
: undefined;
if (sanitizedFilename) {
logger.log(`[DownloadManager] Using filename: ${sanitizedFilename}`);
} else {
logger.log(
`[DownloadManager] No filename extracted, aria2 will use default`
);
}
return {
@@ -151,28 +135,14 @@ export class DownloadManager {
if (!isDownloadingMetadata && !isCheckingFiles) {
if (!download) return null;
const updatedDownload = {
await downloadsSublevel.put(downloadId, {
...download,
bytesDownloaded,
fileSize,
progress,
folderName,
status: "active" as const,
};
await downloadsSublevel.put(downloadId, updatedDownload);
return {
numPeers,
numSeeds,
downloadSpeed,
timeRemaining: calculateETA(fileSize, bytesDownloaded, downloadSpeed),
isDownloadingMetadata,
isCheckingFiles,
progress,
gameId: downloadId,
download: updatedDownload,
} as DownloadProgress;
status: "active",
});
}
return {
@@ -257,10 +227,10 @@ export class DownloadManager {
)
) {
gameFilesManager.extractDownloadedFile();
} else if (download.folderName) {
} else {
gameFilesManager
.extractFilesInDirectory(
path.join(download.downloadPath, download.folderName)
path.join(download.downloadPath, download.folderName!)
)
.then(() => gameFilesManager.setExtractionComplete());
}
@@ -514,29 +484,6 @@ export class DownloadManager {
allow_multiple_connections: true,
};
}
case Downloader.VikingFile: {
logger.log(
`[DownloadManager] Processing VikingFile download for URI: ${download.uri}`
);
try {
const downloadUrl = await VikingFileApi.getDownloadUrl(download.uri);
logger.log(`[DownloadManager] VikingFile direct URL obtained`);
return {
action: "start",
game_id: downloadId,
url: downloadUrl,
save_path: download.downloadPath,
header:
"User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Safari/537.36",
};
} catch (error) {
logger.error(
`[DownloadManager] Error processing VikingFile download:`,
error
);
throw error;
}
}
}
}

View File

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

View File

@@ -5,4 +5,3 @@ export * from "./mediafire";
export * from "./pixeldrain";
export * from "./buzzheavier";
export * from "./fuckingfast";
export * from "./vikingfile";

View File

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

View File

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

View File

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

View File

@@ -1,6 +1,6 @@
import { Downloader } from "@shared";
export const VERSION_CODENAME = "Harbinger";
export const VERSION_CODENAME = "Supernova";
export const DOWNLOADER_NAME = {
[Downloader.RealDebrid]: "Real-Debrid",
@@ -14,7 +14,6 @@ export const DOWNLOADER_NAME = {
[Downloader.FuckingFast]: "FuckingFast",
[Downloader.TorBox]: "TorBox",
[Downloader.Hydra]: "Nimbus",
[Downloader.VikingFile]: "VikingFile",
};
export const MAX_MINUTES_TO_SHOW_IN_PLAYTIME = 120;

View File

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

View File

@@ -19,173 +19,23 @@
color: globals.$body-color;
}
&__downloaders-list-wrapper {
border: 1px solid globals.$border-color;
overflow: hidden;
background-color: globals.$dark-background-color;
&__downloaders {
display: grid;
gap: globals.$spacing-unit;
grid-template-columns: repeat(2, 1fr);
}
&__downloaders-list {
display: flex;
flex-direction: column;
gap: 0;
max-height: 200px;
overflow-y: auto;
overflow-x: hidden;
padding: 0;
&__downloader-option {
position: relative;
&::-webkit-scrollbar {
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);
&:only-child {
grid-column: 1 / -1;
}
}
&__downloader-item {
display: flex;
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;
&__downloader-icon {
position: absolute;
left: calc(globals.$spacing-unit * 2);
}
&__path-error {
@@ -199,17 +49,4 @@
&__change-path-button {
align-self: flex-end;
}
&__loading-spinner {
animation: spin 1s linear infinite;
}
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}

View File

@@ -1,25 +1,17 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { Trans, useTranslation } from "react-i18next";
import {
Badge,
Button,
CheckboxField,
Link,
Modal,
TextField,
} from "@renderer/components";
import {
DownloadIcon,
SyncIcon,
CheckCircleFillIcon,
} from "@primer/octicons-react";
import { Downloader, formatBytes, getDownloadersForUri } from "@shared";
import { CheckCircleFillIcon, DownloadIcon } from "@primer/octicons-react";
import { Downloader, formatBytes, getDownloadersForUris } from "@shared";
import type { GameRepack } from "@types";
import { DOWNLOADER_NAME } from "@renderer/constants";
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";
export interface DownloadSettingsModalProps {
@@ -59,7 +51,6 @@ export function DownloadSettingsModal({
const [hasWritePermission, setHasWritePermission] = useState<boolean | null>(
null
);
const [showRealDebridModal, setShowRealDebridModal] = useState(false);
const { isFeatureEnabled, Feature } = useFeature();
@@ -87,89 +78,18 @@ export function DownloadSettingsModal({
}
}, [visible, checkFolderWritePermission, selectedPath]);
const downloadOptions = useMemo(() => {
const unavailableUrisSet = new Set(repack?.unavailableUris ?? []);
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 downloaders = useMemo(() => {
return getDownloadersForUris(repack?.uris ?? []);
}, [repack?.uris]);
const getDefaultDownloader = useCallback(
(availableDownloaders: Downloader[]) => {
if (availableDownloaders.length === 0) return null;
if (availableDownloaders.includes(Downloader.Hydra)) {
return Downloader.Hydra;
}
if (availableDownloaders.includes(Downloader.RealDebrid)) {
return Downloader.RealDebrid;
}
@@ -192,12 +112,26 @@ export function DownloadSettingsModal({
.then((defaultDownloadsPath) => setSelectedPath(defaultDownloadsPath));
}
const availableDownloaders = downloadOptions
.filter((option) => option.isAvailable)
.map((option) => option.downloader);
const filteredDownloaders = downloaders.filter((downloader) => {
if (downloader === Downloader.RealDebrid)
return userPreferences?.realDebridApiToken;
if (downloader === Downloader.TorBox)
return userPreferences?.torBoxApiToken;
if (downloader === Downloader.Hydra)
return isFeatureEnabled(Feature.Nimbus);
return true;
});
setSelectedDownloader(getDefaultDownloader(availableDownloaders));
}, [getDefaultDownloader, userPreferences?.downloadsPath, downloadOptions]);
setSelectedDownloader(getDefaultDownloader(filteredDownloaders));
}, [
Feature,
isFeatureEnabled,
getDefaultDownloader,
userPreferences?.downloadsPath,
downloaders,
userPreferences?.realDebridApiToken,
userPreferences?.torBoxApiToken,
]);
const handleChooseDownloadsPath = async () => {
const { filePaths } = await window.electron.showOpenDialog({
@@ -252,144 +186,33 @@ export function DownloadSettingsModal({
<div className="download-settings-modal__downloads-path-field">
<span>{t("downloader")}</span>
<div className="download-settings-modal__downloaders-list-wrapper">
<div className="download-settings-modal__downloaders-list">
{downloadOptions.map((option, index) => {
const isSelected = selectedDownloader === option.downloader;
const tooltipId = `availability-indicator-${option.downloader}`;
const isLastItem = index === downloadOptions.length - 1;
<div className="download-settings-modal__downloaders">
{downloaders.map((downloader) => {
const shouldDisableButton =
(downloader === Downloader.RealDebrid &&
!userPreferences?.realDebridApiToken) ||
(downloader === Downloader.TorBox &&
!userPreferences?.torBoxApiToken) ||
(downloader === Downloader.Hydra &&
!isFeatureEnabled(Feature.Nimbus));
const Indicator = option.isAvailable ? motion.span : "span";
const isDisabled =
!option.canHandle ||
(!option.isAvailable && !option.isAvailableButNotConfigured);
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")}
/>
);
return (
<Button
key={downloader}
className="download-settings-modal__downloader-option"
theme={
selectedDownloader === downloader ? "primary" : "outline"
}
if (option.isAvailableButNotConfigured) {
return (
<span
className={`download-settings-modal__availability-indicator download-settings-modal__availability-indicator--warning`}
data-tooltip-id={tooltipId}
data-tooltip-content={t("downloader_not_configured")}
/>
);
}
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>
disabled={shouldDisableButton}
onClick={() => setSelectedDownloader(downloader)}
>
{selectedDownloader === downloader && (
<CheckCircleFillIcon className="download-settings-modal__downloader-icon" />
)}
{DOWNLOADER_NAME[downloader]}
</Button>
);
})}
</div>
</div>
@@ -441,34 +264,13 @@ export function DownloadSettingsModal({
disabled={
downloadStarting ||
selectedDownloader === null ||
!hasWritePermission ||
downloadOptions.some(
(option) =>
option.downloader === selectedDownloader &&
(option.isAvailableButNotConfigured ||
(!option.isAvailable && option.canHandle) ||
!option.canHandle)
)
!hasWritePermission
}
>
{downloadStarting ? (
<>
<SyncIcon className="download-settings-modal__loading-spinner" />
{t("loading")}
</>
) : (
<>
<DownloadIcon />
{t("download_now")}
</>
)}
<DownloadIcon />
{t("download_now")}
</Button>
</div>
<RealDebridInfoModal
visible={showRealDebridModal}
onClose={() => setShowRealDebridModal(false)}
/>
</Modal>
);
}

View File

@@ -1,36 +0,0 @@
@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

@@ -1,58 +0,0 @@
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,26 +221,6 @@
left: 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 {

View File

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

View File

@@ -8,72 +8,6 @@
width: 100%;
max-width: 800px;
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 {
display: flex;
@@ -81,12 +15,6 @@
justify-content: flex-end;
}
&__content-wrapper {
display: flex;
flex-direction: column;
flex: 1;
}
&__list {
display: flex;
flex-direction: column;
@@ -95,22 +23,14 @@
&__empty {
display: flex;
flex: 1;
width: 100%;
height: 100%;
justify-content: center;
align-items: center;
flex-direction: column;
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 {
width: 60px;
height: 60px;

View File

@@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { BellIcon } from "@primer/octicons-react";
import { useTranslation } from "react-i18next";
import { AnimatePresence, motion } from "framer-motion";
@@ -18,11 +18,6 @@ import type {
} from "@types";
import "./notifications.scss";
type NotificationFilter = "all" | "unread";
const STAGGER_DELAY_MS = 70;
const EXIT_DURATION_MS = 250;
export default function Notifications() {
const { t, i18n } = useTranslation("notifications_page");
const { showSuccessToast, showErrorToast } = useToast();
@@ -39,14 +34,12 @@ export default function Notifications() {
>([]);
const [badges, setBadges] = useState<Badge[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [isClearing, setIsClearing] = useState(false);
const [filter, setFilter] = useState<NotificationFilter>("all");
const [clearingIds, setClearingIds] = useState<Set<string>>(new Set());
const [pagination, setPagination] = useState({
total: 0,
hasMore: false,
skip: 0,
});
const clearingTimeoutsRef = useRef<NodeJS.Timeout[]>([]);
const fetchLocalNotifications = useCallback(async () => {
try {
@@ -72,11 +65,7 @@ export default function Notifications() {
}, [i18n.language]);
const fetchApiNotifications = useCallback(
async (
skip = 0,
append = false,
filterParam: NotificationFilter = "all"
) => {
async (skip = 0, append = false) => {
if (!userDetails) return;
try {
@@ -85,7 +74,7 @@ export default function Notifications() {
await window.electron.hydraApi.get<NotificationsResponse>(
"/profile/notifications",
{
params: { filter: filterParam, take: 20, skip },
params: { filter: "all", take: 20, skip },
needsAuth: true,
}
);
@@ -112,24 +101,24 @@ export default function Notifications() {
[userDetails]
);
const fetchAllNotifications = useCallback(
async (filterParam: NotificationFilter = "all") => {
setIsLoading(true);
await Promise.all([
fetchLocalNotifications(),
fetchBadges(),
userDetails
? fetchApiNotifications(0, false, filterParam)
: Promise.resolve(),
]);
setIsLoading(false);
},
[fetchLocalNotifications, fetchBadges, fetchApiNotifications, userDetails]
);
const fetchAllNotifications = useCallback(async () => {
setIsLoading(true);
await Promise.all([
fetchLocalNotifications(),
fetchBadges(),
userDetails ? fetchApiNotifications(0, false) : Promise.resolve(),
]);
setIsLoading(false);
}, [
fetchLocalNotifications,
fetchBadges,
fetchApiNotifications,
userDetails,
]);
useEffect(() => {
fetchAllNotifications(filter);
}, [fetchAllNotifications, filter]);
fetchAllNotifications();
}, [fetchAllNotifications]);
useEffect(() => {
const unsubscribe = window.electron.onLocalNotificationCreated(
@@ -141,12 +130,31 @@ export default function Notifications() {
return () => unsubscribe();
}, []);
// Cleanup timeouts on unmount
useEffect(() => {
return () => {
clearingTimeoutsRef.current.forEach(clearTimeout);
const unsubscribe = window.electron.onSyncNotificationCount(() => {
if (userDetails) {
fetchApiNotifications(0, false);
}
fetchLocalNotifications();
});
return () => unsubscribe();
}, [userDetails, fetchApiNotifications, fetchLocalNotifications]);
useEffect(() => {
const handleVisibilityChange = () => {
if (!document.hidden && userDetails) {
fetchApiNotifications(0, false);
fetchLocalNotifications();
}
};
}, []);
document.addEventListener("visibilitychange", handleVisibilityChange);
return () => {
document.removeEventListener("visibilitychange", handleVisibilityChange);
};
}, [userDetails, fetchApiNotifications, fetchLocalNotifications]);
const mergedNotifications = useMemo<MergedNotification[]>(() => {
const sortByDate = (a: MergedNotification, b: MergedNotification) =>
@@ -162,28 +170,23 @@ export default function Notifications() {
.filter((n) => n.priority !== 1)
.map((n) => ({ ...n, source: "api" as const }));
// Filter local notifications based on current filter
const filteredLocalNotifications =
filter === "unread"
? localNotifications.filter((n) => !n.isRead)
: localNotifications;
const localWithSource: MergedNotification[] =
filteredLocalNotifications.map((n) => ({
const localWithSource: MergedNotification[] = localNotifications.map(
(n) => ({
...n,
source: "local" as const,
}));
})
);
const lowPriority = [...lowPriorityApi, ...localWithSource].sort(
sortByDate
);
return [...highPriority, ...lowPriority];
}, [apiNotifications, localNotifications, filter]);
}, [apiNotifications, localNotifications]);
const displayedNotifications = useMemo(() => {
return mergedNotifications;
}, [mergedNotifications]);
return mergedNotifications.filter((n) => !clearingIds.has(n.id));
}, [mergedNotifications, clearingIds]);
const notifyCountChange = useCallback(() => {
window.dispatchEvent(new CustomEvent("notificationsChanged"));
@@ -274,86 +277,42 @@ export default function Notifications() {
[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 () => {
if (isClearing) return;
try {
setIsClearing(true);
// Mark all as clearing for animation
const allIds = new Set([
...apiNotifications.map((n) => n.id),
...localNotifications.map((n) => n.id),
]);
setClearingIds(allIds);
// Clear any existing timeouts
clearingTimeoutsRef.current.forEach(clearTimeout);
clearingTimeoutsRef.current = [];
// Wait for exit animation
await new Promise((resolve) => setTimeout(resolve, 300));
// Snapshot current notifications for staggered removal
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) {
// Clear all API notifications
if (userDetails && apiNotifications.length > 0) {
await window.electron.hydraApi.delete(`/profile/notifications/all`, {
needsAuth: true,
});
setApiNotifications([]);
}
// Clear all local notifications
await window.electron.clearAllLocalNotifications();
setLocalNotifications([]);
setClearingIds(new Set());
setPagination({ total: 0, hasMore: false, skip: 0 });
notifyCountChange();
showSuccessToast(t("cleared_all"));
} catch (error) {
logger.error("Failed to clear all notifications", error);
setClearingIds(new Set());
showErrorToast(t("failed_to_clear"));
} finally {
setIsClearing(false);
clearingTimeoutsRef.current = [];
}
}, [
displayedNotifications,
isClearing,
removeNotificationWithDelay,
apiNotifications,
localNotifications,
userDetails,
showSuccessToast,
showErrorToast,
@@ -363,19 +322,9 @@ export default function Notifications() {
const handleLoadMore = useCallback(() => {
if (pagination.hasMore && !isLoading) {
fetchApiNotifications(pagination.skip, true, filter);
fetchApiNotifications(pagination.skip, true);
}
}, [pagination, isLoading, fetchApiNotifications, filter]);
const handleFilterChange = useCallback(
(newFilter: NotificationFilter) => {
if (newFilter !== filter) {
setFilter(newFilter);
setPagination({ total: 0, hasMore: false, skip: 0 });
}
},
[filter]
);
}, [pagination, isLoading, fetchApiNotifications]);
const handleAcceptFriendRequest = useCallback(() => {
showSuccessToast(t("friend_request_accepted"));
@@ -394,13 +343,10 @@ export default function Notifications() {
return (
<motion.div
key={key}
layout
initial={{ opacity: 0, x: -20 }}
animate={{ opacity: 1, x: 0 }}
exit={{
opacity: 0,
x: 80,
transition: { duration: EXIT_DURATION_MS / 1000 },
}}
exit={{ opacity: 0, x: 100, transition: { duration: 0.2 } }}
transition={{ duration: 0.2 }}
>
{notification.source === "local" ? (
@@ -423,57 +369,8 @@ 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 = () => {
if (isLoading && hasNoNotifications) {
if (isLoading && mergedNotifications.length === 0) {
return (
<div className="notifications__loading">
<span>{t("loading")}</span>
@@ -481,61 +378,36 @@ 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 (
<div className="notifications">
<div className="notifications__header">
{renderFilterTabs()}
<div className="notifications__actions">
<Button
theme="outline"
onClick={handleMarkAllAsRead}
disabled={shouldDisableActions}
>
{t("mark_all_as_read")}
</Button>
<Button
theme="danger"
onClick={handleClearAll}
disabled={shouldDisableActions}
>
{t("clear_all")}
</Button>
</div>
<div className="notifications__actions">
<Button theme="outline" onClick={handleMarkAllAsRead}>
{t("mark_all_as_read")}
</Button>
<Button theme="danger" onClick={handleClearAll}>
{t("clear_all")}
</Button>
</div>
{/* Keep AnimatePresence mounted during clearing to preserve exit animations */}
<AnimatePresence mode="wait">
<motion.div
key={filter}
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>
<div className="notifications__list">
<AnimatePresence mode="popLayout">
{displayedNotifications.map(renderNotification)}
</AnimatePresence>
</div>
{pagination.hasMore && !isClearing && (
{pagination.hasMore && (
<div className="notifications__load-more">
<Button
theme="outline"

View File

@@ -100,10 +100,8 @@
padding: calc(globals.$spacing-unit * 1.5);
background-color: rgba(255, 255, 255, 0.05);
border-radius: 8px;
border: none;
cursor: pointer;
transition: all ease 0.2s;
text-align: left;
&:hover {
background-color: rgba(255, 255, 255, 0.1);
@@ -118,5 +116,6 @@
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
text-align: left;
}
}

View File

@@ -123,6 +123,11 @@ export function UserStatsBox() {
{t("karma_count")}
</p>
</div>
<div className="user-stats__karma-info">
<small className="user-stats__karma-info-text">
{t("karma_description")}
</small>
</div>
</li>
)}
</ul>

View File

@@ -10,7 +10,6 @@ export enum Downloader {
Hydra,
Buzzheavier,
FuckingFast,
VikingFile,
}
export enum DownloadSourceStatus {

View File

@@ -124,9 +124,6 @@ export const getDownloadersForUri = (uri: string) => {
if (uri.startsWith("https://fuckingfast.co")) {
return [Downloader.FuckingFast];
}
if (uri.startsWith("https://vikingfile.com")) {
return [Downloader.VikingFile];
}
if (realDebridHosts.some((host) => uri.startsWith(host)))
return [Downloader.RealDebrid];

View File

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

View File

@@ -6354,11 +6354,6 @@ keyv@^4.0.0, keyv@^4.5.3:
dependencies:
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:
version "0.3.23"
resolved "https://registry.yarnpkg.com/language-subtag-registry/-/language-subtag-registry-0.3.23.tgz#23529e04d9e3b74679d70142df3fd2eb6ec572e7"
@@ -9128,13 +9123,6 @@ word-wrap@^1.2.5:
resolved "https://registry.yarnpkg.com/word-wrap/-/word-wrap-1.2.5.tgz#d2c45c6dd4fbce621a66f136cbe328afd0410b34"
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":
version "7.0.0"
resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43"