mirror of
https://github.com/hydralauncher/hydra.git
synced 2026-01-16 00:43:00 -03:00
Compare commits
116 Commits
v2.0.2
...
github/for
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f0dd89e471 | ||
|
|
4e8f5a0881 | ||
|
|
ae45547c17 | ||
|
|
4540dcc033 | ||
|
|
11c29355e3 | ||
|
|
91862cd2fe | ||
|
|
6c5d3793ae | ||
|
|
43c5fdbab9 | ||
|
|
3952f106fc | ||
|
|
2e386528a4 | ||
|
|
b6727be3cf | ||
|
|
05f9703c25 | ||
|
|
929be48495 | ||
|
|
8c67dda84e | ||
|
|
6d277cd1d8 | ||
|
|
d4902a5ab1 | ||
|
|
004ccd0db5 | ||
|
|
e55dc20c7d | ||
|
|
7f3d7a56c3 | ||
|
|
d0406282ce | ||
|
|
c6e99f8599 | ||
|
|
5aec973882 | ||
|
|
49fd34c3c0 | ||
|
|
198a283752 | ||
|
|
46b12f2bc2 | ||
|
|
cb93fbcb72 | ||
|
|
22b66149b3 | ||
|
|
6f70b529a2 | ||
|
|
a81b016500 | ||
|
|
ef0699dbea | ||
|
|
b3f87d5662 | ||
|
|
6ff48605da | ||
|
|
0f0a1e98a3 | ||
|
|
007da03837 | ||
|
|
6cc8e8f5fe | ||
|
|
6ccbff0160 | ||
|
|
202f5b60de | ||
|
|
b9558907ec | ||
|
|
8a01352eab | ||
|
|
1aa438b0fa | ||
|
|
e008478e53 | ||
|
|
6a195eb566 | ||
|
|
e2b089e0f8 | ||
|
|
a9b92f3fc1 | ||
|
|
6822ed8447 | ||
|
|
2a6e0f31df | ||
|
|
5683a0ba49 | ||
|
|
18488490c1 | ||
|
|
b7cabfdbde | ||
|
|
2ee3fdc223 | ||
|
|
6fce60f9f7 | ||
|
|
c8aa9fd681 | ||
|
|
0f12dfae88 | ||
|
|
be48306ca2 | ||
|
|
ab81e21341 | ||
|
|
b7f94102da | ||
|
|
9e7b27afe6 | ||
|
|
c24523e8e6 | ||
|
|
b58330ed35 | ||
|
|
dde40f39e9 | ||
|
|
d2b3017de9 | ||
|
|
64f4dad7cc | ||
|
|
154d211b21 | ||
|
|
7905ef6c10 | ||
|
|
b09f2c055f | ||
|
|
2c5b3b4ffa | ||
|
|
fdefc0c165 | ||
|
|
47ca2535e3 | ||
|
|
f706836a43 | ||
|
|
d8158bb80e | ||
|
|
4e422bdf91 | ||
|
|
4be3db8007 | ||
|
|
29b64237ed | ||
|
|
d481164bf3 | ||
|
|
138f33e0c3 | ||
|
|
be3c78f584 | ||
|
|
be1d9825d3 | ||
|
|
981116f221 | ||
|
|
26aad178ee | ||
|
|
56c8349899 | ||
|
|
0b2c407770 | ||
|
|
d2e3d48ef8 | ||
|
|
153291f89f | ||
|
|
ae3daa4c79 | ||
|
|
1397e3932d | ||
|
|
0f5db4f34e | ||
|
|
75c8f69e81 | ||
|
|
aa253466a3 | ||
|
|
b8bd786c45 | ||
|
|
c9c585f820 | ||
|
|
9e11d6c098 | ||
|
|
2f83c2c9da | ||
|
|
dc94a886e6 | ||
|
|
7deabc4889 | ||
|
|
e57200d024 | ||
|
|
7a13739d49 | ||
|
|
f8cbbc64f0 | ||
|
|
9096eb5e0e | ||
|
|
eebb5fec61 | ||
|
|
88cfd0d095 | ||
|
|
a43768ce67 | ||
|
|
16a8c28935 | ||
|
|
1cc5a5b209 | ||
|
|
a39082d326 | ||
|
|
0c1a75eedd | ||
|
|
dd23358a95 | ||
|
|
8f00254dc2 | ||
|
|
449b34d3dd | ||
|
|
9870213fff | ||
|
|
de237b7c39 | ||
|
|
8a5d4e38b6 | ||
|
|
77152a32ab | ||
|
|
c57c8dc477 | ||
|
|
455d80da3e | ||
|
|
d61c535c6f | ||
|
|
23308a7780 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -1,6 +1,7 @@
|
||||
.vscode
|
||||
node_modules
|
||||
hydra-download-manager/
|
||||
aria2/
|
||||
fastlist.exe
|
||||
__pycache__
|
||||
dist
|
||||
|
||||
@@ -83,7 +83,7 @@ Puedes unirte a nuestra conversación y discusiones en nuestro canal de [Telegra
|
||||
|
||||
### Haz un fork y clona tu repositorio
|
||||
|
||||
1. Rea;iza un fork del repositorio [(Haz click acá para hacer un fork ahora)](https://github.com/hydralauncher/hydra/fork)
|
||||
1. Realiza un fork del repositorio [(Haz click acá para hacer un fork ahora)](https://github.com/hydralauncher/hydra/fork)
|
||||
2. Clona el código forkeado `git clone https://github.com/tu_nombredeusuario/hydra`
|
||||
3. Crea una nueva rama
|
||||
4. Sube tus commits
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
<h1 align="center">Hydra Launcher</h1>
|
||||
|
||||
<p align="center">
|
||||
<strong>Hydra is a game launcher with its own embedded bittorrent client and a self-managed repack scraper.</strong>
|
||||
<strong>Hydra is a game launcher with its own embedded bittorrent client.</strong>
|
||||
</p>
|
||||
|
||||
[](https://github.com/hydralauncher/hydra/actions)
|
||||
@@ -50,17 +50,15 @@
|
||||
|
||||
## About
|
||||
|
||||
**Hydra** is a **Game Launcher** with its own embedded **BitTorrent Client** and a **self-managed repack scraper**.
|
||||
**Hydra** is a **Game Launcher** with its own embedded **BitTorrent Client**.
|
||||
<br>
|
||||
The launcher is written in TypeScript (Electron) and Python, which handles the torrenting system by using libtorrent.
|
||||
|
||||
## Features
|
||||
|
||||
- Self-Managed repack scraper among all the most reliable websites on the [Megathread]("https://www.reddit.com/r/Piracy/wiki/megathread/")
|
||||
- Own embedded bittorrent client
|
||||
- How Long To Beat (HLTB) integration on game page
|
||||
- Downloads path customization
|
||||
- Repack list update notifications
|
||||
- Windows and Linux support
|
||||
- Constantly updated
|
||||
- And more ...
|
||||
@@ -134,9 +132,8 @@ pip install -r requirements.txt
|
||||
## Environment variables
|
||||
|
||||
You'll need an SteamGridDB API Key in order to fetch the game icons on installation.
|
||||
If you want to have onlinefix as a repacker you'll need to add your credentials to the .env
|
||||
|
||||
Once you have it, you can copy or rename the `.env.example` file to `.env` and put it on`STEAMGRIDDB_API_KEY`, `ONLINEFIX_USERNAME`, `ONLINEFIX_PASSWORD`.
|
||||
Once you have it, you can copy or rename the `.env.example` file to `.env` and put it on`STEAMGRIDDB_API_KEY`.
|
||||
|
||||
## Running
|
||||
|
||||
|
||||
@@ -3,12 +3,10 @@ productName: Hydra
|
||||
directories:
|
||||
buildResources: build
|
||||
extraResources:
|
||||
- aria2
|
||||
- hydra-download-manager
|
||||
- seeds
|
||||
- from: node_modules/ps-list/vendor/fastlist-0.3.0-x64.exe
|
||||
to: fastlist.exe
|
||||
- from: node_modules/create-desktop-shortcuts/src/windows.vbs
|
||||
- from: resources/hydralauncher.vbs
|
||||
files:
|
||||
- "!**/.vscode/*"
|
||||
- "!src/*"
|
||||
@@ -20,7 +18,6 @@ asarUnpack:
|
||||
- resources/**
|
||||
win:
|
||||
executableName: Hydra
|
||||
requestedExecutionLevel: requireAdministrator
|
||||
target:
|
||||
- nsis
|
||||
- portable
|
||||
@@ -33,7 +30,6 @@ nsis:
|
||||
allowToChangeInstallationDirectory: true
|
||||
portable:
|
||||
artifactName: ${name}-${version}-portable.${ext}
|
||||
requestExecutionLevel: admin
|
||||
mac:
|
||||
entitlementsInherit: build/entitlements.mac.plist
|
||||
extendInfo:
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "hydralauncher",
|
||||
"version": "2.0.2",
|
||||
"version": "2.0.3",
|
||||
"description": "Hydra",
|
||||
"main": "./out/main/index.js",
|
||||
"author": "Los Broxas",
|
||||
@@ -23,7 +23,7 @@
|
||||
"start": "electron-vite preview",
|
||||
"dev": "electron-vite dev",
|
||||
"build": "npm run typecheck && electron-vite build",
|
||||
"postinstall": "electron-builder install-app-deps",
|
||||
"postinstall": "electron-builder install-app-deps && node ./postinstall.cjs",
|
||||
"build:unpack": "npm run build && electron-builder --dir",
|
||||
"build:win": "electron-vite build && electron-builder --win",
|
||||
"build:mac": "electron-vite build && electron-builder --mac",
|
||||
@@ -40,7 +40,9 @@
|
||||
"@reduxjs/toolkit": "^2.2.3",
|
||||
"@sentry/electron": "^5.1.0",
|
||||
"@vanilla-extract/css": "^1.14.2",
|
||||
"@vanilla-extract/dynamic": "^2.1.1",
|
||||
"@vanilla-extract/recipes": "^0.5.2",
|
||||
"aria2": "^4.1.2",
|
||||
"auto-launch": "^5.0.6",
|
||||
"axios": "^1.6.8",
|
||||
"better-sqlite3": "^9.5.0",
|
||||
@@ -65,11 +67,11 @@
|
||||
"lottie-react": "^2.4.0",
|
||||
"parse-torrent": "^11.0.16",
|
||||
"piscina": "^4.5.1",
|
||||
"ps-list": "^8.1.1",
|
||||
"react-i18next": "^14.1.0",
|
||||
"react-loading-skeleton": "^3.4.0",
|
||||
"react-redux": "^9.1.1",
|
||||
"react-router-dom": "^6.22.3",
|
||||
"sudo-prompt": "^9.2.1",
|
||||
"typeorm": "^0.3.20",
|
||||
"user-agents": "^1.1.193",
|
||||
"yaml": "^2.4.1",
|
||||
|
||||
50
postinstall.cjs
Normal file
50
postinstall.cjs
Normal file
@@ -0,0 +1,50 @@
|
||||
const { default: axios } = require("axios");
|
||||
const util = require("node:util");
|
||||
const fs = require("node:fs");
|
||||
|
||||
const exec = util.promisify(require("node:child_process").exec);
|
||||
|
||||
const downloadAria2 = async () => {
|
||||
if (fs.existsSync("aria2")) {
|
||||
console.log("Aria2 already exists, skipping download...");
|
||||
return;
|
||||
}
|
||||
|
||||
const file =
|
||||
process.platform === "win32"
|
||||
? "aria2-1.37.0-win-64bit-build1.zip"
|
||||
: "aria2-1.37.0-1-x86_64.pkg.tar.zst";
|
||||
|
||||
const downloadUrl =
|
||||
process.platform === "win32"
|
||||
? `https://github.com/aria2/aria2/releases/download/release-1.37.0/${file}`
|
||||
: "https://archlinux.org/packages/extra/x86_64/aria2/download/";
|
||||
|
||||
console.log(`Downloading ${file}...`);
|
||||
|
||||
const response = await axios.get(downloadUrl, { responseType: "stream" });
|
||||
|
||||
const stream = response.data.pipe(fs.createWriteStream(file));
|
||||
|
||||
stream.on("finish", async () => {
|
||||
console.log(`Downloaded ${file}, extracting...`);
|
||||
|
||||
if (process.platform === "win32") {
|
||||
await exec(`npx extract-zip ${file}`);
|
||||
console.log("Extracted. Renaming folder...");
|
||||
|
||||
fs.renameSync(file.replace(".zip", ""), "aria2");
|
||||
} else {
|
||||
await exec(`tar --zstd -xvf ${file} usr/bin/aria2c`);
|
||||
console.log("Extracted. Copying binary file...");
|
||||
fs.mkdirSync("aria2");
|
||||
fs.copyFileSync("usr/bin/aria2c", "aria2/aria2c");
|
||||
fs.rmSync("usr", { recursive: true });
|
||||
}
|
||||
|
||||
console.log(`Extracted ${file}, removing compressed downloaded file...`);
|
||||
fs.rmSync(file);
|
||||
});
|
||||
};
|
||||
|
||||
downloadAria2();
|
||||
@@ -3,3 +3,4 @@ cx_Freeze
|
||||
cx_Logging; sys_platform == 'win32'
|
||||
lief; sys_platform == 'win32'
|
||||
pywin32; sys_platform == 'win32'
|
||||
psutil
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
Set WshShell = CreateObject("WScript.Shell" )
|
||||
WshShell.Run """%localappdata%\Programs\Hydra\Hydra.exe""", 0 'Must quote command if it has spaces; must escape quotes
|
||||
Set WshShell = Nothing
|
||||
@@ -194,12 +194,27 @@
|
||||
"found_download_option_other": "Found {{countFormatted}} download options",
|
||||
"import": "Import"
|
||||
},
|
||||
"collections": {
|
||||
"collections": "Collections",
|
||||
"add_the_game_to_the_collection": "Add the game to the collection",
|
||||
"select_a_collection": "Select a collection",
|
||||
"enter_the_name_of_the_collection": "Enter the name of the collection",
|
||||
"add": "Add",
|
||||
"remove": "Remove",
|
||||
"you_cant_give_collections_existing_or_empty_names": "You can`t give collections existing or empty names",
|
||||
"the_collection_has_been_added_successfully": "The collection has been added successfully",
|
||||
"the_collection_has_been_removed_successfully": "The collection has been removed successfully",
|
||||
"the_game_has_been_added_to_the_collection": "The game has been added to the collection",
|
||||
"the_game_has_been_removed_from_the_collection": "The game has been removed from the collection"
|
||||
},
|
||||
"notifications": {
|
||||
"download_complete": "Download complete",
|
||||
"game_ready_to_install": "{{title}} is ready to install",
|
||||
"repack_list_updated": "Repack list updated",
|
||||
"repack_count_one": "{{count}} repack added",
|
||||
"repack_count_other": "{{count}} repacks added"
|
||||
"repack_count_other": "{{count}} repacks added",
|
||||
"new_update_available": "Version {{version}} available",
|
||||
"restart_to_install_update": "Restart Hydra to install the update"
|
||||
},
|
||||
"system_tray": {
|
||||
"open": "Open Hydra",
|
||||
@@ -239,6 +254,15 @@
|
||||
"successfully_signed_out": "Successfully signed out",
|
||||
"sign_out": "Sign out",
|
||||
"playing_for": "Playing for {{amount}}",
|
||||
"sign_out_modal_text": "Your library is linked with your current account. When signing out, your library will not be visible anymore, and any progress will not be saved. Continue with sign out?"
|
||||
"sign_out_modal_text": "Your library is linked with your current account. When signing out, your library will not be visible anymore, and any progress will not be saved. Continue with sign out?",
|
||||
"add_friends": "Add Friends",
|
||||
"add": "Add",
|
||||
"friend_code": "Friend code",
|
||||
"see_profile": "See profile",
|
||||
"sending": "Sending",
|
||||
"friend_request_sent": "Friend request sent",
|
||||
"friends": "Friends",
|
||||
"friends_list": "Friends list",
|
||||
"user_not_found": "User not found"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,7 +36,8 @@
|
||||
"no_downloads_in_progress": "Sin descargas en progreso",
|
||||
"downloading_metadata": "Descargando metadatos de {{title}}…",
|
||||
"downloading": "Descargando {{title}}… ({{percentage}} completado) - Finalizando {{eta}} - {{speed}}",
|
||||
"calculating_eta": "Descargando {{title}}… ({{percentage}} completado) - Calculando tiempo restante…"
|
||||
"calculating_eta": "Descargando {{title}}… ({{percentage}} completado) - Calculando tiempo restante…",
|
||||
"checking_files": "Verificando archivos de {{title}}… ({{percentage}} completado)"
|
||||
},
|
||||
"catalogue": {
|
||||
"next_page": "Siguiente página",
|
||||
@@ -47,7 +48,7 @@
|
||||
"download_options_zero": "No hay opciones de descargas disponibles",
|
||||
"download_options_one": "{{count}} opción de descarga",
|
||||
"download_options_other": "{{count}} opciones de descargas",
|
||||
"updated_at": "Actualizado el {{updated_at}}",
|
||||
"updated_at": "Actualizado el: {{updated_at}}",
|
||||
"install": "Instalar",
|
||||
"resume": "Continuar",
|
||||
"pause": "Pausa",
|
||||
@@ -73,7 +74,7 @@
|
||||
"remove_from_library": "Eliminar de la biblioteca",
|
||||
"no_downloads": "No hay descargas disponibles",
|
||||
"play_time": "Jugado por {{amount}}",
|
||||
"last_time_played": "Jugado por última vez {{period}}",
|
||||
"last_time_played": "Jugado por última vez: {{period}}",
|
||||
"not_played_yet": "Aún no has jugado a {{title}}",
|
||||
"next_suggestion": "Siguiente sugerencia",
|
||||
"play": "Jugar",
|
||||
@@ -92,7 +93,7 @@
|
||||
"screenshot": "Captura {{number}}",
|
||||
"open_screenshot": "Abrir captura {{number}}",
|
||||
"download_settings": "Ajustes de descarga",
|
||||
"downloader": "Descargador",
|
||||
"downloader": "Método de descarga",
|
||||
"select_executable": "Seleccionar",
|
||||
"no_executable_selected": "No se seleccionó un ejecutable",
|
||||
"open_folder": "Abrir carpeta",
|
||||
@@ -106,8 +107,8 @@
|
||||
"executable_section_description": "Ruta del archivo que se ejecutará cuando se presione \"Jugar\"",
|
||||
"downloads_secion_title": "Descargas",
|
||||
"downloads_section_description": "Buscar actualizaciones u otras versiones de este juego",
|
||||
"danger_zone_section_title": "Zona de Peligro",
|
||||
"danger_zone_section_description": "Eliminar este juego de tu librería o los archivos descargados por Hydra",
|
||||
"danger_zone_section_title": "Opciones Avanzadas",
|
||||
"danger_zone_section_description": "Eliminar este juego de tu librería o los archivos descargados por Hydra (Esto solo eliminará los archivos de instalación y no el juego instalado)",
|
||||
"download_in_progress": "Descarga en progreso",
|
||||
"download_paused": "Descarga pausada",
|
||||
"last_downloaded_option": "Última opción descargada",
|
||||
@@ -137,14 +138,15 @@
|
||||
"deleting": "Eliminando instalador…",
|
||||
"delete": "Eliminar instalador",
|
||||
"delete_modal_title": "¿Estás seguro?",
|
||||
"delete_modal_description": "Esto eliminará todos los archivos de instalación de tu computadora.",
|
||||
"delete_modal_description": "Esto eliminará todos los archivos de la instalación del repack del juego de tu computadora. (Si ya instalaste el juego, puedes eliminar esto, no afectará al juego)",
|
||||
"install": "Instalar",
|
||||
"download_in_progress": "En progreso",
|
||||
"queued_downloads": "Descargas en cola",
|
||||
"downloads_completed": "Completado",
|
||||
"queued": "En cola",
|
||||
"no_downloads_title": "Esto está tan... vacío",
|
||||
"no_downloads_description": "No has descargado nada con Hydra... aún, ¡pero nunca es tarde para comenzar!."
|
||||
"no_downloads_description": "No has descargado nada con Hydra... aún, ¡pero nunca es tarde para comenzar!.",
|
||||
"checking_files": "Verificando archivos…"
|
||||
},
|
||||
"settings": {
|
||||
"downloads_path": "Ruta de descarga",
|
||||
@@ -161,7 +163,7 @@
|
||||
"language": "Idioma",
|
||||
"real_debrid_api_token": "Token API",
|
||||
"enable_real_debrid": "Activar Real-Debrid",
|
||||
"real_debrid_description": "Real-Debrid es un descargador sin restricciones que te permite descargar archivos instantáneamente con la máxima velocidad de tu internet.",
|
||||
"real_debrid_description": "Real-Debrid es una forma de descargar sin restricciones archivos instantáneamente con la máxima velocidad de tu internet.",
|
||||
"real_debrid_invalid_token": "Token de API inválido",
|
||||
"real_debrid_api_token_hint": "Puedes obtener tu clave de API <0>aquí</0>",
|
||||
"real_debrid_free_account_error": "La cuenta \"{{username}}\" es una cuenta gratuita. Por favor, suscríbete a Real-Debrid",
|
||||
@@ -197,7 +199,9 @@
|
||||
"game_ready_to_install": "{{title}} está listo para instalarse",
|
||||
"repack_list_updated": "Lista de repacks actualizadas",
|
||||
"repack_count_one": "{{count}} repack ha sido añadido",
|
||||
"repack_count_other": "{{count}} repacks añadidos"
|
||||
"repack_count_other": "{{count}} repacks añadidos",
|
||||
"new_update_available": "Version {{version}} disponible",
|
||||
"restart_to_install_update": "Reinicia Hydra para instalar la actualización"
|
||||
},
|
||||
"system_tray": {
|
||||
"open": "Abrir Hydra",
|
||||
@@ -220,13 +224,13 @@
|
||||
"user_profile": {
|
||||
"amount_hours": "{{amount}} horas",
|
||||
"amount_minutes": "{{amount}} minutos",
|
||||
"last_time_played": "Última vez jugado {{period}}",
|
||||
"last_time_played": "Última vez jugado: {{period}}",
|
||||
"activity": "Actividad reciente",
|
||||
"library": "Biblioteca",
|
||||
"total_play_time": "Total de tiempo jugado: {{amount}}",
|
||||
"no_recent_activity_title": "Que raro, no hay nada por acá, ¿que tal si jugamos algo para empezar?",
|
||||
"no_recent_activity_title": "Que raro, no hay nada por acá...",
|
||||
"no_recent_activity_description": "No has jugado ningún juego recientemente, ¡vamos a cambiar eso ahora!",
|
||||
"display_name": "Nombre a mostrar",
|
||||
"display_name": "Nombre en pantalla",
|
||||
"saving": "Guardando",
|
||||
"save": "Guardar",
|
||||
"edit_profile": "Editar perfil",
|
||||
@@ -237,6 +241,15 @@
|
||||
"successfully_signed_out": "Sesión cerrada exitosamente",
|
||||
"sign_out": "Cerrar sesión",
|
||||
"playing_for": "Jugando por {{amount}}",
|
||||
"sign_out_modal_text": "Tu biblioteca se ha vinculado con tu cuenta. Cuando cierres sesión, tú biblioteca ya no será visible y cualquier progreso no se guardará. ¿Continuar con el cierre de sesión?"
|
||||
"sign_out_modal_text": "Tu biblioteca se ha vinculado con tu cuenta. Cuando cierres sesión, tú biblioteca ya no será visible y cualquier progreso no se guardará. ¿Continuar con el cierre de sesión?",
|
||||
"add_friends": "Añadir amigos",
|
||||
"add": "Añadir",
|
||||
"friend_code": "Código de amigo",
|
||||
"see_profile": "Ver perfil",
|
||||
"sending": "Enviando",
|
||||
"friend_request_sent": "Solicitud de amistad enviada",
|
||||
"friends": "Amigos",
|
||||
"friends_list": "Lista de amigos",
|
||||
"user_not_found": "Usuario no encontrado"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
{
|
||||
"app": {
|
||||
"successfully_signed_in": "Logado com sucesso"
|
||||
"successfully_signed_in": "Autenticado com sucesso"
|
||||
},
|
||||
"home": {
|
||||
"featured": "Destaque",
|
||||
"featured": "Destaques",
|
||||
"trending": "Populares",
|
||||
"surprise_me": "Surpreenda-me",
|
||||
"no_results": "Nenhum resultado encontrado"
|
||||
@@ -12,11 +12,11 @@
|
||||
"catalogue": "Catálogo",
|
||||
"downloads": "Downloads",
|
||||
"settings": "Ajustes",
|
||||
"my_library": "Minha biblioteca",
|
||||
"my_library": "Biblioteca",
|
||||
"downloading_metadata": "{{title}} (Baixando metadados…)",
|
||||
"paused": "{{title}} (Pausado)",
|
||||
"downloading": "{{title}} ({{percentage}} - Baixando…)",
|
||||
"filter": "Filtrar biblioteca",
|
||||
"filter": "Buscar",
|
||||
"home": "Início",
|
||||
"queued": "{{title}} (Na fila)",
|
||||
"game_has_no_executable": "Jogo não possui executável selecionado",
|
||||
@@ -45,7 +45,7 @@
|
||||
"download_options_one": "{{count}} opção de download",
|
||||
"download_options_other": "{{count}} opções de download",
|
||||
"updated_at": "Atualizado {{updated_at}}",
|
||||
"resume": "Resumir",
|
||||
"resume": "Retomar",
|
||||
"pause": "Pausar",
|
||||
"cancel": "Cancelar",
|
||||
"remove": "Remover",
|
||||
@@ -54,7 +54,7 @@
|
||||
"calculating_eta": "Calculando tempo restante…",
|
||||
"downloading_metadata": "Baixando metadados…",
|
||||
"filter": "Filtrar repacks",
|
||||
"requirements": "Requisitos do sistema",
|
||||
"requirements": "Requisitos de sistema",
|
||||
"minimum": "Mínimos",
|
||||
"recommended": "Recomendados",
|
||||
"paused": "Pausado",
|
||||
@@ -68,16 +68,16 @@
|
||||
"add_to_library": "Adicionar à biblioteca",
|
||||
"remove_from_library": "Remover da biblioteca",
|
||||
"no_downloads": "Nenhum download disponível",
|
||||
"play_time": "Jogado por {{amount}}",
|
||||
"play_time": "Jogou por {{amount}}",
|
||||
"next_suggestion": "Próxima sugestão",
|
||||
"install": "Instalar",
|
||||
"last_time_played": "Jogou por último {{period}}",
|
||||
"last_time_played": "Última sessão {{period}}",
|
||||
"play": "Jogar",
|
||||
"not_played_yet": "Você ainda não jogou {{title}}",
|
||||
"close": "Fechar",
|
||||
"deleting": "Excluindo instalador…",
|
||||
"playing_now": "Jogando agora",
|
||||
"change": "Mudar",
|
||||
"change": "Explorar",
|
||||
"repacks_modal_description": "Escolha o repack do jogo que deseja baixar",
|
||||
"select_folder_hint": "Para trocar o diretório padrão, acesse a <0>Tela de Ajustes</0>",
|
||||
"download_now": "Iniciar download",
|
||||
@@ -90,13 +90,13 @@
|
||||
"open_screenshot": "Ver captura de tela {{number}}",
|
||||
"download_settings": "Ajustes do download",
|
||||
"downloader": "Downloader",
|
||||
"select_executable": "Selecionar",
|
||||
"select_executable": "Explorar",
|
||||
"no_executable_selected": "Nenhum executável selecionado",
|
||||
"open_folder": "Abrir pasta",
|
||||
"open_download_location": "Ver arquivos baixados",
|
||||
"create_shortcut": "Criar atalho na área de trabalho",
|
||||
"remove_files": "Remover arquivos",
|
||||
"options": "Opções",
|
||||
"options": "Gerenciar",
|
||||
"remove_from_library_description": "Isso irá remover {{game}} da sua biblioteca",
|
||||
"remove_from_library_title": "Tem certeza?",
|
||||
"executable_section_title": "Executável",
|
||||
@@ -120,7 +120,7 @@
|
||||
"loading": "Carregando…"
|
||||
},
|
||||
"downloads": {
|
||||
"resume": "Resumir",
|
||||
"resume": "Retomar",
|
||||
"pause": "Pausar",
|
||||
"eta": "Conclusão {{eta}}",
|
||||
"paused": "Pausado",
|
||||
@@ -146,12 +146,12 @@
|
||||
},
|
||||
"settings": {
|
||||
"downloads_path": "Diretório dos downloads",
|
||||
"change": "Mudar",
|
||||
"change": "Explorar...",
|
||||
"notifications": "Notificações",
|
||||
"enable_download_notifications": "Quando um download for concluído",
|
||||
"enable_repack_list_notifications": "Quando a lista de repacks for atualizada",
|
||||
"real_debrid_api_token_label": "Token de API do Real-Debrid",
|
||||
"quit_app_instead_hiding": "Encerrar o Hydra ao invés de minimizá-lo ao fechar",
|
||||
"quit_app_instead_hiding": "Encerrar o Hydra em vez de apenas minimizá-lo ao fechar.",
|
||||
"launch_with_system": "Iniciar o Hydra junto com o sistema",
|
||||
"general": "Geral",
|
||||
"behavior": "Comportamento",
|
||||
@@ -195,7 +195,9 @@
|
||||
"game_ready_to_install": "{{title}} está pronto para ser instalado",
|
||||
"repack_list_updated": "Lista de repacks atualizada",
|
||||
"repack_count_one": "{{count}} novo repack",
|
||||
"repack_count_other": "{{count}} novos repacks"
|
||||
"repack_count_other": "{{count}} novos repacks",
|
||||
"new_update_available": "Versão {{version}} disponível",
|
||||
"restart_to_install_update": "Reinicie o Hydra para instalar a nova versão"
|
||||
},
|
||||
"system_tray": {
|
||||
"open": "Abrir Hydra",
|
||||
@@ -206,7 +208,7 @@
|
||||
},
|
||||
"binary_not_found_modal": {
|
||||
"title": "Programas não instalados",
|
||||
"description": "Não foram encontrados no seu sistema os executáveis do Wine ou Lutris",
|
||||
"description": "Os executáveis do Wine ou Lutris não foram encontrados em seu sistema.",
|
||||
"instructions": "Verifique a forma correta de instalar algum deles no seu distro Linux, garantindo assim a execução normal do jogo"
|
||||
},
|
||||
"catalogue": {
|
||||
@@ -222,8 +224,8 @@
|
||||
"user_profile": {
|
||||
"amount_hours": "{{amount}} horas",
|
||||
"amount_minutes": "{{amount}} minutos",
|
||||
"last_time_played": "Jogou {{period}}",
|
||||
"activity": "Atividade recente",
|
||||
"last_time_played": "Última sessão {{period}}",
|
||||
"activity": "Atividades recentes",
|
||||
"library": "Biblioteca",
|
||||
"total_play_time": "Tempo total de jogo: {{amount}}",
|
||||
"no_recent_activity_title": "Hmmm… nada por aqui",
|
||||
@@ -231,7 +233,7 @@
|
||||
"display_name": "Nome de exibição",
|
||||
"saving": "Salvando…",
|
||||
"save": "Salvar",
|
||||
"edit_profile": "Editar Perfil",
|
||||
"edit_profile": "Editar perfil",
|
||||
"saved_successfully": "Salvo com sucesso",
|
||||
"try_again": "Por favor, tente novamente",
|
||||
"cancel": "Cancelar",
|
||||
@@ -239,6 +241,15 @@
|
||||
"sign_out": "Sair da conta",
|
||||
"sign_out_modal_title": "Tem certeza?",
|
||||
"playing_for": "Jogando por {{amount}}",
|
||||
"sign_out_modal_text": "Sua biblioteca de jogos está associada com a sua conta atual. Ao sair, sua biblioteca não aparecerá mais no Hydra e qualquer progresso não será salvo. Deseja continuar?"
|
||||
"sign_out_modal_text": "Sua biblioteca de jogos está associada com a sua conta atual. Ao sair, sua biblioteca não aparecerá mais no Hydra e qualquer progresso não será salvo. Deseja continuar?",
|
||||
"add_friends": "Adicionar Amigos",
|
||||
"friend_code": "Código de amigo",
|
||||
"see_profile": "Ver perfil",
|
||||
"friend_request_sent": "Pedido de amizade enviado",
|
||||
"friends": "Amigos",
|
||||
"add": "Adicionar",
|
||||
"sending": "Enviando",
|
||||
"friends_list": "Lista de amigos",
|
||||
"user_not_found": "Usuário não encontrado"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,7 +36,8 @@
|
||||
"no_downloads_in_progress": "Нет активных загрузок",
|
||||
"downloading_metadata": "Загрузка метаданных {{title}}…",
|
||||
"downloading": "Загрузка {{title}}… ({{percentage}} завершено) - Окончание {{eta}} - {{speed}}",
|
||||
"calculating_eta": "Загрузка {{title}}… ({{percentage}} завершено) - Подсчёт оставшегося времени…"
|
||||
"calculating_eta": "Загрузка {{title}}… ({{percentage}} завершено) - Подсчёт оставшегося времени…",
|
||||
"checking_files": "Проверка файлов {{title}}… ({{percentage}} завершено)"
|
||||
},
|
||||
"catalogue": {
|
||||
"next_page": "Следующая страница",
|
||||
@@ -144,7 +145,8 @@
|
||||
"downloads_completed": "Завершено",
|
||||
"queued": "В очереди",
|
||||
"no_downloads_title": "Здесь так пусто...",
|
||||
"no_downloads_description": "Вы ещё ничего не скачали через Hydra, но никогда не поздно начать."
|
||||
"no_downloads_description": "Вы ещё ничего не скачали через Hydra, но никогда не поздно начать.",
|
||||
"checking_files": "Проверка файлов…"
|
||||
},
|
||||
"settings": {
|
||||
"downloads_path": "Путь загрузок",
|
||||
@@ -192,12 +194,27 @@
|
||||
"found_download_option_other": "Найдено {{countFormatted}} вариантов загрузки",
|
||||
"import": "Импортировать"
|
||||
},
|
||||
"collections": {
|
||||
"collections": "Коллекции",
|
||||
"add_the_game_to_the_collection": "Добавьте игру в коллекцию",
|
||||
"select_a_collection": "Выберите коллекцию",
|
||||
"enter_the_name_of_the_collection": "Введите название коллекции",
|
||||
"add": "Добавить",
|
||||
"remove": "Удалить",
|
||||
"you_cant_give_collections_existing_or_empty_names": "Нельзя давать коллекциям существующие или пустые названия",
|
||||
"the_collection_has_been_added_successfully": "Коллекция успешно добавлена",
|
||||
"the_collection_has_been_removed_successfully": "Коллекция успешно удалена",
|
||||
"the_game_has_been_added_to_the_collection": "Игра добавлена в коллекцию",
|
||||
"the_game_has_been_removed_from_the_collection": "Игра удалена из коллекции"
|
||||
},
|
||||
"notifications": {
|
||||
"download_complete": "Загрузка завершена",
|
||||
"game_ready_to_install": "{{title}} готова к установке",
|
||||
"repack_list_updated": "Список репаков обновлен",
|
||||
"repack_count_one": "{{count}} репак добавлен",
|
||||
"repack_count_other": "{{count}} репаков добавлено"
|
||||
"repack_count_other": "{{count}} репаков добавлено",
|
||||
"new_update_available": "Доступна версия {{version}}",
|
||||
"restart_to_install_update": "Перезапустите Hydra для установки обновления"
|
||||
},
|
||||
"system_tray": {
|
||||
"open": "Открыть Hydra",
|
||||
@@ -228,7 +245,7 @@
|
||||
"no_recent_activity_description": "Вы давно ни во что не играли. Пора это изменить!",
|
||||
"display_name": "Отображаемое имя",
|
||||
"saving": "Сохранение",
|
||||
"save": "Сохранено",
|
||||
"save": "Сохранить",
|
||||
"edit_profile": "Редактировать Профиль",
|
||||
"saved_successfully": "Успешно сохранено",
|
||||
"try_again": "Пожалуйста, попробуйте ещё раз",
|
||||
|
||||
@@ -14,12 +14,3 @@ export const logsPath = path.join(app.getPath("appData"), "hydra", "logs");
|
||||
export const seedsPath = app.isPackaged
|
||||
? path.join(process.resourcesPath, "seeds")
|
||||
: path.join(__dirname, "..", "..", "seeds");
|
||||
|
||||
export const windowsStartupPath = path.join(
|
||||
app.getPath("appData"),
|
||||
"Microsoft",
|
||||
"Windows",
|
||||
"Start Menu",
|
||||
"Programs",
|
||||
"Startup"
|
||||
);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { DataSource } from "typeorm";
|
||||
import {
|
||||
Collection,
|
||||
DownloadQueue,
|
||||
DownloadSource,
|
||||
Game,
|
||||
@@ -19,6 +20,7 @@ export const createDataSource = (
|
||||
new DataSource({
|
||||
type: "better-sqlite3",
|
||||
entities: [
|
||||
Collection,
|
||||
Game,
|
||||
Repack,
|
||||
UserPreferences,
|
||||
|
||||
80
src/main/declaration.d.ts
vendored
Normal file
80
src/main/declaration.d.ts
vendored
Normal file
@@ -0,0 +1,80 @@
|
||||
declare module "aria2" {
|
||||
export type Aria2Status =
|
||||
| "active"
|
||||
| "waiting"
|
||||
| "paused"
|
||||
| "error"
|
||||
| "complete"
|
||||
| "removed";
|
||||
|
||||
export interface StatusResponse {
|
||||
gid: string;
|
||||
status: Aria2Status;
|
||||
totalLength: string;
|
||||
completedLength: string;
|
||||
uploadLength: string;
|
||||
bitfield: string;
|
||||
downloadSpeed: string;
|
||||
uploadSpeed: string;
|
||||
infoHash?: string;
|
||||
numSeeders?: string;
|
||||
seeder?: boolean;
|
||||
pieceLength: string;
|
||||
numPieces: string;
|
||||
connections: string;
|
||||
errorCode?: string;
|
||||
errorMessage?: string;
|
||||
followedBy?: string[];
|
||||
following: string;
|
||||
belongsTo: string;
|
||||
dir: string;
|
||||
files: {
|
||||
path: string;
|
||||
length: string;
|
||||
completedLength: string;
|
||||
selected: string;
|
||||
}[];
|
||||
bittorrent?: {
|
||||
announceList: string[][];
|
||||
comment: string;
|
||||
creationDate: string;
|
||||
mode: "single" | "multi";
|
||||
info: {
|
||||
name: string;
|
||||
verifiedLength: string;
|
||||
verifyIntegrityPending: string;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
export default class Aria2 {
|
||||
constructor(options: any);
|
||||
open: () => Promise<void>;
|
||||
call(
|
||||
method: "addUri",
|
||||
uris: string[],
|
||||
options: { dir: string }
|
||||
): Promise<string>;
|
||||
call(
|
||||
method: "tellStatus",
|
||||
gid: string,
|
||||
keys?: string[]
|
||||
): Promise<StatusResponse>;
|
||||
call(method: "pause", gid: string): Promise<string>;
|
||||
call(method: "forcePause", gid: string): Promise<string>;
|
||||
call(method: "unpause", gid: string): Promise<string>;
|
||||
call(method: "remove", gid: string): Promise<string>;
|
||||
call(method: "forceRemove", gid: string): Promise<string>;
|
||||
call(method: "pauseAll"): Promise<string>;
|
||||
call(method: "forcePauseAll"): Promise<string>;
|
||||
listNotifications: () => [
|
||||
"onDownloadStart",
|
||||
"onDownloadPause",
|
||||
"onDownloadStop",
|
||||
"onDownloadComplete",
|
||||
"onDownloadError",
|
||||
"onBtDownloadComplete",
|
||||
];
|
||||
on: (event: string, callback: (params: any) => void) => void;
|
||||
}
|
||||
}
|
||||
21
src/main/entity/collection.entity.ts
Normal file
21
src/main/entity/collection.entity.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import {
|
||||
Entity,
|
||||
PrimaryGeneratedColumn,
|
||||
Column,
|
||||
ManyToMany,
|
||||
JoinTable,
|
||||
} from "typeorm";
|
||||
import { Game } from "./game.entity";
|
||||
|
||||
@Entity("collection")
|
||||
export class Collection {
|
||||
@PrimaryGeneratedColumn()
|
||||
id: number;
|
||||
|
||||
@Column("text", { unique: true })
|
||||
title: string;
|
||||
|
||||
@ManyToMany("Game", "collections")
|
||||
@JoinTable()
|
||||
games: Game[];
|
||||
}
|
||||
@@ -4,4 +4,5 @@ export * from "./user-preferences.entity";
|
||||
export * from "./game-shop-cache.entity";
|
||||
export * from "./download-source.entity";
|
||||
export * from "./download-queue.entity";
|
||||
export * from "./collection.entity";
|
||||
export * from "./user-auth";
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { registerEvent } from "../register-event";
|
||||
import * as Sentry from "@sentry/electron/main";
|
||||
import { HydraApi, TorrentDownloader, gamesPlaytime } from "@main/services";
|
||||
import { HydraApi, PythonInstance, gamesPlaytime } from "@main/services";
|
||||
import { dataSource } from "@main/data-source";
|
||||
import { DownloadQueue, Game, UserAuth } from "@main/entity";
|
||||
|
||||
@@ -24,11 +24,11 @@ const signOut = async (_event: Electron.IpcMainInvokeEvent) => {
|
||||
Sentry.setUser(null);
|
||||
|
||||
/* Disconnects libtorrent */
|
||||
TorrentDownloader.kill();
|
||||
PythonInstance.killTorrent();
|
||||
|
||||
await Promise.all([
|
||||
databaseOperations,
|
||||
HydraApi.post("/auth/logout").catch(),
|
||||
HydraApi.post("/auth/logout").catch(() => {}),
|
||||
]);
|
||||
};
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import { registerEvent } from "../register-event";
|
||||
import updater, { UpdateInfo } from "electron-updater";
|
||||
import { WindowManager } from "@main/services";
|
||||
import { app } from "electron";
|
||||
import { publishNotificationUpdateReadyToInstall } from "@main/services/notifications";
|
||||
|
||||
const { autoUpdater } = updater;
|
||||
|
||||
@@ -20,13 +21,17 @@ const mockValuesForDebug = () => {
|
||||
sendEvent({ type: "update-downloaded" });
|
||||
};
|
||||
|
||||
const newVersionInfo = { version: "" };
|
||||
|
||||
const checkForUpdates = async (_event: Electron.IpcMainInvokeEvent) => {
|
||||
autoUpdater
|
||||
.once("update-available", (info: UpdateInfo) => {
|
||||
sendEvent({ type: "update-available", info });
|
||||
newVersionInfo.version = info.version;
|
||||
})
|
||||
.once("update-downloaded", () => {
|
||||
sendEvent({ type: "update-downloaded" });
|
||||
publishNotificationUpdateReadyToInstall(newVersionInfo.version);
|
||||
});
|
||||
|
||||
if (app.isPackaged) {
|
||||
|
||||
18
src/main/events/collections/add-collection-game.ts
Normal file
18
src/main/events/collections/add-collection-game.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { collectionRepository } from "@main/repository";
|
||||
|
||||
import { registerEvent } from "../register-event";
|
||||
import { Collection, Game } from "@main/entity";
|
||||
|
||||
const addCollectionGame = async (
|
||||
_event: Electron.IpcMainInvokeEvent,
|
||||
collectionId: number,
|
||||
game: Game
|
||||
) => {
|
||||
return await collectionRepository
|
||||
.createQueryBuilder()
|
||||
.relation(Collection, "games")
|
||||
.of(collectionId)
|
||||
.add(game);
|
||||
};
|
||||
|
||||
registerEvent("addCollectionGame", addCollectionGame);
|
||||
14
src/main/events/collections/add-collection.ts
Normal file
14
src/main/events/collections/add-collection.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { collectionRepository } from "@main/repository";
|
||||
|
||||
import { registerEvent } from "../register-event";
|
||||
|
||||
const addCollection = async (
|
||||
_event: Electron.IpcMainInvokeEvent,
|
||||
title: string
|
||||
) => {
|
||||
return await collectionRepository.insert({
|
||||
title: title,
|
||||
});
|
||||
};
|
||||
|
||||
registerEvent("addCollection", addCollection);
|
||||
19
src/main/events/collections/get-collections.ts
Normal file
19
src/main/events/collections/get-collections.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { collectionRepository } from "@main/repository";
|
||||
import { registerEvent } from "../register-event";
|
||||
|
||||
const getCollections = async () =>
|
||||
collectionRepository.find({
|
||||
relations: {
|
||||
games: true,
|
||||
},
|
||||
select: {
|
||||
games: {
|
||||
id: true,
|
||||
},
|
||||
},
|
||||
order: {
|
||||
title: "asc",
|
||||
},
|
||||
});
|
||||
|
||||
registerEvent("getCollections", getCollections);
|
||||
18
src/main/events/collections/remove-collection-game.ts
Normal file
18
src/main/events/collections/remove-collection-game.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { collectionRepository } from "@main/repository";
|
||||
|
||||
import { registerEvent } from "../register-event";
|
||||
import { Collection, Game } from "@main/entity";
|
||||
|
||||
const removeCollectionGame = async (
|
||||
_event: Electron.IpcMainInvokeEvent,
|
||||
collectionId: number,
|
||||
game: Game
|
||||
) => {
|
||||
return await collectionRepository
|
||||
.createQueryBuilder()
|
||||
.relation(Collection, "games")
|
||||
.of(collectionId)
|
||||
.remove(game);
|
||||
};
|
||||
|
||||
registerEvent("removeCollectionGame", removeCollectionGame);
|
||||
13
src/main/events/collections/remove-collection.ts
Normal file
13
src/main/events/collections/remove-collection.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { collectionRepository } from "@main/repository";
|
||||
|
||||
import { registerEvent } from "../register-event";
|
||||
import { Collection } from "@main/entity";
|
||||
|
||||
const removeCollection = async (
|
||||
_event: Electron.IpcMainInvokeEvent,
|
||||
collection: Collection
|
||||
) => {
|
||||
return await collectionRepository.remove(collection);
|
||||
};
|
||||
|
||||
registerEvent("removeCollection", removeCollection);
|
||||
@@ -1,17 +1,12 @@
|
||||
import { registerEvent } from "../register-event";
|
||||
import axios from "axios";
|
||||
import { downloadSourceRepository } from "@main/repository";
|
||||
import { downloadSourceSchema } from "../helpers/validators";
|
||||
import { RepacksManager } from "@main/services";
|
||||
import { downloadSourceWorker } from "@main/workers";
|
||||
|
||||
const validateDownloadSource = async (
|
||||
_event: Electron.IpcMainInvokeEvent,
|
||||
url: string
|
||||
) => {
|
||||
const response = await axios.get(url);
|
||||
|
||||
const source = downloadSourceSchema.parse(response.data);
|
||||
|
||||
const existingSource = await downloadSourceRepository.findOne({
|
||||
where: { url },
|
||||
});
|
||||
@@ -21,14 +16,12 @@ const validateDownloadSource = async (
|
||||
|
||||
const repacks = RepacksManager.repacks;
|
||||
|
||||
const existingUris = source.downloads
|
||||
.flatMap((download) => download.uris)
|
||||
.filter((uri) => repacks.some((repack) => repack.magnet === uri));
|
||||
|
||||
return {
|
||||
name: source.name,
|
||||
downloadCount: source.downloads.length - existingUris.length,
|
||||
};
|
||||
return downloadSourceWorker.run(
|
||||
{ url, repacks },
|
||||
{
|
||||
name: "validateDownloadSource",
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
registerEvent("validateDownloadSource", validateDownloadSource);
|
||||
|
||||
@@ -8,6 +8,11 @@ import "./catalogue/get-how-long-to-beat";
|
||||
import "./catalogue/get-random-game";
|
||||
import "./catalogue/search-games";
|
||||
import "./catalogue/search-game-repacks";
|
||||
import "./collections/add-collection";
|
||||
import "./collections/add-collection-game";
|
||||
import "./collections/get-collections";
|
||||
import "./collections/remove-collection";
|
||||
import "./collections/remove-collection-game";
|
||||
import "./hardware/get-disk-free-space";
|
||||
import "./library/add-game-to-library";
|
||||
import "./library/create-game-shortcut";
|
||||
@@ -22,7 +27,6 @@ import "./library/open-game-installer-path";
|
||||
import "./library/update-executable-path";
|
||||
import "./library/remove-game";
|
||||
import "./library/remove-game-from-library";
|
||||
import "./misc/is-user-logged-in";
|
||||
import "./misc/open-external";
|
||||
import "./misc/show-open-dialog";
|
||||
import "./torrenting/cancel-game-download";
|
||||
@@ -44,8 +48,11 @@ import "./auth/sign-out";
|
||||
import "./auth/open-auth-window";
|
||||
import "./auth/get-session-hash";
|
||||
import "./user/get-user";
|
||||
import "./profile/get-friend-requests";
|
||||
import "./profile/get-me";
|
||||
import "./profile/update-friend-request";
|
||||
import "./profile/update-profile";
|
||||
import "./profile/send-friend-request";
|
||||
|
||||
ipcMain.handle("ping", () => "pong");
|
||||
ipcMain.handle("getVersion", () => app.getVersion());
|
||||
|
||||
@@ -53,18 +53,7 @@ const addGameToLibrary = async (
|
||||
|
||||
const game = await gameRepository.findOne({ where: { objectID } });
|
||||
|
||||
createGame(game!).then((response) => {
|
||||
const {
|
||||
id: remoteId,
|
||||
playTimeInMilliseconds,
|
||||
lastTimePlayed,
|
||||
} = response.data;
|
||||
|
||||
gameRepository.update(
|
||||
{ objectID },
|
||||
{ remoteId, playTimeInMilliseconds, lastTimePlayed }
|
||||
);
|
||||
});
|
||||
createGame(game!);
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -1,39 +1,45 @@
|
||||
import path from "node:path";
|
||||
|
||||
import { gameRepository } from "@main/repository";
|
||||
import { getProcesses } from "@main/helpers";
|
||||
|
||||
import { registerEvent } from "../register-event";
|
||||
import { PythonInstance, logger } from "@main/services";
|
||||
import sudo from "sudo-prompt";
|
||||
import { app } from "electron";
|
||||
|
||||
const getKillCommand = (pid: number) => {
|
||||
if (process.platform == "win32") {
|
||||
return `taskkill /PID ${pid}`;
|
||||
}
|
||||
|
||||
return `kill -9 ${pid}`;
|
||||
};
|
||||
|
||||
const closeGame = async (
|
||||
_event: Electron.IpcMainInvokeEvent,
|
||||
gameId: number
|
||||
) => {
|
||||
const processes = await getProcesses();
|
||||
const processes = await PythonInstance.getProcessList();
|
||||
const game = await gameRepository.findOne({
|
||||
where: { id: gameId, isDeleted: false },
|
||||
});
|
||||
|
||||
if (!game) return false;
|
||||
|
||||
const executablePath = game.executablePath!;
|
||||
|
||||
const basename = path.win32.basename(executablePath);
|
||||
const basenameWithoutExtension = path.win32.basename(
|
||||
executablePath,
|
||||
path.extname(executablePath)
|
||||
);
|
||||
if (!game) return;
|
||||
|
||||
const gameProcess = processes.find((runningProcess) => {
|
||||
if (process.platform === "win32") {
|
||||
return runningProcess.name === basename;
|
||||
}
|
||||
|
||||
return [basename, basenameWithoutExtension].includes(runningProcess.name);
|
||||
return runningProcess.exe === game.executablePath;
|
||||
});
|
||||
|
||||
if (gameProcess) return process.kill(gameProcess.pid);
|
||||
return false;
|
||||
if (gameProcess) {
|
||||
try {
|
||||
process.kill(gameProcess.pid);
|
||||
} catch (err) {
|
||||
sudo.exec(
|
||||
getKillCommand(gameProcess.pid),
|
||||
{ name: app.getName() },
|
||||
(error, _stdout, _stderr) => {
|
||||
logger.error(error);
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
registerEvent("closeGame", closeGame);
|
||||
|
||||
@@ -4,6 +4,7 @@ import { IsNull, Not } from "typeorm";
|
||||
import createDesktopShortcut from "create-desktop-shortcuts";
|
||||
import path from "node:path";
|
||||
import { app } from "electron";
|
||||
import { removeSymbolsFromName } from "@shared";
|
||||
|
||||
const createGameShortcut = async (
|
||||
_event: Electron.IpcMainInvokeEvent,
|
||||
@@ -22,7 +23,7 @@ const createGameShortcut = async (
|
||||
|
||||
const options = {
|
||||
filePath,
|
||||
name: game.title,
|
||||
name: removeSymbolsFromName(game.title),
|
||||
};
|
||||
|
||||
return createDesktopShortcut({
|
||||
|
||||
@@ -20,7 +20,7 @@ const removeRemoveGameFromLibrary = async (gameId: number) => {
|
||||
const game = await gameRepository.findOne({ where: { id: gameId } });
|
||||
|
||||
if (game?.remoteId) {
|
||||
HydraApi.delete(`/games/${game.remoteId}`);
|
||||
HydraApi.delete(`/games/${game.remoteId}`).catch(() => {});
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
import { registerEvent } from "../register-event";
|
||||
import { HydraApi } from "@main/services";
|
||||
|
||||
const isUserLoggedIn = async (_event: Electron.IpcMainInvokeEvent) => {
|
||||
return HydraApi.isLoggedIn();
|
||||
};
|
||||
|
||||
registerEvent("isUserLoggedIn", isUserLoggedIn);
|
||||
11
src/main/events/profile/get-friend-requests.ts
Normal file
11
src/main/events/profile/get-friend-requests.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { registerEvent } from "../register-event";
|
||||
import { HydraApi } from "@main/services";
|
||||
import { FriendRequest } from "@types";
|
||||
|
||||
const getFriendRequests = async (
|
||||
_event: Electron.IpcMainInvokeEvent
|
||||
): Promise<FriendRequest[]> => {
|
||||
return HydraApi.get(`/profile/friend-requests`).catch(() => []);
|
||||
};
|
||||
|
||||
registerEvent("getFriendRequests", getFriendRequests);
|
||||
@@ -3,15 +3,13 @@ import * as Sentry from "@sentry/electron/main";
|
||||
import { HydraApi } from "@main/services";
|
||||
import { UserProfile } from "@types";
|
||||
import { userAuthRepository } from "@main/repository";
|
||||
import { logger } from "@main/services";
|
||||
import { UserNotLoggedInError } from "@shared";
|
||||
|
||||
const getMe = async (
|
||||
_event: Electron.IpcMainInvokeEvent
|
||||
): Promise<UserProfile | null> => {
|
||||
return HydraApi.get(`/profile/me`)
|
||||
.then((response) => {
|
||||
const me = response.data;
|
||||
|
||||
.then((me) => {
|
||||
userAuthRepository.upsert(
|
||||
{
|
||||
id: 1,
|
||||
@@ -26,9 +24,18 @@ const getMe = async (
|
||||
|
||||
return me;
|
||||
})
|
||||
.catch((err) => {
|
||||
logger.error("getMe", err.message);
|
||||
return userAuthRepository.findOne({ where: { id: 1 } });
|
||||
.catch(async (err) => {
|
||||
if (err instanceof UserNotLoggedInError) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const loggedUser = await userAuthRepository.findOne({ where: { id: 1 } });
|
||||
|
||||
if (loggedUser) {
|
||||
return { ...loggedUser, id: loggedUser.userId };
|
||||
}
|
||||
|
||||
return null;
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
11
src/main/events/profile/send-friend-request.ts
Normal file
11
src/main/events/profile/send-friend-request.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { registerEvent } from "../register-event";
|
||||
import { HydraApi } from "@main/services";
|
||||
|
||||
const sendFriendRequest = async (
|
||||
_event: Electron.IpcMainInvokeEvent,
|
||||
userId: string
|
||||
) => {
|
||||
return HydraApi.post("/profile/friend-requests", { friendCode: userId });
|
||||
};
|
||||
|
||||
registerEvent("sendFriendRequest", sendFriendRequest);
|
||||
19
src/main/events/profile/update-friend-request.ts
Normal file
19
src/main/events/profile/update-friend-request.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { registerEvent } from "../register-event";
|
||||
import { HydraApi } from "@main/services";
|
||||
import { FriendRequestAction } from "@types";
|
||||
|
||||
const updateFriendRequest = async (
|
||||
_event: Electron.IpcMainInvokeEvent,
|
||||
userId: string,
|
||||
action: FriendRequestAction
|
||||
) => {
|
||||
if (action == "CANCEL") {
|
||||
return HydraApi.delete(`/profile/friend-requests/${userId}`);
|
||||
}
|
||||
|
||||
return HydraApi.patch(`/profile/friend-requests/${userId}`, {
|
||||
requestState: action,
|
||||
});
|
||||
};
|
||||
|
||||
registerEvent("updateFriendRequest", updateFriendRequest);
|
||||
@@ -28,7 +28,7 @@ const updateProfile = async (
|
||||
newProfileImagePath: string | null
|
||||
): Promise<UserProfile> => {
|
||||
if (!newProfileImagePath) {
|
||||
return (await patchUserProfile(displayName)).data;
|
||||
return patchUserProfile(displayName);
|
||||
}
|
||||
|
||||
const stats = fs.statSync(newProfileImagePath);
|
||||
@@ -40,7 +40,7 @@ const updateProfile = async (
|
||||
imageLength: fileSizeInBytes,
|
||||
})
|
||||
.then(async (preSignedResponse) => {
|
||||
const { presignedUrl, profileImageUrl } = preSignedResponse.data;
|
||||
const { presignedUrl, profileImageUrl } = preSignedResponse;
|
||||
|
||||
const mimeType = await fileTypeFromFile(newProfileImagePath);
|
||||
|
||||
@@ -49,13 +49,11 @@ const updateProfile = async (
|
||||
"Content-Type": mimeType?.mime,
|
||||
},
|
||||
});
|
||||
return profileImageUrl;
|
||||
return profileImageUrl as string;
|
||||
})
|
||||
.catch(() => {
|
||||
return undefined;
|
||||
});
|
||||
.catch(() => undefined);
|
||||
|
||||
return (await patchUserProfile(displayName, profileImageUrl)).data;
|
||||
return patchUserProfile(displayName, profileImageUrl);
|
||||
};
|
||||
|
||||
registerEvent("updateProfile", updateProfile);
|
||||
|
||||
@@ -95,18 +95,7 @@ const startGameDownload = async (
|
||||
},
|
||||
});
|
||||
|
||||
createGame(updatedGame!).then((response) => {
|
||||
const {
|
||||
id: remoteId,
|
||||
playTimeInMilliseconds,
|
||||
lastTimePlayed,
|
||||
} = response.data;
|
||||
|
||||
gameRepository.update(
|
||||
{ objectID },
|
||||
{ remoteId, playTimeInMilliseconds, lastTimePlayed }
|
||||
);
|
||||
});
|
||||
createGame(updatedGame!);
|
||||
|
||||
await downloadQueueRepository.delete({ game: { id: updatedGame!.id } });
|
||||
await downloadQueueRepository.insert({ game: { id: updatedGame!.id } });
|
||||
|
||||
@@ -1,9 +1,18 @@
|
||||
import { windowsStartupPath } from "@main/constants";
|
||||
import { registerEvent } from "../register-event";
|
||||
import AutoLaunch from "auto-launch";
|
||||
import { app } from "electron";
|
||||
import path from "path";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { logger } from "@main/services";
|
||||
|
||||
const windowsStartupPath = path.join(
|
||||
app.getPath("appData"),
|
||||
"Microsoft",
|
||||
"Windows",
|
||||
"Start Menu",
|
||||
"Programs",
|
||||
"Startup"
|
||||
);
|
||||
|
||||
const autoLaunch = async (
|
||||
_event: Electron.IpcMainInvokeEvent,
|
||||
@@ -15,23 +24,18 @@ const autoLaunch = async (
|
||||
name: app.getName(),
|
||||
});
|
||||
|
||||
if (process.platform == "win32") {
|
||||
const destination = path.join(windowsStartupPath, "Hydra.vbs");
|
||||
|
||||
if (enabled) {
|
||||
const scriptPath = path.join(process.resourcesPath, "hydralauncher.vbs");
|
||||
|
||||
fs.copyFileSync(scriptPath, destination);
|
||||
} else {
|
||||
appLauncher.disable().catch();
|
||||
fs.rmSync(destination);
|
||||
}
|
||||
if (enabled) {
|
||||
appLauncher.enable().catch((err) => {
|
||||
logger.error(err);
|
||||
});
|
||||
} else {
|
||||
if (enabled) {
|
||||
appLauncher.enable().catch();
|
||||
} else {
|
||||
appLauncher.disable().catch();
|
||||
if (process.platform == "win32") {
|
||||
fs.rm(path.join(windowsStartupPath, "Hydra.vbs"), () => {});
|
||||
}
|
||||
|
||||
appLauncher.disable().catch((err) => {
|
||||
logger.error(err);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -2,17 +2,23 @@ import { userPreferencesRepository } from "@main/repository";
|
||||
import { registerEvent } from "../register-event";
|
||||
|
||||
import type { UserPreferences } from "@types";
|
||||
import i18next from "i18next";
|
||||
|
||||
const updateUserPreferences = async (
|
||||
_event: Electron.IpcMainInvokeEvent,
|
||||
preferences: Partial<UserPreferences>
|
||||
) =>
|
||||
userPreferencesRepository.upsert(
|
||||
) => {
|
||||
if (preferences.language) {
|
||||
i18next.changeLanguage(preferences.language);
|
||||
}
|
||||
|
||||
return userPreferencesRepository.upsert(
|
||||
{
|
||||
id: 1,
|
||||
...preferences,
|
||||
},
|
||||
["id"]
|
||||
);
|
||||
};
|
||||
|
||||
registerEvent("updateUserPreferences", updateUserPreferences);
|
||||
|
||||
@@ -10,8 +10,7 @@ const getUser = async (
|
||||
userId: string
|
||||
): Promise<UserProfile | null> => {
|
||||
try {
|
||||
const response = await HydraApi.get(`/user/${userId}`);
|
||||
const profile = response.data;
|
||||
const profile = await HydraApi.get(`/user/${userId}`);
|
||||
|
||||
const recentGames = await Promise.all(
|
||||
profile.recentGames.map(async (game) => {
|
||||
|
||||
@@ -57,5 +57,4 @@ export const requestWebPage = async (url: string) => {
|
||||
.then((response) => response.data);
|
||||
};
|
||||
|
||||
export * from "./ps";
|
||||
export * from "./download-source";
|
||||
|
||||
@@ -1,41 +0,0 @@
|
||||
import psList from "ps-list";
|
||||
import path from "node:path";
|
||||
import childProcess from "node:child_process";
|
||||
import { promisify } from "node:util";
|
||||
import { app } from "electron";
|
||||
|
||||
const TEN_MEGABYTES = 1000 * 1000 * 10;
|
||||
const execFile = promisify(childProcess.execFile);
|
||||
|
||||
export const getProcesses = async () => {
|
||||
if (process.platform == "win32") {
|
||||
const binaryPath = app.isPackaged
|
||||
? path.join(process.resourcesPath, "fastlist.exe")
|
||||
: path.join(
|
||||
__dirname,
|
||||
"..",
|
||||
"..",
|
||||
"node_modules",
|
||||
"ps-list",
|
||||
"vendor",
|
||||
"fastlist-0.3.0-x64.exe"
|
||||
);
|
||||
|
||||
const { stdout } = await execFile(binaryPath, {
|
||||
maxBuffer: TEN_MEGABYTES,
|
||||
windowsHide: true,
|
||||
});
|
||||
|
||||
return stdout
|
||||
.trim()
|
||||
.split("\r\n")
|
||||
.map((line) => line.split("\t"))
|
||||
.map(([pid, ppid, name]) => ({
|
||||
pid: Number.parseInt(pid, 10),
|
||||
ppid: Number.parseInt(ppid, 10),
|
||||
name,
|
||||
}));
|
||||
} else {
|
||||
return psList();
|
||||
}
|
||||
};
|
||||
@@ -5,7 +5,7 @@ import i18n from "i18next";
|
||||
import path from "node:path";
|
||||
import url from "node:url";
|
||||
import { electronApp, optimizer } from "@electron-toolkit/utils";
|
||||
import { logger, TorrentDownloader, WindowManager } from "@main/services";
|
||||
import { logger, PythonInstance, WindowManager } from "@main/services";
|
||||
import { dataSource } from "@main/data-source";
|
||||
import * as resources from "@locales";
|
||||
import { userPreferencesRepository } from "@main/repository";
|
||||
@@ -20,6 +20,8 @@ autoUpdater.setFeedURL({
|
||||
|
||||
autoUpdater.logger = logger;
|
||||
|
||||
logger.log("Init Hydra");
|
||||
|
||||
const gotTheLock = app.requestSingleInstanceLock();
|
||||
if (!gotTheLock) app.quit();
|
||||
|
||||
@@ -72,6 +74,10 @@ app.whenReady().then(async () => {
|
||||
where: { id: 1 },
|
||||
});
|
||||
|
||||
if (userPreferences?.language) {
|
||||
i18n.changeLanguage(userPreferences.language);
|
||||
}
|
||||
|
||||
WindowManager.createMainWindow();
|
||||
WindowManager.createSystemTray(userPreferences?.language || "en");
|
||||
});
|
||||
@@ -116,7 +122,8 @@ app.on("window-all-closed", () => {
|
||||
|
||||
app.on("before-quit", () => {
|
||||
/* Disconnects libtorrent */
|
||||
TorrentDownloader.kill();
|
||||
PythonInstance.kill();
|
||||
logger.log("Quit Hydra");
|
||||
});
|
||||
|
||||
app.on("activate", () => {
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
import { DownloadManager, RepacksManager, startMainLoop } from "./services";
|
||||
import {
|
||||
DownloadManager,
|
||||
RepacksManager,
|
||||
PythonInstance,
|
||||
startMainLoop,
|
||||
} from "./services";
|
||||
import {
|
||||
downloadQueueRepository,
|
||||
repackRepository,
|
||||
@@ -12,18 +17,16 @@ import { MoreThan } from "typeorm";
|
||||
import { HydraApi } from "./services/hydra-api";
|
||||
import { uploadGamesBatch } from "./services/library-sync";
|
||||
|
||||
startMainLoop();
|
||||
|
||||
const loadState = async (userPreferences: UserPreferences | null) => {
|
||||
await RepacksManager.updateRepacks();
|
||||
RepacksManager.updateRepacks();
|
||||
|
||||
import("./events");
|
||||
|
||||
if (userPreferences?.realDebridApiToken)
|
||||
RealDebridClient.authorize(userPreferences?.realDebridApiToken);
|
||||
|
||||
HydraApi.setupApi().then(async () => {
|
||||
if (HydraApi.isLoggedIn()) uploadGamesBatch();
|
||||
HydraApi.setupApi().then(() => {
|
||||
uploadGamesBatch();
|
||||
});
|
||||
|
||||
const [nextQueueItem] = await downloadQueueRepository.find({
|
||||
@@ -35,8 +38,13 @@ const loadState = async (userPreferences: UserPreferences | null) => {
|
||||
},
|
||||
});
|
||||
|
||||
if (nextQueueItem?.game.status === "active")
|
||||
if (nextQueueItem?.game.status === "active") {
|
||||
DownloadManager.startDownload(nextQueueItem.game);
|
||||
} else {
|
||||
PythonInstance.spawn();
|
||||
}
|
||||
|
||||
startMainLoop();
|
||||
|
||||
const now = new Date();
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { dataSource } from "./data-source";
|
||||
import {
|
||||
Collection,
|
||||
DownloadQueue,
|
||||
DownloadSource,
|
||||
Game,
|
||||
@@ -24,3 +25,5 @@ export const downloadSourceRepository =
|
||||
export const downloadQueueRepository = dataSource.getRepository(DownloadQueue);
|
||||
|
||||
export const userAuthRepository = dataSource.getRepository(UserAuth);
|
||||
|
||||
export const collectionRepository = dataSource.getRepository(Collection);
|
||||
|
||||
20
src/main/services/aria2c.ts
Normal file
20
src/main/services/aria2c.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import path from "node:path";
|
||||
import { spawn } from "node:child_process";
|
||||
import { app } from "electron";
|
||||
|
||||
export const startAria2 = () => {
|
||||
const binaryPath = app.isPackaged
|
||||
? path.join(process.resourcesPath, "aria2", "aria2c")
|
||||
: path.join(__dirname, "..", "..", "aria2", "aria2c");
|
||||
|
||||
return spawn(
|
||||
binaryPath,
|
||||
[
|
||||
"--enable-rpc",
|
||||
"--rpc-listen-all",
|
||||
"--file-allocation=none",
|
||||
"--allow-overwrite=true",
|
||||
],
|
||||
{ stdio: "inherit", windowsHide: true }
|
||||
);
|
||||
};
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Game } from "@main/entity";
|
||||
import { Downloader } from "@shared";
|
||||
import { TorrentDownloader } from "./torrent-downloader";
|
||||
import { PythonInstance } from "./python-instance";
|
||||
import { WindowManager } from "../window-manager";
|
||||
import { downloadQueueRepository, gameRepository } from "@main/repository";
|
||||
import { publishDownloadCompleteNotification } from "../notifications";
|
||||
@@ -16,7 +16,7 @@ export class DownloadManager {
|
||||
if (this.currentDownloader === Downloader.RealDebrid) {
|
||||
status = await RealDebridDownloader.getStatus();
|
||||
} else {
|
||||
status = await TorrentDownloader.getStatus();
|
||||
status = await PythonInstance.getStatus();
|
||||
}
|
||||
|
||||
if (status) {
|
||||
@@ -63,9 +63,9 @@ export class DownloadManager {
|
||||
|
||||
static async pauseDownload() {
|
||||
if (this.currentDownloader === Downloader.RealDebrid) {
|
||||
RealDebridDownloader.pauseDownload();
|
||||
await RealDebridDownloader.pauseDownload();
|
||||
} else {
|
||||
await TorrentDownloader.pauseDownload();
|
||||
await PythonInstance.pauseDownload();
|
||||
}
|
||||
|
||||
WindowManager.mainWindow?.setProgressBar(-1);
|
||||
@@ -77,16 +77,16 @@ export class DownloadManager {
|
||||
RealDebridDownloader.startDownload(game);
|
||||
this.currentDownloader = Downloader.RealDebrid;
|
||||
} else {
|
||||
TorrentDownloader.startDownload(game);
|
||||
PythonInstance.startDownload(game);
|
||||
this.currentDownloader = Downloader.Torrent;
|
||||
}
|
||||
}
|
||||
|
||||
static async cancelDownload(gameId: number) {
|
||||
if (this.currentDownloader === Downloader.RealDebrid) {
|
||||
RealDebridDownloader.cancelDownload();
|
||||
RealDebridDownloader.cancelDownload(gameId);
|
||||
} else {
|
||||
TorrentDownloader.cancelDownload(gameId);
|
||||
PythonInstance.cancelDownload(gameId);
|
||||
}
|
||||
|
||||
WindowManager.mainWindow?.setProgressBar(-1);
|
||||
@@ -98,7 +98,7 @@ export class DownloadManager {
|
||||
RealDebridDownloader.startDownload(game);
|
||||
this.currentDownloader = Downloader.RealDebrid;
|
||||
} else {
|
||||
TorrentDownloader.startDownload(game);
|
||||
PythonInstance.startDownload(game);
|
||||
this.currentDownloader = Downloader.Torrent;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,123 +1,68 @@
|
||||
import path from "node:path";
|
||||
import fs from "node:fs";
|
||||
import crypto from "node:crypto";
|
||||
|
||||
import axios, { type AxiosProgressEvent } from "axios";
|
||||
import { app } from "electron";
|
||||
import type { ChildProcess } from "node:child_process";
|
||||
import { logger } from "../logger";
|
||||
import { sleep } from "@main/helpers";
|
||||
import { startAria2 } from "../aria2c";
|
||||
import Aria2 from "aria2";
|
||||
|
||||
export class HttpDownload {
|
||||
private abortController: AbortController;
|
||||
public lastProgressEvent: AxiosProgressEvent;
|
||||
private trackerFilePath: string;
|
||||
private static connected = false;
|
||||
private static aria2c: ChildProcess | null = null;
|
||||
|
||||
private trackerProgressEvent: AxiosProgressEvent | null = null;
|
||||
private downloadPath: string;
|
||||
private static aria2 = new Aria2({});
|
||||
|
||||
private downloadTrackersPath = path.join(
|
||||
app.getPath("documents"),
|
||||
"Hydra",
|
||||
"Downloads"
|
||||
);
|
||||
private static async connect() {
|
||||
this.aria2c = startAria2();
|
||||
|
||||
constructor(
|
||||
private url: string,
|
||||
private savePath: string
|
||||
) {
|
||||
this.abortController = new AbortController();
|
||||
let retries = 0;
|
||||
|
||||
const sha256Hasher = crypto.createHash("sha256");
|
||||
const hash = sha256Hasher.update(url).digest("hex");
|
||||
while (retries < 4 && !this.connected) {
|
||||
try {
|
||||
await this.aria2.open();
|
||||
logger.log("Connected to aria2");
|
||||
|
||||
this.trackerFilePath = path.join(
|
||||
this.downloadTrackersPath,
|
||||
`${hash}.hydradownload`
|
||||
);
|
||||
|
||||
const filename = path.win32.basename(this.url);
|
||||
this.downloadPath = path.join(this.savePath, filename);
|
||||
}
|
||||
|
||||
private updateTrackerFile() {
|
||||
if (!fs.existsSync(this.downloadTrackersPath)) {
|
||||
fs.mkdirSync(this.downloadTrackersPath, {
|
||||
recursive: true,
|
||||
});
|
||||
}
|
||||
|
||||
fs.writeFileSync(
|
||||
this.trackerFilePath,
|
||||
JSON.stringify(this.lastProgressEvent),
|
||||
{ encoding: "utf-8" }
|
||||
);
|
||||
}
|
||||
|
||||
private removeTrackerFile() {
|
||||
if (fs.existsSync(this.trackerFilePath)) {
|
||||
fs.rm(this.trackerFilePath, (err) => {
|
||||
logger.error(err);
|
||||
});
|
||||
this.connected = true;
|
||||
} catch (err) {
|
||||
await sleep(100);
|
||||
logger.log("Failed to connect to aria2, retrying...");
|
||||
retries++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public async startDownload() {
|
||||
// Check if there's already a tracker file and download file
|
||||
if (
|
||||
fs.existsSync(this.trackerFilePath) &&
|
||||
fs.existsSync(this.downloadPath)
|
||||
) {
|
||||
this.trackerProgressEvent = JSON.parse(
|
||||
fs.readFileSync(this.trackerFilePath, { encoding: "utf-8" })
|
||||
);
|
||||
public static getStatus(gid: string) {
|
||||
if (this.connected) {
|
||||
return this.aria2.call("tellStatus", gid);
|
||||
}
|
||||
|
||||
const response = await axios.get(this.url, {
|
||||
responseType: "stream",
|
||||
signal: this.abortController.signal,
|
||||
headers: {
|
||||
Range: `bytes=${this.trackerProgressEvent?.loaded ?? 0}-`,
|
||||
},
|
||||
onDownloadProgress: (progressEvent) => {
|
||||
const total =
|
||||
this.trackerProgressEvent?.total ?? progressEvent.total ?? 0;
|
||||
const loaded =
|
||||
(this.trackerProgressEvent?.loaded ?? 0) + progressEvent.loaded;
|
||||
|
||||
const progress = loaded / total;
|
||||
|
||||
this.lastProgressEvent = {
|
||||
...progressEvent,
|
||||
total,
|
||||
progress,
|
||||
loaded,
|
||||
};
|
||||
this.updateTrackerFile();
|
||||
|
||||
if (progressEvent.progress === 1) {
|
||||
this.removeTrackerFile();
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
response.data.pipe(
|
||||
fs.createWriteStream(this.downloadPath, {
|
||||
flags: "a",
|
||||
})
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
public async pauseDownload() {
|
||||
this.abortController.abort();
|
||||
public static disconnect() {
|
||||
if (this.aria2c) {
|
||||
this.aria2c.kill();
|
||||
this.connected = false;
|
||||
}
|
||||
}
|
||||
|
||||
public cancelDownload() {
|
||||
this.pauseDownload();
|
||||
static async cancelDownload(gid: string) {
|
||||
await this.aria2.call("forceRemove", gid);
|
||||
}
|
||||
|
||||
fs.rm(this.downloadPath, (err) => {
|
||||
if (err) logger.error(err);
|
||||
});
|
||||
fs.rm(this.trackerFilePath, (err) => {
|
||||
if (err) logger.error(err);
|
||||
});
|
||||
static async pauseDownload(gid: string) {
|
||||
await this.aria2.call("forcePause", gid);
|
||||
}
|
||||
|
||||
static async resumeDownload(gid: string) {
|
||||
await this.aria2.call("unpause", gid);
|
||||
}
|
||||
|
||||
static async startDownload(downloadPath: string, downloadUrl: string) {
|
||||
if (!this.connected) await this.connect();
|
||||
|
||||
const options = {
|
||||
dir: downloadPath,
|
||||
};
|
||||
|
||||
return this.aria2.call("addUri", [downloadUrl], options);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
export * from "./download-manager";
|
||||
export * from "./torrent-downloader";
|
||||
export * from "./python-instance";
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import cp from "node:child_process";
|
||||
|
||||
import { Game } from "@main/entity";
|
||||
import { RPC_PASSWORD, RPC_PORT, startTorrentClient } from "./torrent-client";
|
||||
import {
|
||||
RPC_PASSWORD,
|
||||
RPC_PORT,
|
||||
startTorrentClient as startRPCClient,
|
||||
} from "./torrent-client";
|
||||
import { gameRepository } from "@main/repository";
|
||||
import { DownloadProgress } from "@types";
|
||||
import { QueryDeepPartialEntity } from "typeorm/query-builder/QueryPartialEntity";
|
||||
@@ -13,10 +17,11 @@ import {
|
||||
PauseDownloadPayload,
|
||||
LibtorrentStatus,
|
||||
LibtorrentPayload,
|
||||
ProcessPayload,
|
||||
} from "./types";
|
||||
|
||||
export class TorrentDownloader {
|
||||
private static torrentClient: cp.ChildProcess | null = null;
|
||||
export class PythonInstance {
|
||||
private static pythonProcess: cp.ChildProcess | null = null;
|
||||
private static downloadingGameId = -1;
|
||||
|
||||
private static rpc = axios.create({
|
||||
@@ -26,18 +31,31 @@ export class TorrentDownloader {
|
||||
},
|
||||
});
|
||||
|
||||
private static spawn(args: StartDownloadPayload) {
|
||||
this.torrentClient = startTorrentClient(args);
|
||||
public static spawn(args?: StartDownloadPayload) {
|
||||
this.pythonProcess = startRPCClient(args);
|
||||
}
|
||||
|
||||
public static kill() {
|
||||
if (this.torrentClient) {
|
||||
this.torrentClient.kill();
|
||||
this.torrentClient = null;
|
||||
if (this.pythonProcess) {
|
||||
this.pythonProcess.kill();
|
||||
this.pythonProcess = null;
|
||||
this.downloadingGameId = -1;
|
||||
}
|
||||
}
|
||||
|
||||
public static killTorrent() {
|
||||
if (this.pythonProcess) {
|
||||
this.rpc.post("/action", { action: "kill-torrent" });
|
||||
this.downloadingGameId = -1;
|
||||
}
|
||||
}
|
||||
|
||||
public static async getProcessList() {
|
||||
return (
|
||||
(await this.rpc.get<ProcessPayload[] | null>("/process-list")).data || []
|
||||
);
|
||||
}
|
||||
|
||||
public static async getStatus() {
|
||||
if (this.downloadingGameId === -1) return null;
|
||||
|
||||
@@ -113,7 +131,7 @@ export class TorrentDownloader {
|
||||
}
|
||||
|
||||
static async startDownload(game: Game) {
|
||||
if (!this.torrentClient) {
|
||||
if (!this.pythonProcess) {
|
||||
this.spawn({
|
||||
game_id: game.id,
|
||||
magnet: game.uri!,
|
||||
@@ -6,10 +6,10 @@ import { DownloadProgress } from "@types";
|
||||
import { HttpDownload } from "./http-download";
|
||||
|
||||
export class RealDebridDownloader {
|
||||
private static downloads = new Map<number, string>();
|
||||
private static downloadingGame: Game | null = null;
|
||||
|
||||
private static realDebridTorrentId: string | null = null;
|
||||
private static httpDownload: HttpDownload | null = null;
|
||||
|
||||
private static async getRealDebridDownloadUrl() {
|
||||
if (this.realDebridTorrentId) {
|
||||
@@ -35,39 +35,47 @@ export class RealDebridDownloader {
|
||||
}
|
||||
|
||||
public static async getStatus() {
|
||||
const lastProgressEvent = this.httpDownload?.lastProgressEvent;
|
||||
if (this.downloadingGame) {
|
||||
const gid = this.downloads.get(this.downloadingGame.id)!;
|
||||
const status = await HttpDownload.getStatus(gid);
|
||||
|
||||
if (lastProgressEvent) {
|
||||
await gameRepository.update(
|
||||
{ id: this.downloadingGame!.id },
|
||||
{
|
||||
bytesDownloaded: lastProgressEvent.loaded,
|
||||
fileSize: lastProgressEvent.total,
|
||||
progress: lastProgressEvent.progress,
|
||||
status: "active",
|
||||
if (status) {
|
||||
const progress =
|
||||
Number(status.completedLength) / Number(status.totalLength);
|
||||
|
||||
await gameRepository.update(
|
||||
{ id: this.downloadingGame!.id },
|
||||
{
|
||||
bytesDownloaded: Number(status.completedLength),
|
||||
fileSize: Number(status.totalLength),
|
||||
progress,
|
||||
status: "active",
|
||||
}
|
||||
);
|
||||
|
||||
const result = {
|
||||
numPeers: 0,
|
||||
numSeeds: 0,
|
||||
downloadSpeed: Number(status.downloadSpeed),
|
||||
timeRemaining: calculateETA(
|
||||
Number(status.totalLength),
|
||||
Number(status.completedLength),
|
||||
Number(status.downloadSpeed)
|
||||
),
|
||||
isDownloadingMetadata: false,
|
||||
isCheckingFiles: false,
|
||||
progress,
|
||||
gameId: this.downloadingGame!.id,
|
||||
} as DownloadProgress;
|
||||
|
||||
if (progress === 1) {
|
||||
this.downloads.delete(this.downloadingGame.id);
|
||||
this.realDebridTorrentId = null;
|
||||
this.downloadingGame = null;
|
||||
}
|
||||
);
|
||||
|
||||
const progress = {
|
||||
numPeers: 0,
|
||||
numSeeds: 0,
|
||||
downloadSpeed: lastProgressEvent.rate,
|
||||
timeRemaining: calculateETA(
|
||||
lastProgressEvent.total ?? 0,
|
||||
lastProgressEvent.loaded,
|
||||
lastProgressEvent.rate ?? 0
|
||||
),
|
||||
isDownloadingMetadata: false,
|
||||
isCheckingFiles: false,
|
||||
progress: lastProgressEvent.progress,
|
||||
gameId: this.downloadingGame!.id,
|
||||
} as DownloadProgress;
|
||||
|
||||
if (lastProgressEvent.progress === 1) {
|
||||
this.pauseDownload();
|
||||
return result;
|
||||
}
|
||||
|
||||
return progress;
|
||||
}
|
||||
|
||||
if (this.realDebridTorrentId && this.downloadingGame) {
|
||||
@@ -101,25 +109,54 @@ export class RealDebridDownloader {
|
||||
}
|
||||
|
||||
static async pauseDownload() {
|
||||
this.httpDownload?.pauseDownload();
|
||||
const gid = this.downloads.get(this.downloadingGame!.id!);
|
||||
if (gid) {
|
||||
await HttpDownload.pauseDownload(gid);
|
||||
}
|
||||
|
||||
this.realDebridTorrentId = null;
|
||||
this.downloadingGame = null;
|
||||
}
|
||||
|
||||
static async startDownload(game: Game) {
|
||||
this.realDebridTorrentId = await RealDebridClient.getTorrentId(game!.uri!);
|
||||
this.downloadingGame = game;
|
||||
|
||||
if (this.downloads.has(game.id)) {
|
||||
await this.resumeDownload(game.id!);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
this.realDebridTorrentId = await RealDebridClient.getTorrentId(game!.uri!);
|
||||
|
||||
const downloadUrl = await this.getRealDebridDownloadUrl();
|
||||
|
||||
if (downloadUrl) {
|
||||
this.realDebridTorrentId = null;
|
||||
this.httpDownload = new HttpDownload(downloadUrl, game!.downloadPath!);
|
||||
this.httpDownload.startDownload();
|
||||
|
||||
const gid = await HttpDownload.startDownload(
|
||||
game.downloadPath!,
|
||||
downloadUrl
|
||||
);
|
||||
|
||||
this.downloads.set(game.id!, gid);
|
||||
}
|
||||
}
|
||||
|
||||
static cancelDownload() {
|
||||
return this.httpDownload?.cancelDownload();
|
||||
static async cancelDownload(gameId: number) {
|
||||
const gid = this.downloads.get(gameId);
|
||||
|
||||
if (gid) {
|
||||
await HttpDownload.cancelDownload(gid);
|
||||
this.downloads.delete(gameId);
|
||||
}
|
||||
}
|
||||
|
||||
static async resumeDownload(gameId: number) {
|
||||
const gid = this.downloads.get(gameId);
|
||||
|
||||
if (gid) {
|
||||
await HttpDownload.resumeDownload(gid);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,12 +15,12 @@ export const BITTORRENT_PORT = "5881";
|
||||
export const RPC_PORT = "8084";
|
||||
export const RPC_PASSWORD = crypto.randomBytes(32).toString("hex");
|
||||
|
||||
export const startTorrentClient = (args: StartDownloadPayload) => {
|
||||
export const startTorrentClient = (args?: StartDownloadPayload) => {
|
||||
const commonArgs = [
|
||||
BITTORRENT_PORT,
|
||||
RPC_PORT,
|
||||
RPC_PASSWORD,
|
||||
encodeURIComponent(JSON.stringify(args)),
|
||||
args ? encodeURIComponent(JSON.stringify(args)) : "",
|
||||
];
|
||||
|
||||
if (app.isPackaged) {
|
||||
|
||||
@@ -31,3 +31,8 @@ export interface LibtorrentPayload {
|
||||
status: LibtorrentStatus;
|
||||
gameId: number;
|
||||
}
|
||||
|
||||
export interface ProcessPayload {
|
||||
exe: string;
|
||||
pid: number;
|
||||
}
|
||||
|
||||
@@ -5,11 +5,12 @@ import url from "url";
|
||||
import { uploadGamesBatch } from "./library-sync";
|
||||
import { clearGamesRemoteIds } from "./library-sync/clear-games-remote-id";
|
||||
import { logger } from "./logger";
|
||||
import { UserNotLoggedInError } from "@shared";
|
||||
|
||||
export class HydraApi {
|
||||
private static instance: AxiosInstance;
|
||||
|
||||
private static readonly EXPIRATION_OFFSET_IN_MS = 1000 * 60 * 5;
|
||||
private static readonly EXPIRATION_OFFSET_IN_MS = 1000 * 60 * 5; // 5 minutes
|
||||
|
||||
private static secondsToMilliseconds = (seconds: number) => seconds * 1000;
|
||||
|
||||
@@ -19,7 +20,7 @@ export class HydraApi {
|
||||
expirationTimestamp: 0,
|
||||
};
|
||||
|
||||
static isLoggedIn() {
|
||||
private static isLoggedIn() {
|
||||
return this.userAuth.authToken !== "";
|
||||
}
|
||||
|
||||
@@ -44,6 +45,8 @@ export class HydraApi {
|
||||
expirationTimestamp: tokenExpirationTimestamp,
|
||||
};
|
||||
|
||||
logger.log("Sign in received", this.userAuth);
|
||||
|
||||
await userAuthRepository.upsert(
|
||||
{
|
||||
id: 1,
|
||||
@@ -73,7 +76,7 @@ export class HydraApi {
|
||||
return request;
|
||||
},
|
||||
(error) => {
|
||||
logger.log("request error", error);
|
||||
logger.error("request error", error);
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
@@ -94,12 +97,18 @@ export class HydraApi {
|
||||
|
||||
const { config } = error;
|
||||
|
||||
logger.error(config.method, config.baseURL, config.url, config.headers);
|
||||
logger.error(
|
||||
config.method,
|
||||
config.baseURL,
|
||||
config.url,
|
||||
config.headers,
|
||||
config.data
|
||||
);
|
||||
|
||||
if (error.response) {
|
||||
logger.error(error.response.status, error.response.data);
|
||||
logger.error("Response", error.response.status, error.response.data);
|
||||
} else if (error.request) {
|
||||
logger.error(error.request);
|
||||
logger.error("Request", error.request);
|
||||
} else {
|
||||
logger.error("Error", error.message);
|
||||
}
|
||||
@@ -127,14 +136,8 @@ export class HydraApi {
|
||||
}
|
||||
|
||||
private static async revalidateAccessTokenIfExpired() {
|
||||
if (!this.userAuth.authToken) {
|
||||
userAuthRepository.delete({ id: 1 });
|
||||
logger.error("user is not logged in");
|
||||
this.sendSignOutEvent();
|
||||
throw new Error("user is not logged in");
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
|
||||
if (this.userAuth.expirationTimestamp < now.getTime()) {
|
||||
try {
|
||||
const response = await this.instance.post(`/auth/refresh`, {
|
||||
@@ -151,6 +154,8 @@ export class HydraApi {
|
||||
this.userAuth.authToken = accessToken;
|
||||
this.userAuth.expirationTimestamp = tokenExpirationTimestamp;
|
||||
|
||||
logger.log("Token refreshed", this.userAuth);
|
||||
|
||||
userAuthRepository.upsert(
|
||||
{
|
||||
id: 1,
|
||||
@@ -175,6 +180,8 @@ export class HydraApi {
|
||||
|
||||
private static handleUnauthorizedError = (err) => {
|
||||
if (err instanceof AxiosError && err.response?.status === 401) {
|
||||
logger.error("401 - Current credentials:", this.userAuth);
|
||||
|
||||
this.userAuth = {
|
||||
authToken: "",
|
||||
expirationTimestamp: 0,
|
||||
@@ -190,37 +197,52 @@ export class HydraApi {
|
||||
};
|
||||
|
||||
static async get(url: string) {
|
||||
if (!this.isLoggedIn()) throw new UserNotLoggedInError();
|
||||
|
||||
await this.revalidateAccessTokenIfExpired();
|
||||
return this.instance
|
||||
.get(url, this.getAxiosConfig())
|
||||
.then((response) => response.data)
|
||||
.catch(this.handleUnauthorizedError);
|
||||
}
|
||||
|
||||
static async post(url: string, data?: any) {
|
||||
if (!this.isLoggedIn()) throw new UserNotLoggedInError();
|
||||
|
||||
await this.revalidateAccessTokenIfExpired();
|
||||
return this.instance
|
||||
.post(url, data, this.getAxiosConfig())
|
||||
.then((response) => response.data)
|
||||
.catch(this.handleUnauthorizedError);
|
||||
}
|
||||
|
||||
static async put(url: string, data?: any) {
|
||||
if (!this.isLoggedIn()) throw new UserNotLoggedInError();
|
||||
|
||||
await this.revalidateAccessTokenIfExpired();
|
||||
return this.instance
|
||||
.put(url, data, this.getAxiosConfig())
|
||||
.then((response) => response.data)
|
||||
.catch(this.handleUnauthorizedError);
|
||||
}
|
||||
|
||||
static async patch(url: string, data?: any) {
|
||||
if (!this.isLoggedIn()) throw new UserNotLoggedInError();
|
||||
|
||||
await this.revalidateAccessTokenIfExpired();
|
||||
return this.instance
|
||||
.patch(url, data, this.getAxiosConfig())
|
||||
.then((response) => response.data)
|
||||
.catch(this.handleUnauthorizedError);
|
||||
}
|
||||
|
||||
static async delete(url: string) {
|
||||
if (!this.isLoggedIn()) throw new UserNotLoggedInError();
|
||||
|
||||
await this.revalidateAccessTokenIfExpired();
|
||||
return this.instance
|
||||
.delete(url, this.getAxiosConfig())
|
||||
.then((response) => response.data)
|
||||
.catch(this.handleUnauthorizedError);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,21 @@
|
||||
import { Game } from "@main/entity";
|
||||
import { HydraApi } from "../hydra-api";
|
||||
import { gameRepository } from "@main/repository";
|
||||
|
||||
export const createGame = async (game: Game) => {
|
||||
return HydraApi.post(`/games`, {
|
||||
HydraApi.post(`/games`, {
|
||||
objectId: game.objectID,
|
||||
playTimeInMilliseconds: Math.trunc(game.playTimeInMilliseconds),
|
||||
shop: game.shop,
|
||||
lastTimePlayed: game.lastTimePlayed,
|
||||
});
|
||||
})
|
||||
.then((response) => {
|
||||
const { id: remoteId, playTimeInMilliseconds, lastTimePlayed } = response;
|
||||
|
||||
gameRepository.update(
|
||||
{ objectID: game.objectID },
|
||||
{ remoteId, playTimeInMilliseconds, lastTimePlayed }
|
||||
);
|
||||
})
|
||||
.catch(() => {});
|
||||
};
|
||||
|
||||
@@ -2,71 +2,63 @@ import { gameRepository } from "@main/repository";
|
||||
import { HydraApi } from "../hydra-api";
|
||||
import { steamGamesWorker } from "@main/workers";
|
||||
import { getSteamAppAsset } from "@main/helpers";
|
||||
import { logger } from "../logger";
|
||||
import { AxiosError } from "axios";
|
||||
|
||||
export const mergeWithRemoteGames = async () => {
|
||||
try {
|
||||
const games = await HydraApi.get("/games");
|
||||
|
||||
for (const game of games.data) {
|
||||
const localGame = await gameRepository.findOne({
|
||||
where: {
|
||||
objectID: game.objectId,
|
||||
},
|
||||
});
|
||||
|
||||
if (localGame) {
|
||||
const updatedLastTimePlayed =
|
||||
localGame.lastTimePlayed == null ||
|
||||
(game.lastTimePlayed &&
|
||||
new Date(game.lastTimePlayed) > localGame.lastTimePlayed)
|
||||
? game.lastTimePlayed
|
||||
: localGame.lastTimePlayed;
|
||||
|
||||
const updatedPlayTime =
|
||||
localGame.playTimeInMilliseconds < game.playTimeInMilliseconds
|
||||
? game.playTimeInMilliseconds
|
||||
: localGame.playTimeInMilliseconds;
|
||||
|
||||
gameRepository.update(
|
||||
{
|
||||
return HydraApi.get("/games")
|
||||
.then(async (response) => {
|
||||
for (const game of response) {
|
||||
const localGame = await gameRepository.findOne({
|
||||
where: {
|
||||
objectID: game.objectId,
|
||||
shop: "steam",
|
||||
},
|
||||
{
|
||||
remoteId: game.id,
|
||||
lastTimePlayed: updatedLastTimePlayed,
|
||||
playTimeInMilliseconds: updatedPlayTime,
|
||||
}
|
||||
);
|
||||
} else {
|
||||
const steamGame = await steamGamesWorker.run(Number(game.objectId), {
|
||||
name: "getById",
|
||||
});
|
||||
|
||||
if (steamGame) {
|
||||
const iconUrl = steamGame?.clientIcon
|
||||
? getSteamAppAsset("icon", game.objectId, steamGame.clientIcon)
|
||||
: null;
|
||||
if (localGame) {
|
||||
const updatedLastTimePlayed =
|
||||
localGame.lastTimePlayed == null ||
|
||||
(game.lastTimePlayed &&
|
||||
new Date(game.lastTimePlayed) > localGame.lastTimePlayed)
|
||||
? game.lastTimePlayed
|
||||
: localGame.lastTimePlayed;
|
||||
|
||||
gameRepository.insert({
|
||||
objectID: game.objectId,
|
||||
title: steamGame?.name,
|
||||
remoteId: game.id,
|
||||
shop: game.shop,
|
||||
iconUrl,
|
||||
lastTimePlayed: game.lastTimePlayed,
|
||||
playTimeInMilliseconds: game.playTimeInMilliseconds,
|
||||
const updatedPlayTime =
|
||||
localGame.playTimeInMilliseconds < game.playTimeInMilliseconds
|
||||
? game.playTimeInMilliseconds
|
||||
: localGame.playTimeInMilliseconds;
|
||||
|
||||
gameRepository.update(
|
||||
{
|
||||
objectID: game.objectId,
|
||||
shop: "steam",
|
||||
},
|
||||
{
|
||||
remoteId: game.id,
|
||||
lastTimePlayed: updatedLastTimePlayed,
|
||||
playTimeInMilliseconds: updatedPlayTime,
|
||||
}
|
||||
);
|
||||
} else {
|
||||
const steamGame = await steamGamesWorker.run(Number(game.objectId), {
|
||||
name: "getById",
|
||||
});
|
||||
|
||||
if (steamGame) {
|
||||
const iconUrl = steamGame?.clientIcon
|
||||
? getSteamAppAsset("icon", game.objectId, steamGame.clientIcon)
|
||||
: null;
|
||||
|
||||
gameRepository.insert({
|
||||
objectID: game.objectId,
|
||||
title: steamGame?.name,
|
||||
remoteId: game.id,
|
||||
shop: game.shop,
|
||||
iconUrl,
|
||||
lastTimePlayed: game.lastTimePlayed,
|
||||
playTimeInMilliseconds: game.playTimeInMilliseconds,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
if (err instanceof AxiosError) {
|
||||
logger.error("getRemoteGames", err.message);
|
||||
} else {
|
||||
logger.error("getRemoteGames", err);
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
};
|
||||
|
||||
@@ -6,8 +6,8 @@ export const updateGamePlaytime = async (
|
||||
deltaInMillis: number,
|
||||
lastTimePlayed: Date
|
||||
) => {
|
||||
return HydraApi.put(`/games/${game.remoteId}`, {
|
||||
HydraApi.put(`/games/${game.remoteId}`, {
|
||||
playTimeDeltaInSeconds: Math.trunc(deltaInMillis / 1000),
|
||||
lastTimePlayed,
|
||||
});
|
||||
}).catch(() => {});
|
||||
};
|
||||
|
||||
@@ -2,43 +2,32 @@ import { gameRepository } from "@main/repository";
|
||||
import { chunk } from "lodash-es";
|
||||
import { IsNull } from "typeorm";
|
||||
import { HydraApi } from "../hydra-api";
|
||||
import { logger } from "../logger";
|
||||
import { AxiosError } from "axios";
|
||||
|
||||
import { mergeWithRemoteGames } from "./merge-with-remote-games";
|
||||
import { WindowManager } from "../window-manager";
|
||||
|
||||
export const uploadGamesBatch = async () => {
|
||||
try {
|
||||
const games = await gameRepository.find({
|
||||
where: { remoteId: IsNull(), isDeleted: false },
|
||||
});
|
||||
const games = await gameRepository.find({
|
||||
where: { remoteId: IsNull(), isDeleted: false },
|
||||
});
|
||||
|
||||
const gamesChunks = chunk(games, 200);
|
||||
const gamesChunks = chunk(games, 200);
|
||||
|
||||
for (const chunk of gamesChunks) {
|
||||
await HydraApi.post(
|
||||
"/games/batch",
|
||||
chunk.map((game) => {
|
||||
return {
|
||||
objectId: game.objectID,
|
||||
playTimeInMilliseconds: Math.trunc(game.playTimeInMilliseconds),
|
||||
shop: game.shop,
|
||||
lastTimePlayed: game.lastTimePlayed,
|
||||
};
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
await mergeWithRemoteGames();
|
||||
|
||||
if (WindowManager.mainWindow)
|
||||
WindowManager.mainWindow.webContents.send("on-library-batch-complete");
|
||||
} catch (err) {
|
||||
if (err instanceof AxiosError) {
|
||||
logger.error("uploadGamesBatch", err.response, err.message);
|
||||
} else {
|
||||
logger.error("uploadGamesBatch", err);
|
||||
}
|
||||
for (const chunk of gamesChunks) {
|
||||
await HydraApi.post(
|
||||
"/games/batch",
|
||||
chunk.map((game) => {
|
||||
return {
|
||||
objectId: game.objectID,
|
||||
playTimeInMilliseconds: Math.trunc(game.playTimeInMilliseconds),
|
||||
shop: game.shop,
|
||||
lastTimePlayed: game.lastTimePlayed,
|
||||
};
|
||||
})
|
||||
).catch(() => {});
|
||||
}
|
||||
|
||||
await mergeWithRemoteGames();
|
||||
|
||||
if (WindowManager.mainWindow)
|
||||
WindowManager.mainWindow.webContents.send("on-library-batch-complete");
|
||||
};
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Notification, nativeImage } from "electron";
|
||||
import { t } from "i18next";
|
||||
import { parseICO } from "icojs";
|
||||
|
||||
import trayIcon from "@resources/tray-icon.png?asset";
|
||||
import { Game } from "@main/entity";
|
||||
import { gameRepository, userPreferencesRepository } from "@main/repository";
|
||||
|
||||
@@ -39,11 +39,9 @@ export const publishDownloadCompleteNotification = async (game: Game) => {
|
||||
new Notification({
|
||||
title: t("download_complete", {
|
||||
ns: "notifications",
|
||||
lng: userPreferences.language,
|
||||
}),
|
||||
body: t("game_ready_to_install", {
|
||||
ns: "notifications",
|
||||
lng: userPreferences.language,
|
||||
title: game.title,
|
||||
}),
|
||||
icon,
|
||||
@@ -60,13 +58,26 @@ export const publishNewRepacksNotifications = async (count: number) => {
|
||||
new Notification({
|
||||
title: t("repack_list_updated", {
|
||||
ns: "notifications",
|
||||
lng: userPreferences?.language || "en",
|
||||
}),
|
||||
body: t("repack_count", {
|
||||
ns: "notifications",
|
||||
lng: userPreferences?.language || "en",
|
||||
count: count,
|
||||
}),
|
||||
}).show();
|
||||
}
|
||||
};
|
||||
|
||||
export const publishNotificationUpdateReadyToInstall = async (
|
||||
version: string
|
||||
) => {
|
||||
new Notification({
|
||||
title: t("new_update_available", {
|
||||
ns: "notifications",
|
||||
version,
|
||||
}),
|
||||
body: t("restart_to_install_update", {
|
||||
ns: "notifications",
|
||||
}),
|
||||
icon: trayIcon,
|
||||
}).show();
|
||||
};
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
import path from "node:path";
|
||||
|
||||
import { IsNull, Not } from "typeorm";
|
||||
import { gameRepository } from "@main/repository";
|
||||
import { getProcesses } from "@main/helpers";
|
||||
import { WindowManager } from "./window-manager";
|
||||
import { createGame, updateGamePlaytime } from "./library-sync";
|
||||
import { GameRunning } from "@types";
|
||||
import { PythonInstance } from "./download";
|
||||
|
||||
export const gamesPlaytime = new Map<
|
||||
number,
|
||||
@@ -21,23 +19,13 @@ export const watchProcesses = async () => {
|
||||
});
|
||||
|
||||
if (games.length === 0) return;
|
||||
|
||||
const processes = await getProcesses();
|
||||
const processes = await PythonInstance.getProcessList();
|
||||
|
||||
for (const game of games) {
|
||||
const executablePath = game.executablePath!;
|
||||
const basename = path.win32.basename(executablePath);
|
||||
const basenameWithoutExtension = path.win32.basename(
|
||||
executablePath,
|
||||
path.extname(executablePath)
|
||||
);
|
||||
|
||||
const gameProcess = processes.find((runningProcess) => {
|
||||
if (process.platform === "win32") {
|
||||
return runningProcess.name === basename;
|
||||
}
|
||||
|
||||
return [basename, basenameWithoutExtension].includes(runningProcess.name);
|
||||
return executablePath == runningProcess.exe;
|
||||
});
|
||||
|
||||
if (gameProcess) {
|
||||
@@ -60,12 +48,7 @@ export const watchProcesses = async () => {
|
||||
if (game.remoteId) {
|
||||
updateGamePlaytime(game, 0, new Date());
|
||||
} else {
|
||||
createGame({ ...game, lastTimePlayed: new Date() }).then(
|
||||
(response) => {
|
||||
const { id: remoteId } = response.data;
|
||||
gameRepository.update({ objectID: game.objectID }, { remoteId });
|
||||
}
|
||||
);
|
||||
createGame({ ...game, lastTimePlayed: new Date() });
|
||||
}
|
||||
|
||||
gamesPlaytime.set(game.id, {
|
||||
@@ -84,10 +67,7 @@ export const watchProcesses = async () => {
|
||||
game.lastTimePlayed!
|
||||
);
|
||||
} else {
|
||||
createGame(game).then((response) => {
|
||||
const { id: remoteId } = response.data;
|
||||
gameRepository.update({ objectID: game.objectID }, { remoteId });
|
||||
});
|
||||
createGame(game);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { downloadSourceSchema } from "@main/events/helpers/validators";
|
||||
import { DownloadSourceStatus } from "@shared";
|
||||
import type { DownloadSource } from "@types";
|
||||
import type { DownloadSource, GameRepack } from "@types";
|
||||
import axios, { AxiosError, AxiosHeaders } from "axios";
|
||||
import { z } from "zod";
|
||||
|
||||
@@ -48,3 +48,24 @@ export const getUpdatedRepacks = async (downloadSources: DownloadSource[]) => {
|
||||
|
||||
return results;
|
||||
};
|
||||
|
||||
export const validateDownloadSource = async ({
|
||||
url,
|
||||
repacks,
|
||||
}: {
|
||||
url: string;
|
||||
repacks: GameRepack[];
|
||||
}) => {
|
||||
const response = await axios.get(url);
|
||||
|
||||
const source = downloadSourceSchema.parse(response.data);
|
||||
|
||||
const existingUris = source.downloads
|
||||
.flatMap((download) => download.uris)
|
||||
.filter((uri) => repacks.some((repack) => repack.magnet === uri));
|
||||
|
||||
return {
|
||||
name: source.name,
|
||||
downloadCount: source.downloads.length - existingUris.length,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -9,6 +9,9 @@ import type {
|
||||
AppUpdaterEvent,
|
||||
StartGameDownloadPayload,
|
||||
GameRunning,
|
||||
Collection,
|
||||
Game,
|
||||
FriendRequestAction,
|
||||
} from "@types";
|
||||
|
||||
contextBridge.exposeInMainWorld("electron", {
|
||||
@@ -102,6 +105,16 @@ contextBridge.exposeInMainWorld("electron", {
|
||||
ipcRenderer.removeListener("on-library-batch-complete", listener);
|
||||
},
|
||||
|
||||
/* Collections */
|
||||
addCollection: (title: string) => ipcRenderer.invoke("addCollection", title),
|
||||
addCollectionGame: (id: number, game: Game) =>
|
||||
ipcRenderer.invoke("addCollectionGame", id, game),
|
||||
getCollections: () => ipcRenderer.invoke("getCollections"),
|
||||
removeCollection: (collection: Collection) =>
|
||||
ipcRenderer.invoke("removeCollection", collection),
|
||||
removeCollectionGame: (id: number, game: Game) =>
|
||||
ipcRenderer.invoke("removeCollectionGame", id, game),
|
||||
|
||||
/* Hardware */
|
||||
getDiskFreeSpace: (path: string) =>
|
||||
ipcRenderer.invoke("getDiskFreeSpace", path),
|
||||
@@ -112,7 +125,6 @@ contextBridge.exposeInMainWorld("electron", {
|
||||
getDefaultDownloadsPath: () => ipcRenderer.invoke("getDefaultDownloadsPath"),
|
||||
isPortableVersion: () => ipcRenderer.invoke("isPortableVersion"),
|
||||
openExternal: (src: string) => ipcRenderer.invoke("openExternal", src),
|
||||
isUserLoggedIn: () => ipcRenderer.invoke("isUserLoggedIn"),
|
||||
showOpenDialog: (options: Electron.OpenDialogOptions) =>
|
||||
ipcRenderer.invoke("showOpenDialog", options),
|
||||
platform: process.platform,
|
||||
@@ -137,6 +149,11 @@ contextBridge.exposeInMainWorld("electron", {
|
||||
getMe: () => ipcRenderer.invoke("getMe"),
|
||||
updateProfile: (displayName: string, newProfileImagePath: string | null) =>
|
||||
ipcRenderer.invoke("updateProfile", displayName, newProfileImagePath),
|
||||
getFriendRequests: () => ipcRenderer.invoke("getFriendRequests"),
|
||||
updateFriendRequest: (userId: string, action: FriendRequestAction) =>
|
||||
ipcRenderer.invoke("updateFriendRequest", userId, action),
|
||||
sendFriendRequest: (userId: string) =>
|
||||
ipcRenderer.invoke("sendFriendRequest", userId),
|
||||
|
||||
/* User */
|
||||
getUser: (userId: string) => ipcRenderer.invoke("getUser", userId),
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
<title>Hydra</title>
|
||||
<meta
|
||||
http-equiv="Content-Security-Policy"
|
||||
content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: local: https://*.cloudfront.net https://*.s3.amazonaws.com https://steamcdn-a.akamaihd.net https://shared.akamai.steamstatic.com https://cdn.cloudflare.steamstatic.com https://cdn2.steamgriddb.com https://cdn.akamai.steamstatic.com; media-src 'self' local: data: https://steamcdn-a.akamaihd.net https://cdn.cloudflare.steamstatic.com https://cdn2.steamgriddb.com https://cdn.akamai.steamstatic.com https://shared.akamai.steamstatic.com;"
|
||||
content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: local: https://cdn.discordapp.com https://*.cloudfront.net https://*.s3.amazonaws.com https://steamcdn-a.akamaihd.net https://shared.akamai.steamstatic.com https://cdn.cloudflare.steamstatic.com https://cdn2.steamgriddb.com https://cdn.akamai.steamstatic.com; media-src 'self' local: data: https://steamcdn-a.akamaihd.net https://cdn.cloudflare.steamstatic.com https://cdn2.steamgriddb.com https://cdn.akamai.steamstatic.com https://shared.akamai.steamstatic.com;"
|
||||
/>
|
||||
</head>
|
||||
<body style="background-color: #1c1c1c">
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
setGameRunning,
|
||||
} from "@renderer/features";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { UserFriendModal } from "./pages/shared-modals/user-friend-modal";
|
||||
|
||||
export interface AppProps {
|
||||
children: React.ReactNode;
|
||||
@@ -38,6 +39,13 @@ export function App() {
|
||||
|
||||
const { clearDownload, setLastPacket } = useDownload();
|
||||
|
||||
const {
|
||||
isFriendsModalVisible,
|
||||
friendRequetsModalTab,
|
||||
updateFriendRequests,
|
||||
hideFriendsModal,
|
||||
} = useUserDetails();
|
||||
|
||||
const { fetchUserDetails, updateUserDetails, clearUserDetails } =
|
||||
useUserDetails();
|
||||
|
||||
@@ -93,11 +101,10 @@ export function App() {
|
||||
dispatch(setProfileBackground(profileBackground));
|
||||
}
|
||||
|
||||
window.electron.isUserLoggedIn().then((isLoggedIn) => {
|
||||
if (isLoggedIn) {
|
||||
fetchUserDetails().then((response) => {
|
||||
if (response) updateUserDetails(response);
|
||||
});
|
||||
fetchUserDetails().then((response) => {
|
||||
if (response) {
|
||||
updateUserDetails(response);
|
||||
updateFriendRequests();
|
||||
}
|
||||
});
|
||||
}, [fetchUserDetails, updateUserDetails, dispatch]);
|
||||
@@ -106,6 +113,7 @@ export function App() {
|
||||
fetchUserDetails().then((response) => {
|
||||
if (response) {
|
||||
updateUserDetails(response);
|
||||
updateFriendRequests();
|
||||
showSuccessToast(t("successfully_signed_in"));
|
||||
}
|
||||
});
|
||||
@@ -210,6 +218,12 @@ export function App() {
|
||||
onClose={handleToastClose}
|
||||
/>
|
||||
|
||||
<UserFriendModal
|
||||
visible={isFriendsModalVisible}
|
||||
initialTab={friendRequetsModalTab}
|
||||
onClose={hideFriendsModal}
|
||||
/>
|
||||
|
||||
<main>
|
||||
<Sidebar />
|
||||
|
||||
|
||||
@@ -47,10 +47,8 @@ export function AutoUpdateSubHeader() {
|
||||
return (
|
||||
<header className={styles.subheader}>
|
||||
<Link to={releasesPageUrl} className={styles.newVersionLink}>
|
||||
<SyncIcon size={12} />
|
||||
<small>
|
||||
{t("version_available_download", { version: newVersion })}
|
||||
</small>
|
||||
<SyncIcon className={styles.newVersionIcon} size={12} />
|
||||
{t("version_available_download", { version: newVersion })}
|
||||
</Link>
|
||||
</header>
|
||||
);
|
||||
@@ -64,10 +62,8 @@ export function AutoUpdateSubHeader() {
|
||||
className={styles.newVersionButton}
|
||||
onClick={handleClickInstallUpdate}
|
||||
>
|
||||
<SyncIcon size={12} />
|
||||
<small>
|
||||
{t("version_available_install", { version: newVersion })}
|
||||
</small>
|
||||
<SyncIcon className={styles.newVersionIcon} size={12} />
|
||||
{t("version_available_install", { version: newVersion })}
|
||||
</button>
|
||||
</header>
|
||||
);
|
||||
|
||||
@@ -157,7 +157,7 @@ export const newVersionButton = style({
|
||||
justifyContent: "center",
|
||||
gap: `${SPACING_UNIT}px`,
|
||||
color: vars.color.body,
|
||||
fontSize: "13px",
|
||||
fontSize: "12px",
|
||||
":hover": {
|
||||
textDecoration: "underline",
|
||||
cursor: "pointer",
|
||||
@@ -169,5 +169,9 @@ export const newVersionLink = style({
|
||||
alignItems: "center",
|
||||
gap: `${SPACING_UNIT}px`,
|
||||
color: "#8e919b",
|
||||
fontSize: "13px",
|
||||
fontSize: "12px",
|
||||
});
|
||||
|
||||
export const newVersionIcon = style({
|
||||
color: vars.color.success,
|
||||
});
|
||||
|
||||
@@ -1,7 +1,18 @@
|
||||
import { style } from "@vanilla-extract/css";
|
||||
import { createVar, style } from "@vanilla-extract/css";
|
||||
|
||||
import { SPACING_UNIT, vars } from "../../theme.css";
|
||||
|
||||
export const profileContainerBackground = createVar();
|
||||
|
||||
export const profileContainer = style({
|
||||
background: profileContainerBackground,
|
||||
position: "relative",
|
||||
cursor: "pointer",
|
||||
":hover": {
|
||||
backgroundColor: "rgba(255, 255, 255, 0.15)",
|
||||
},
|
||||
});
|
||||
|
||||
export const profileButton = style({
|
||||
display: "flex",
|
||||
cursor: "pointer",
|
||||
@@ -10,9 +21,8 @@ export const profileButton = style({
|
||||
color: vars.color.muted,
|
||||
borderBottom: `solid 1px ${vars.color.border}`,
|
||||
boxShadow: "0px 0px 15px 0px rgb(0 0 0 / 70%)",
|
||||
":hover": {
|
||||
backgroundColor: "rgba(255, 255, 255, 0.15)",
|
||||
},
|
||||
width: "100%",
|
||||
zIndex: "10",
|
||||
});
|
||||
|
||||
export const profileButtonContent = style({
|
||||
@@ -64,3 +74,25 @@ export const profileButtonTitle = style({
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
});
|
||||
|
||||
export const friendRequestContainer = style({
|
||||
position: "absolute",
|
||||
padding: "8px",
|
||||
right: `${SPACING_UNIT}px`,
|
||||
display: "flex",
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
alignItems: "center",
|
||||
});
|
||||
|
||||
export const friendRequestButton = style({
|
||||
color: vars.color.success,
|
||||
cursor: "pointer",
|
||||
borderRadius: "50%",
|
||||
overflow: "hidden",
|
||||
width: "40px",
|
||||
height: "40px",
|
||||
":hover": {
|
||||
color: vars.color.muted,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,17 +1,20 @@
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { PersonIcon } from "@primer/octicons-react";
|
||||
import { PersonAddIcon, PersonIcon } from "@primer/octicons-react";
|
||||
import * as styles from "./sidebar-profile.css";
|
||||
|
||||
import { assignInlineVars } from "@vanilla-extract/dynamic";
|
||||
import { useAppSelector, useUserDetails } from "@renderer/hooks";
|
||||
import { useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { profileContainerBackground } from "./sidebar-profile.css";
|
||||
import { UserFriendModalTab } from "@renderer/pages/shared-modals/user-friend-modal";
|
||||
|
||||
export function SidebarProfile() {
|
||||
const navigate = useNavigate();
|
||||
|
||||
const { t } = useTranslation("sidebar");
|
||||
|
||||
const { userDetails, profileBackground } = useUserDetails();
|
||||
const { userDetails, profileBackground, friendRequests, showFriendsModal } =
|
||||
useUserDetails();
|
||||
|
||||
const { gameRunning } = useAppSelector((state) => state.gameRunning);
|
||||
|
||||
@@ -30,46 +33,64 @@ export function SidebarProfile() {
|
||||
}, [profileBackground]);
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={styles.profileButton}
|
||||
style={{ background: profileButtonBackground }}
|
||||
onClick={handleButtonClick}
|
||||
<div
|
||||
className={styles.profileContainer}
|
||||
style={assignInlineVars({
|
||||
[profileContainerBackground]: profileButtonBackground,
|
||||
})}
|
||||
>
|
||||
<div className={styles.profileButtonContent}>
|
||||
<div className={styles.profileAvatar}>
|
||||
{userDetails?.profileImageUrl ? (
|
||||
<img
|
||||
className={styles.profileAvatar}
|
||||
src={userDetails.profileImageUrl}
|
||||
alt={userDetails.displayName}
|
||||
/>
|
||||
) : (
|
||||
<PersonIcon />
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.profileButton}
|
||||
onClick={handleButtonClick}
|
||||
>
|
||||
<div className={styles.profileButtonContent}>
|
||||
<div className={styles.profileAvatar}>
|
||||
{userDetails?.profileImageUrl ? (
|
||||
<img
|
||||
className={styles.profileAvatar}
|
||||
src={userDetails.profileImageUrl}
|
||||
alt={userDetails.displayName}
|
||||
/>
|
||||
) : (
|
||||
<PersonIcon size={24} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className={styles.profileButtonInformation}>
|
||||
<p className={styles.profileButtonTitle}>
|
||||
{userDetails ? userDetails.displayName : t("sign_in")}
|
||||
</p>
|
||||
<div className={styles.profileButtonInformation}>
|
||||
<p className={styles.profileButtonTitle}>
|
||||
{userDetails ? userDetails.displayName : t("sign_in")}
|
||||
</p>
|
||||
|
||||
{userDetails && gameRunning && (
|
||||
<div>
|
||||
<small>{gameRunning.title}</small>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{userDetails && gameRunning && (
|
||||
<div>
|
||||
<small>{gameRunning.title}</small>
|
||||
</div>
|
||||
<img
|
||||
alt={gameRunning.title}
|
||||
width={24}
|
||||
style={{ borderRadius: 4 }}
|
||||
src={gameRunning.iconUrl}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{userDetails && gameRunning && (
|
||||
<img
|
||||
alt={gameRunning.title}
|
||||
width={24}
|
||||
style={{ borderRadius: 4 }}
|
||||
src={gameRunning.iconUrl}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
</button>
|
||||
{userDetails && friendRequests.length > 0 && !gameRunning && (
|
||||
<div className={styles.friendRequestContainer}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.friendRequestButton}
|
||||
onClick={() => showFriendsModal(UserFriendModalTab.AddFriend)}
|
||||
>
|
||||
<PersonAddIcon size={24} />
|
||||
{friendRequests.length}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ import { buildGameDetailsPath } from "@renderer/helpers";
|
||||
import SteamLogo from "@renderer/assets/steam-logo.svg?react";
|
||||
import { SidebarProfile } from "./sidebar-profile";
|
||||
import { sortBy } from "lodash-es";
|
||||
import { useCollections } from "@renderer/hooks/use-collections";
|
||||
|
||||
const SIDEBAR_MIN_WIDTH = 200;
|
||||
const SIDEBAR_INITIAL_WIDTH = 250;
|
||||
@@ -25,6 +26,7 @@ const initialSidebarWidth = window.localStorage.getItem("sidebarWidth");
|
||||
export function Sidebar() {
|
||||
const { t } = useTranslation("sidebar");
|
||||
const { library, updateLibrary } = useLibrary();
|
||||
const { collections, updateCollections } = useCollections();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [filteredLibrary, setFilteredLibrary] = useState<LibraryGame[]>([]);
|
||||
@@ -33,6 +35,7 @@ export function Sidebar() {
|
||||
const [sidebarWidth, setSidebarWidth] = useState(
|
||||
initialSidebarWidth ? Number(initialSidebarWidth) : SIDEBAR_INITIAL_WIDTH
|
||||
);
|
||||
const [showCollections, setShowCollections] = useState(true);
|
||||
|
||||
const location = useLocation();
|
||||
|
||||
@@ -46,7 +49,8 @@ export function Sidebar() {
|
||||
|
||||
useEffect(() => {
|
||||
updateLibrary();
|
||||
}, [lastPacket?.game.id, updateLibrary]);
|
||||
updateCollections();
|
||||
}, [lastPacket?.game.id, updateLibrary, updateCollections]);
|
||||
|
||||
const isDownloading = sortedLibrary.some(
|
||||
(game) => game.status === "active" && game.progress !== 1
|
||||
@@ -67,18 +71,27 @@ export function Sidebar() {
|
||||
};
|
||||
|
||||
const handleFilter: React.ChangeEventHandler<HTMLInputElement> = (event) => {
|
||||
const val = event.target.value.toLocaleLowerCase();
|
||||
|
||||
setFilteredLibrary(
|
||||
sortedLibrary.filter((game) =>
|
||||
game.title
|
||||
.toLowerCase()
|
||||
.includes(event.target.value.toLocaleLowerCase())
|
||||
)
|
||||
sortedLibrary.filter((game) => game.title.toLowerCase().includes(val))
|
||||
);
|
||||
|
||||
setShowCollections(val == "");
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
setFilteredLibrary(sortedLibrary);
|
||||
}, [sortedLibrary]);
|
||||
setFilteredLibrary(
|
||||
sortedLibrary.filter(
|
||||
(game) =>
|
||||
!collections.some((collection) =>
|
||||
collection.games.some(
|
||||
(collectionGame) => collectionGame.id == game.id
|
||||
)
|
||||
)
|
||||
)
|
||||
);
|
||||
}, [sortedLibrary, collections]);
|
||||
|
||||
useEffect(() => {
|
||||
window.onmousemove = (event: MouseEvent) => {
|
||||
@@ -199,6 +212,58 @@ export function Sidebar() {
|
||||
theme="dark"
|
||||
/>
|
||||
|
||||
{collections.map((collection) =>
|
||||
collection.games?.length && showCollections ? (
|
||||
<section className={styles.section} key={collection.id}>
|
||||
<small className={styles.sectionTitle}>
|
||||
{collection.title}
|
||||
</small>
|
||||
|
||||
<ul className={styles.menu}>
|
||||
{sortedLibrary
|
||||
.filter((game) =>
|
||||
collection.games.some(
|
||||
(collectionGame) => game.id == collectionGame.id
|
||||
)
|
||||
)
|
||||
.map((game) => (
|
||||
<li
|
||||
key={game.id}
|
||||
className={styles.menuItem({
|
||||
active:
|
||||
location.pathname ===
|
||||
`/game/${game.shop}/${game.objectID}`,
|
||||
muted: game.status === "removed",
|
||||
})}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.menuItemButton}
|
||||
onClick={(event) =>
|
||||
handleSidebarGameClick(event, game)
|
||||
}
|
||||
>
|
||||
{game.iconUrl ? (
|
||||
<img
|
||||
className={styles.gameIcon}
|
||||
src={game.iconUrl}
|
||||
alt={game.title}
|
||||
/>
|
||||
) : (
|
||||
<SteamLogo className={styles.gameIcon} />
|
||||
)}
|
||||
|
||||
<span className={styles.menuItemButtonLabel}>
|
||||
{getGameTitle(game)}
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
) : null
|
||||
)}
|
||||
|
||||
<ul className={styles.menu}>
|
||||
{filteredLibrary.map((game) => (
|
||||
<li
|
||||
|
||||
17
src/renderer/src/declaration.d.ts
vendored
17
src/renderer/src/declaration.d.ts
vendored
@@ -14,6 +14,9 @@ import type {
|
||||
RealDebridUser,
|
||||
DownloadSource,
|
||||
UserProfile,
|
||||
Collection,
|
||||
FriendRequest,
|
||||
FriendRequestAction,
|
||||
} from "@types";
|
||||
import type { DiskSpace } from "check-disk-space";
|
||||
|
||||
@@ -78,6 +81,13 @@ declare global {
|
||||
) => () => Electron.IpcRenderer;
|
||||
onLibraryBatchComplete: (cb: () => void) => () => Electron.IpcRenderer;
|
||||
|
||||
/* Collections */
|
||||
addCollection: (title: string) => Promise<void>;
|
||||
addCollectionGame: (id: number, game: Game) => Promise<void>;
|
||||
getCollections: () => Promise<Collection[]>;
|
||||
removeCollection: (collection: Collection) => Promise<void>;
|
||||
removeCollectionGame: (id: number, game: Game) => Promise<void>;
|
||||
|
||||
/* User preferences */
|
||||
getUserPreferences: () => Promise<UserPreferences | null>;
|
||||
updateUserPreferences: (
|
||||
@@ -100,7 +110,6 @@ declare global {
|
||||
|
||||
/* Misc */
|
||||
openExternal: (src: string) => Promise<void>;
|
||||
isUserLoggedIn: () => Promise<boolean>;
|
||||
getVersion: () => Promise<string>;
|
||||
ping: () => string;
|
||||
getDefaultDownloadsPath: () => Promise<string>;
|
||||
@@ -133,6 +142,12 @@ declare global {
|
||||
displayName: string,
|
||||
newProfileImagePath: string | null
|
||||
) => Promise<UserProfile>;
|
||||
getFriendRequests: () => Promise<FriendRequest[]>;
|
||||
updateFriendRequest: (
|
||||
userId: string,
|
||||
action: FriendRequestAction
|
||||
) => Promise<void>;
|
||||
sendFriendRequest: (userId: string) => Promise<void>;
|
||||
}
|
||||
|
||||
interface Window {
|
||||
|
||||
26
src/renderer/src/features/collections-slice.ts
Normal file
26
src/renderer/src/features/collections-slice.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { createSlice } from "@reduxjs/toolkit";
|
||||
import type { PayloadAction } from "@reduxjs/toolkit";
|
||||
import { Collection } from "../../../types/index";
|
||||
|
||||
export interface CollectionsState {
|
||||
value: Collection[];
|
||||
}
|
||||
|
||||
const initialState: CollectionsState = {
|
||||
value: [],
|
||||
};
|
||||
|
||||
export const collectionsSlice = createSlice({
|
||||
name: "collections",
|
||||
initialState,
|
||||
reducers: {
|
||||
setCollections: (
|
||||
state,
|
||||
action: PayloadAction<CollectionsState["value"]>
|
||||
) => {
|
||||
state.value = action.payload;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const { setCollections } = collectionsSlice.actions;
|
||||
@@ -6,3 +6,4 @@ export * from "./window-slice";
|
||||
export * from "./toast-slice";
|
||||
export * from "./user-details-slice";
|
||||
export * from "./running-game-slice";
|
||||
export * from "./collections-slice";
|
||||
|
||||
@@ -1,14 +1,21 @@
|
||||
import { PayloadAction, createSlice } from "@reduxjs/toolkit";
|
||||
import type { UserDetails } from "@types";
|
||||
import { UserFriendModalTab } from "@renderer/pages/shared-modals/user-friend-modal";
|
||||
import type { FriendRequest, UserDetails } from "@types";
|
||||
|
||||
export interface UserDetailsState {
|
||||
userDetails: UserDetails | null;
|
||||
profileBackground: null | string;
|
||||
friendRequests: FriendRequest[];
|
||||
isFriendsModalVisible: boolean;
|
||||
friendRequetsModalTab: UserFriendModalTab | null;
|
||||
}
|
||||
|
||||
const initialState: UserDetailsState = {
|
||||
userDetails: null,
|
||||
profileBackground: null,
|
||||
friendRequests: [],
|
||||
isFriendsModalVisible: false,
|
||||
friendRequetsModalTab: null,
|
||||
};
|
||||
|
||||
export const userDetailsSlice = createSlice({
|
||||
@@ -21,8 +28,27 @@ export const userDetailsSlice = createSlice({
|
||||
setProfileBackground: (state, action: PayloadAction<string | null>) => {
|
||||
state.profileBackground = action.payload;
|
||||
},
|
||||
setFriendRequests: (state, action: PayloadAction<FriendRequest[]>) => {
|
||||
state.friendRequests = action.payload;
|
||||
},
|
||||
setFriendsModalVisible: (
|
||||
state,
|
||||
action: PayloadAction<UserFriendModalTab>
|
||||
) => {
|
||||
state.isFriendsModalVisible = true;
|
||||
state.friendRequetsModalTab = action.payload;
|
||||
},
|
||||
setFriendsModalHidden: (state) => {
|
||||
state.isFriendsModalVisible = false;
|
||||
state.friendRequetsModalTab = null;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const { setUserDetails, setProfileBackground } =
|
||||
userDetailsSlice.actions;
|
||||
export const {
|
||||
setUserDetails,
|
||||
setProfileBackground,
|
||||
setFriendRequests,
|
||||
setFriendsModalVisible,
|
||||
setFriendsModalHidden,
|
||||
} = userDetailsSlice.actions;
|
||||
|
||||
@@ -4,3 +4,4 @@ export * from "./use-date";
|
||||
export * from "./use-toast";
|
||||
export * from "./redux";
|
||||
export * from "./use-user-details";
|
||||
export * from "./use-collections";
|
||||
|
||||
64
src/renderer/src/hooks/use-collections.ts
Normal file
64
src/renderer/src/hooks/use-collections.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import { useCallback } from "react";
|
||||
import { useAppDispatch, useAppSelector } from "./redux";
|
||||
import { setCollections } from "@renderer/features";
|
||||
import { Collection, Game } from "@types";
|
||||
import { useToast } from "./use-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
export function useCollections() {
|
||||
const { t } = useTranslation("collections");
|
||||
|
||||
const dispatch = useAppDispatch();
|
||||
const collections = useAppSelector((state) => state.collections.value);
|
||||
const { showSuccessToast, showErrorToast } = useToast();
|
||||
|
||||
const updateCollections = useCallback(async () => {
|
||||
return window.electron
|
||||
.getCollections()
|
||||
.then((updatedCollection) => dispatch(setCollections(updatedCollection)));
|
||||
}, [dispatch]);
|
||||
|
||||
const addCollection = async (title: string) => {
|
||||
if (
|
||||
!collections.some((collection) => collection.title === title) &&
|
||||
title !== ""
|
||||
) {
|
||||
await window.electron.addCollection(title);
|
||||
|
||||
updateCollections();
|
||||
showSuccessToast(t("the_collection_has_been_added_successfully"));
|
||||
} else {
|
||||
showErrorToast(t("you_cant_give_collections_existing_or_empty_names"));
|
||||
}
|
||||
};
|
||||
|
||||
const removeCollection = async (collection: Collection) => {
|
||||
await window.electron.removeCollection(collection);
|
||||
|
||||
updateCollections();
|
||||
showSuccessToast(t("the_collection_has_been_removed_successfully"));
|
||||
};
|
||||
|
||||
const addCollectionGame = async (collectionId: number, game: Game) => {
|
||||
await window.electron.addCollectionGame(collectionId, game);
|
||||
|
||||
updateCollections();
|
||||
showSuccessToast(t("the_game_has_been_added_to_the_collection"));
|
||||
};
|
||||
|
||||
const removeCollectionGame = async (collectionId: number, game: Game) => {
|
||||
await window.electron.removeCollectionGame(collectionId, game);
|
||||
|
||||
updateCollections();
|
||||
showSuccessToast(t("the_game_has_been_removed_from_the_collection"));
|
||||
};
|
||||
|
||||
return {
|
||||
collections,
|
||||
updateCollections,
|
||||
addCollection,
|
||||
removeCollection,
|
||||
addCollectionGame,
|
||||
removeCollectionGame,
|
||||
};
|
||||
}
|
||||
@@ -2,16 +2,27 @@ import { useCallback } from "react";
|
||||
import { average } from "color.js";
|
||||
|
||||
import { useAppDispatch, useAppSelector } from "./redux";
|
||||
import { setProfileBackground, setUserDetails } from "@renderer/features";
|
||||
import {
|
||||
setProfileBackground,
|
||||
setUserDetails,
|
||||
setFriendRequests,
|
||||
setFriendsModalVisible,
|
||||
setFriendsModalHidden,
|
||||
} from "@renderer/features";
|
||||
import { darkenColor } from "@renderer/helpers";
|
||||
import { UserDetails } from "@types";
|
||||
import { FriendRequestAction, UserDetails } from "@types";
|
||||
import { UserFriendModalTab } from "@renderer/pages/shared-modals/user-friend-modal";
|
||||
|
||||
export function useUserDetails() {
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const { userDetails, profileBackground } = useAppSelector(
|
||||
(state) => state.userDetails
|
||||
);
|
||||
const {
|
||||
userDetails,
|
||||
profileBackground,
|
||||
friendRequests,
|
||||
isFriendsModalVisible,
|
||||
friendRequetsModalTab,
|
||||
} = useAppSelector((state) => state.userDetails);
|
||||
|
||||
const clearUserDetails = useCallback(async () => {
|
||||
dispatch(setUserDetails(null));
|
||||
@@ -57,8 +68,14 @@ export function useUserDetails() {
|
||||
);
|
||||
|
||||
const fetchUserDetails = useCallback(async () => {
|
||||
return window.electron.getMe();
|
||||
}, []);
|
||||
return window.electron.getMe().then((userDetails) => {
|
||||
if (userDetails == null) {
|
||||
clearUserDetails();
|
||||
}
|
||||
|
||||
return userDetails;
|
||||
});
|
||||
}, [clearUserDetails]);
|
||||
|
||||
const patchUser = useCallback(
|
||||
async (displayName: string, imageProfileUrl: string | null) => {
|
||||
@@ -72,13 +89,56 @@ export function useUserDetails() {
|
||||
[updateUserDetails]
|
||||
);
|
||||
|
||||
const updateFriendRequests = useCallback(async () => {
|
||||
const friendRequests = await window.electron.getFriendRequests();
|
||||
dispatch(setFriendRequests(friendRequests));
|
||||
}, [dispatch]);
|
||||
|
||||
const showFriendsModal = useCallback(
|
||||
(tab: UserFriendModalTab) => {
|
||||
dispatch(setFriendsModalVisible(tab));
|
||||
updateFriendRequests();
|
||||
},
|
||||
[dispatch]
|
||||
);
|
||||
|
||||
const hideFriendsModal = useCallback(() => {
|
||||
dispatch(setFriendsModalHidden());
|
||||
}, [dispatch]);
|
||||
|
||||
const sendFriendRequest = useCallback(
|
||||
async (userId: string) => {
|
||||
return window.electron
|
||||
.sendFriendRequest(userId)
|
||||
.then(() => updateFriendRequests());
|
||||
},
|
||||
[updateFriendRequests]
|
||||
);
|
||||
|
||||
const updateFriendRequestState = useCallback(
|
||||
async (userId: string, action: FriendRequestAction) => {
|
||||
return window.electron
|
||||
.updateFriendRequest(userId, action)
|
||||
.then(() => updateFriendRequests());
|
||||
},
|
||||
[updateFriendRequests]
|
||||
);
|
||||
|
||||
return {
|
||||
userDetails,
|
||||
profileBackground,
|
||||
friendRequests,
|
||||
friendRequetsModalTab,
|
||||
isFriendsModalVisible,
|
||||
showFriendsModal,
|
||||
hideFriendsModal,
|
||||
fetchUserDetails,
|
||||
signOut,
|
||||
clearUserDetails,
|
||||
updateUserDetails,
|
||||
patchUser,
|
||||
profileBackground,
|
||||
sendFriendRequest,
|
||||
updateFriendRequests,
|
||||
updateFriendRequestState,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -122,7 +122,7 @@ export function HeroPanelActions() {
|
||||
<Button
|
||||
onClick={() => setShowGameOptionsModal(true)}
|
||||
theme="outline"
|
||||
disabled={deleting || isGameRunning}
|
||||
disabled={deleting}
|
||||
className={styles.heroPanelAction}
|
||||
>
|
||||
<GearIcon />
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import { style } from "@vanilla-extract/css";
|
||||
|
||||
import { SPACING_UNIT } from "../../../theme.css";
|
||||
|
||||
export const collectionsContainer = style({
|
||||
display: "flex",
|
||||
gap: `${SPACING_UNIT * 2}px`,
|
||||
flexDirection: "column",
|
||||
width: "50%",
|
||||
margin: "auto",
|
||||
});
|
||||
|
||||
export const buttonsContainer = style({
|
||||
display: "flex",
|
||||
gap: `${SPACING_UNIT * 2}px`,
|
||||
flexDirection: "row",
|
||||
});
|
||||
|
||||
export const buttonSelect = style({
|
||||
flex: 3,
|
||||
});
|
||||
|
||||
export const buttonRemove = style({
|
||||
flex: 1,
|
||||
});
|
||||
108
src/renderer/src/pages/game-details/modals/collections-modal.tsx
Normal file
108
src/renderer/src/pages/game-details/modals/collections-modal.tsx
Normal file
@@ -0,0 +1,108 @@
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Button, Modal, TextField } from "@renderer/components";
|
||||
import type { Collection, Game } from "@types";
|
||||
import * as styles from "./collections-modal.css";
|
||||
import { useCollections } from "@renderer/hooks/use-collections";
|
||||
import { useLibrary } from "@renderer/hooks";
|
||||
|
||||
export interface CollectionsModalProps {
|
||||
visible: boolean;
|
||||
game: Game;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function CollectionsModal({
|
||||
visible,
|
||||
game,
|
||||
onClose,
|
||||
}: CollectionsModalProps) {
|
||||
const { t } = useTranslation("collections");
|
||||
const {
|
||||
collections,
|
||||
addCollection,
|
||||
removeCollection,
|
||||
addCollectionGame,
|
||||
removeCollectionGame,
|
||||
} = useCollections();
|
||||
const { updateLibrary } = useLibrary();
|
||||
|
||||
const [collectionTitle, setcollectionTitle] = useState<string>("");
|
||||
|
||||
const handleAddCollection = () => {
|
||||
addCollection(collectionTitle);
|
||||
setcollectionTitle("");
|
||||
};
|
||||
|
||||
const handleRemoveCollection = (collection: Collection) => {
|
||||
removeCollection(collection);
|
||||
updateLibrary();
|
||||
};
|
||||
|
||||
const handleSetCollection = (id: number, addOrRemove: boolean) => {
|
||||
addOrRemove ? addCollectionGame(id, game) : removeCollectionGame(id, game);
|
||||
updateLibrary();
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Modal
|
||||
visible={visible}
|
||||
title={t("collections")}
|
||||
onClose={onClose}
|
||||
large={true}
|
||||
>
|
||||
<div className={styles.collectionsContainer}>
|
||||
<TextField
|
||||
value={collectionTitle}
|
||||
theme="dark"
|
||||
placeholder={t("enter_the_name_of_the_collection")}
|
||||
onChange={(e) => setcollectionTitle(e.target.value)}
|
||||
rightContent={
|
||||
<Button
|
||||
type="button"
|
||||
theme="outline"
|
||||
onClick={handleAddCollection}
|
||||
>
|
||||
{t("add")}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
{collections.map((collection) => (
|
||||
<div className={styles.buttonsContainer} key={collection.id}>
|
||||
<Button
|
||||
className={styles.buttonSelect}
|
||||
type="button"
|
||||
theme={
|
||||
collection.games?.some(
|
||||
(collectionGame) => collectionGame.id == game.id
|
||||
)
|
||||
? "primary"
|
||||
: "outline"
|
||||
}
|
||||
onClick={() =>
|
||||
handleSetCollection(
|
||||
collection.id,
|
||||
!collection.games?.some(
|
||||
(collectionGame) => collectionGame.id == game.id
|
||||
)
|
||||
)
|
||||
}
|
||||
>
|
||||
{collection.title}
|
||||
</Button>
|
||||
<Button
|
||||
className={styles.buttonRemove}
|
||||
type="button"
|
||||
theme="danger"
|
||||
onClick={() => handleRemoveCollection(collection)}
|
||||
>
|
||||
{t("remove")}
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import { gameDetailsContext } from "@renderer/context";
|
||||
import { DeleteGameModal } from "@renderer/pages/downloads/delete-game-modal";
|
||||
import { useDownload, useToast } from "@renderer/hooks";
|
||||
import { RemoveGameFromLibraryModal } from "./remove-from-library-modal";
|
||||
import { CollectionsModal } from "./collections-modal";
|
||||
|
||||
export interface GameOptionsModalProps {
|
||||
visible: boolean;
|
||||
@@ -19,7 +20,7 @@ export function GameOptionsModal({
|
||||
game,
|
||||
onClose,
|
||||
}: GameOptionsModalProps) {
|
||||
const { t } = useTranslation("game_details");
|
||||
const { t } = useTranslation(["game_details", "collections"]);
|
||||
|
||||
const { showSuccessToast, showErrorToast } = useToast();
|
||||
|
||||
@@ -28,6 +29,7 @@ export function GameOptionsModal({
|
||||
|
||||
const [showDeleteModal, setShowDeleteModal] = useState(false);
|
||||
const [showRemoveGameModal, setShowRemoveGameModal] = useState(false);
|
||||
const [showCollectionsModal, setShowCollectionsModal] = useState(false);
|
||||
|
||||
const {
|
||||
removeGameInstaller,
|
||||
@@ -107,6 +109,29 @@ export function GameOptionsModal({
|
||||
large={true}
|
||||
>
|
||||
<div className={styles.optionsContainer}>
|
||||
<div className={styles.gameOptionHeader}>
|
||||
<h2>{t("collections:collections")}</h2>
|
||||
<h4 className={styles.gameOptionHeaderDescription}>
|
||||
{t("collections:add_the_game_to_the_collection")}
|
||||
</h4>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
theme="outline"
|
||||
onClick={() => setShowCollectionsModal(true)}
|
||||
>
|
||||
{t("collections:select_a_collection")}
|
||||
</Button>
|
||||
|
||||
<CollectionsModal
|
||||
visible={showCollectionsModal}
|
||||
game={game}
|
||||
onClose={() => {
|
||||
setShowCollectionsModal(false);
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className={styles.gameOptionHeader}>
|
||||
<h2>{t("executable_section_title")}</h2>
|
||||
<h4 className={styles.gameOptionHeaderDescription}>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useContext, useEffect, useState } from "react";
|
||||
import { useCallback, useContext, useEffect, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import parseTorrent from "parse-torrent";
|
||||
|
||||
@@ -12,6 +12,7 @@ import { format } from "date-fns";
|
||||
import { DownloadSettingsModal } from "./download-settings-modal";
|
||||
import { gameDetailsContext } from "@renderer/context";
|
||||
import { Downloader } from "@shared";
|
||||
import { orderBy } from "lodash-es";
|
||||
|
||||
export interface RepacksModalProps {
|
||||
visible: boolean;
|
||||
@@ -38,16 +39,20 @@ export function RepacksModal({
|
||||
|
||||
const { t } = useTranslation("game_details");
|
||||
|
||||
const sortedRepacks = useMemo(() => {
|
||||
return orderBy(repacks, (repack) => repack.uploadDate, "desc");
|
||||
}, [repacks]);
|
||||
|
||||
const getInfoHash = useCallback(async () => {
|
||||
const torrent = await parseTorrent(game?.uri ?? "");
|
||||
if (torrent.infoHash) setInfoHash(torrent.infoHash);
|
||||
}, [game]);
|
||||
|
||||
useEffect(() => {
|
||||
setFilteredRepacks(repacks);
|
||||
setFilteredRepacks(sortedRepacks);
|
||||
|
||||
if (game?.uri) getInfoHash();
|
||||
}, [repacks, visible, game, getInfoHash]);
|
||||
}, [sortedRepacks, visible, game, getInfoHash]);
|
||||
|
||||
const handleRepackClick = (repack: GameRepack) => {
|
||||
setRepack(repack);
|
||||
@@ -58,7 +63,7 @@ export function RepacksModal({
|
||||
const term = event.target.value.toLocaleLowerCase();
|
||||
|
||||
setFilteredRepacks(
|
||||
repacks.filter((repack) => {
|
||||
sortedRepacks.filter((repack) => {
|
||||
const lowerCaseTitle = repack.title.toLowerCase();
|
||||
const lowerCaseRepacker = repack.repacker.toLowerCase();
|
||||
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./user-friend-modal";
|
||||
@@ -0,0 +1,140 @@
|
||||
import { Button, TextField } from "@renderer/components";
|
||||
import { useToast, useUserDetails } from "@renderer/hooks";
|
||||
import { SPACING_UNIT } from "@renderer/theme.css";
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { UserFriendRequest } from "./user-friend-request";
|
||||
|
||||
export interface UserFriendModalAddFriendProps {
|
||||
closeModal: () => void;
|
||||
}
|
||||
|
||||
export const UserFriendModalAddFriend = ({
|
||||
closeModal,
|
||||
}: UserFriendModalAddFriendProps) => {
|
||||
const { t } = useTranslation("user_profile");
|
||||
|
||||
const [friendCode, setFriendCode] = useState("");
|
||||
const [isAddingFriend, setIsAddingFriend] = useState(false);
|
||||
|
||||
const navigate = useNavigate();
|
||||
|
||||
const { sendFriendRequest, updateFriendRequestState, friendRequests } =
|
||||
useUserDetails();
|
||||
|
||||
const { showErrorToast } = useToast();
|
||||
|
||||
const handleClickAddFriend = () => {
|
||||
setIsAddingFriend(true);
|
||||
sendFriendRequest(friendCode)
|
||||
.then(() => {
|
||||
// TODO: add validation for this input?
|
||||
setFriendCode("");
|
||||
})
|
||||
.catch(() => {
|
||||
showErrorToast("Não foi possível enviar o pedido de amizade");
|
||||
})
|
||||
.finally(() => {
|
||||
setIsAddingFriend(false);
|
||||
});
|
||||
};
|
||||
|
||||
const resetAndClose = () => {
|
||||
setFriendCode("");
|
||||
closeModal();
|
||||
};
|
||||
|
||||
const handleClickRequest = (userId: string) => {
|
||||
resetAndClose();
|
||||
navigate(`/user/${userId}`);
|
||||
};
|
||||
|
||||
const handleClickSeeProfile = () => {
|
||||
resetAndClose();
|
||||
// TODO: add validation for this input?
|
||||
navigate(`/user/${friendCode}`);
|
||||
};
|
||||
|
||||
const handleClickCancelFriendRequest = (userId: string) => {
|
||||
updateFriendRequestState(userId, "CANCEL").catch(() => {
|
||||
showErrorToast("Falha ao cancelar convite");
|
||||
});
|
||||
};
|
||||
|
||||
const handleClickAcceptFriendRequest = (userId: string) => {
|
||||
updateFriendRequestState(userId, "ACCEPTED").catch(() => {
|
||||
showErrorToast("Falha ao aceitar convite");
|
||||
});
|
||||
};
|
||||
|
||||
const handleClickRefuseFriendRequest = (userId: string) => {
|
||||
updateFriendRequestState(userId, "REFUSED").catch(() => {
|
||||
showErrorToast("Falha ao recusar convite");
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "row",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
gap: `${SPACING_UNIT}px`,
|
||||
}}
|
||||
>
|
||||
<TextField
|
||||
label={t("friend_code")}
|
||||
value={friendCode}
|
||||
minLength={8}
|
||||
maxLength={8}
|
||||
containerProps={{ style: { width: "100%" } }}
|
||||
onChange={(e) => setFriendCode(e.target.value)}
|
||||
/>
|
||||
<Button
|
||||
disabled={isAddingFriend}
|
||||
style={{ alignSelf: "end" }}
|
||||
type="button"
|
||||
onClick={handleClickAddFriend}
|
||||
>
|
||||
{isAddingFriend ? t("sending") : t("add")}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleClickSeeProfile}
|
||||
disabled={isAddingFriend}
|
||||
style={{ alignSelf: "end" }}
|
||||
type="button"
|
||||
>
|
||||
{t("see_profile")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: `${SPACING_UNIT * 2}px`,
|
||||
}}
|
||||
>
|
||||
<h3>Pendentes</h3>
|
||||
{friendRequests.map((request) => {
|
||||
return (
|
||||
<UserFriendRequest
|
||||
key={request.id}
|
||||
displayName={request.displayName}
|
||||
isRequestSent={request.type === "SENT"}
|
||||
profileImageUrl={request.profileImageUrl}
|
||||
userId={request.id}
|
||||
onClickAcceptRequest={handleClickAcceptFriendRequest}
|
||||
onClickCancelRequest={handleClickCancelFriendRequest}
|
||||
onClickRefuseRequest={handleClickRefuseFriendRequest}
|
||||
onClickRequest={handleClickRequest}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,92 @@
|
||||
import { SPACING_UNIT, vars } from "../../../theme.css";
|
||||
import { style } from "@vanilla-extract/css";
|
||||
|
||||
export const profileContentBox = style({
|
||||
display: "flex",
|
||||
gap: `${SPACING_UNIT * 3}px`,
|
||||
alignItems: "center",
|
||||
borderRadius: "4px",
|
||||
border: `solid 1px ${vars.color.border}`,
|
||||
width: "100%",
|
||||
boxShadow: "0px 0px 15px 0px rgba(0, 0, 0, 0.7)",
|
||||
transition: "all ease 0.3s",
|
||||
});
|
||||
|
||||
export const friendAvatarContainer = style({
|
||||
width: "35px",
|
||||
minWidth: "35px",
|
||||
height: "35px",
|
||||
borderRadius: "50%",
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
backgroundColor: vars.color.background,
|
||||
overflow: "hidden",
|
||||
border: `solid 1px ${vars.color.border}`,
|
||||
boxShadow: "0px 0px 5px 0px rgba(0, 0, 0, 0.7)",
|
||||
});
|
||||
|
||||
export const friendListDisplayName = style({
|
||||
fontWeight: "bold",
|
||||
fontSize: vars.size.body,
|
||||
textAlign: "left",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
});
|
||||
|
||||
export const profileAvatar = style({
|
||||
height: "100%",
|
||||
width: "100%",
|
||||
objectFit: "cover",
|
||||
});
|
||||
|
||||
export const friendListContainer = style({
|
||||
width: "100%",
|
||||
height: "54px",
|
||||
transition: "all ease 0.2s",
|
||||
position: "relative",
|
||||
":hover": {
|
||||
backgroundColor: "rgba(255, 255, 255, 0.15)",
|
||||
},
|
||||
});
|
||||
|
||||
export const friendListButton = style({
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
position: "absolute",
|
||||
cursor: "pointer",
|
||||
height: "100%",
|
||||
width: "100%",
|
||||
flexDirection: "row",
|
||||
color: vars.color.body,
|
||||
gap: `${SPACING_UNIT + SPACING_UNIT / 2}px`,
|
||||
padding: `0 ${SPACING_UNIT}px`,
|
||||
});
|
||||
|
||||
export const friendRequestItem = style({
|
||||
color: vars.color.body,
|
||||
":hover": {
|
||||
backgroundColor: "rgba(255, 255, 255, 0.15)",
|
||||
},
|
||||
});
|
||||
|
||||
export const acceptRequestButton = style({
|
||||
cursor: "pointer",
|
||||
color: vars.color.body,
|
||||
width: "28px",
|
||||
height: "28px",
|
||||
":hover": {
|
||||
color: vars.color.success,
|
||||
},
|
||||
});
|
||||
|
||||
export const cancelRequestButton = style({
|
||||
cursor: "pointer",
|
||||
color: vars.color.body,
|
||||
width: "28px",
|
||||
height: "28px",
|
||||
":hover": {
|
||||
color: vars.color.danger,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,77 @@
|
||||
import { Button, Modal } from "@renderer/components";
|
||||
import { SPACING_UNIT } from "@renderer/theme.css";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { UserFriendModalAddFriend } from "./user-friend-modal-add-friend";
|
||||
|
||||
export enum UserFriendModalTab {
|
||||
FriendsList,
|
||||
AddFriend,
|
||||
}
|
||||
|
||||
export interface UserAddFriendsModalProps {
|
||||
visible: boolean;
|
||||
onClose: () => void;
|
||||
initialTab: UserFriendModalTab | null;
|
||||
}
|
||||
|
||||
export const UserFriendModal = ({
|
||||
visible,
|
||||
onClose,
|
||||
initialTab,
|
||||
}: UserAddFriendsModalProps) => {
|
||||
const { t } = useTranslation("user_profile");
|
||||
|
||||
const tabs = [t("friends_list"), t("add_friends")];
|
||||
|
||||
const [currentTab, setCurrentTab] = useState(
|
||||
initialTab || UserFriendModalTab.FriendsList
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (initialTab != null) {
|
||||
setCurrentTab(initialTab);
|
||||
}
|
||||
}, [initialTab]);
|
||||
|
||||
const renderTab = () => {
|
||||
if (currentTab == UserFriendModalTab.FriendsList) {
|
||||
return <></>;
|
||||
}
|
||||
|
||||
if (currentTab == UserFriendModalTab.AddFriend) {
|
||||
return <UserFriendModalAddFriend closeModal={onClose} />;
|
||||
}
|
||||
|
||||
return <></>;
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal visible={visible} title={t("friends")} onClose={onClose}>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
width: "500px",
|
||||
flexDirection: "column",
|
||||
gap: `${SPACING_UNIT * 2}px`,
|
||||
}}
|
||||
>
|
||||
<section style={{ display: "flex", gap: `${SPACING_UNIT}px` }}>
|
||||
{tabs.map((tab, index) => {
|
||||
return (
|
||||
<Button
|
||||
key={tab}
|
||||
theme={index === currentTab ? "primary" : "outline"}
|
||||
onClick={() => setCurrentTab(index)}
|
||||
>
|
||||
{tab}
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</section>
|
||||
<h2>{tabs[currentTab]}</h2>
|
||||
{renderTab()}
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,97 @@
|
||||
import {
|
||||
CheckCircleIcon,
|
||||
PersonIcon,
|
||||
XCircleIcon,
|
||||
} from "@primer/octicons-react";
|
||||
import * as styles from "./user-friend-modal.css";
|
||||
import cn from "classnames";
|
||||
import { SPACING_UNIT } from "@renderer/theme.css";
|
||||
|
||||
export interface UserFriendRequestProps {
|
||||
userId: string;
|
||||
profileImageUrl: string | null;
|
||||
displayName: string;
|
||||
isRequestSent: boolean;
|
||||
onClickCancelRequest: (userId: string) => void;
|
||||
onClickAcceptRequest: (userId: string) => void;
|
||||
onClickRefuseRequest: (userId: string) => void;
|
||||
onClickRequest: (userId: string) => void;
|
||||
}
|
||||
|
||||
export const UserFriendRequest = ({
|
||||
userId,
|
||||
profileImageUrl,
|
||||
displayName,
|
||||
isRequestSent,
|
||||
onClickCancelRequest,
|
||||
onClickAcceptRequest,
|
||||
onClickRefuseRequest,
|
||||
onClickRequest,
|
||||
}: UserFriendRequestProps) => {
|
||||
return (
|
||||
<div className={cn(styles.friendListContainer, styles.profileContentBox)}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.friendListButton}
|
||||
onClick={() => onClickRequest(userId)}
|
||||
>
|
||||
<div className={styles.friendAvatarContainer}>
|
||||
{profileImageUrl ? (
|
||||
<img
|
||||
className={styles.profileAvatar}
|
||||
alt={displayName}
|
||||
src={profileImageUrl}
|
||||
/>
|
||||
) : (
|
||||
<PersonIcon size={24} />
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "flex-start",
|
||||
flex: "1",
|
||||
minWidth: 0,
|
||||
}}
|
||||
>
|
||||
<p className={styles.friendListDisplayName}>{displayName}</p>
|
||||
<small>{isRequestSent ? "Pedido enviado" : "Pedido recebido"}</small>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
right: "8px",
|
||||
display: "flex",
|
||||
gap: `${SPACING_UNIT}px`,
|
||||
}}
|
||||
>
|
||||
{isRequestSent ? (
|
||||
<button
|
||||
className={styles.cancelRequestButton}
|
||||
onClick={() => onClickCancelRequest(userId)}
|
||||
>
|
||||
<XCircleIcon size={28} />
|
||||
</button>
|
||||
) : (
|
||||
<>
|
||||
<button
|
||||
className={styles.acceptRequestButton}
|
||||
onClick={() => onClickAcceptRequest(userId)}
|
||||
>
|
||||
<CheckCircleIcon size={28} />
|
||||
</button>
|
||||
<button
|
||||
className={styles.cancelRequestButton}
|
||||
onClick={() => onClickRefuseRequest(userId)}
|
||||
>
|
||||
<XCircleIcon size={28} />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,9 +1,8 @@
|
||||
import { UserGame, UserProfile } from "@types";
|
||||
import cn from "classnames";
|
||||
|
||||
import * as styles from "./user.css";
|
||||
import { SPACING_UNIT, vars } from "@renderer/theme.css";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import SteamLogo from "@renderer/assets/steam-logo.svg?react";
|
||||
import {
|
||||
@@ -14,10 +13,11 @@ import {
|
||||
} from "@renderer/hooks";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { buildGameDetailsPath, steamUrlBuilder } from "@renderer/helpers";
|
||||
import { PersonIcon, TelescopeIcon } from "@primer/octicons-react";
|
||||
import { PersonIcon, PlusIcon, TelescopeIcon } from "@primer/octicons-react";
|
||||
import { Button, Link } from "@renderer/components";
|
||||
import { UserEditProfileModal } from "./user-edit-modal";
|
||||
import { UserSignOutModal } from "./user-signout-modal";
|
||||
import { UserFriendModalTab } from "../shared-modals/user-friend-modal";
|
||||
|
||||
const MAX_MINUTES_TO_SHOW_IN_PLAYTIME = 120;
|
||||
|
||||
@@ -32,7 +32,13 @@ export function UserContent({
|
||||
}: ProfileContentProps) {
|
||||
const { t, i18n } = useTranslation("user_profile");
|
||||
|
||||
const { userDetails, profileBackground, signOut } = useUserDetails();
|
||||
const {
|
||||
userDetails,
|
||||
profileBackground,
|
||||
signOut,
|
||||
updateFriendRequests,
|
||||
showFriendsModal,
|
||||
} = useUserDetails();
|
||||
const { showSuccessToast } = useToast();
|
||||
|
||||
const [showEditProfileModal, setShowEditProfileModal] = useState(false);
|
||||
@@ -72,6 +78,10 @@ export function UserContent({
|
||||
setShowEditProfileModal(true);
|
||||
};
|
||||
|
||||
const handleOnClickFriend = (userId: string) => {
|
||||
navigate(`/user/${userId}`);
|
||||
};
|
||||
|
||||
const handleConfirmSignout = async () => {
|
||||
await signOut();
|
||||
|
||||
@@ -82,6 +92,10 @@ export function UserContent({
|
||||
|
||||
const isMe = userDetails?.id == userProfile.id;
|
||||
|
||||
useEffect(() => {
|
||||
if (isMe) updateFriendRequests();
|
||||
}, [isMe]);
|
||||
|
||||
const profileContentBoxBackground = useMemo(() => {
|
||||
if (profileBackground) return profileBackground;
|
||||
/* TODO: Render background colors for other users */
|
||||
@@ -216,9 +230,11 @@ export function UserContent({
|
||||
<TelescopeIcon size={24} />
|
||||
</div>
|
||||
<h2>{t("no_recent_activity_title")}</h2>
|
||||
<p style={{ fontFamily: "Fira Sans" }}>
|
||||
{t("no_recent_activity_description")}
|
||||
</p>
|
||||
{isMe && (
|
||||
<p style={{ fontFamily: "Fira Sans" }}>
|
||||
{t("no_recent_activity_description")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
@@ -259,55 +275,128 @@ export function UserContent({
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className={cn(styles.contentSidebar, styles.profileGameSection)}>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
gap: `${SPACING_UNIT * 2}px`,
|
||||
}}
|
||||
>
|
||||
<h2>{t("library")}</h2>
|
||||
|
||||
<div className={styles.contentSidebar}>
|
||||
<div className={styles.profileGameSection}>
|
||||
<div
|
||||
style={{
|
||||
flex: 1,
|
||||
backgroundColor: vars.color.border,
|
||||
height: "1px",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
gap: `${SPACING_UNIT * 2}px`,
|
||||
}}
|
||||
/>
|
||||
<h3 style={{ fontWeight: "400" }}>
|
||||
{userProfile.libraryGames.length}
|
||||
</h3>
|
||||
>
|
||||
<h2>{t("library")}</h2>
|
||||
|
||||
<div
|
||||
style={{
|
||||
flex: 1,
|
||||
backgroundColor: vars.color.border,
|
||||
height: "1px",
|
||||
}}
|
||||
/>
|
||||
<h3 style={{ fontWeight: "400" }}>
|
||||
{userProfile.libraryGames.length}
|
||||
</h3>
|
||||
</div>
|
||||
<small>{t("total_play_time", { amount: formatPlayTime() })}</small>
|
||||
<div
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "repeat(4, 1fr)",
|
||||
gap: `${SPACING_UNIT}px`,
|
||||
}}
|
||||
>
|
||||
{userProfile.libraryGames.map((game) => (
|
||||
<button
|
||||
key={game.objectID}
|
||||
className={cn(styles.gameListItem, styles.profileContentBox)}
|
||||
onClick={() => handleGameClick(game)}
|
||||
title={game.title}
|
||||
>
|
||||
{game.iconUrl ? (
|
||||
<img
|
||||
className={styles.libraryGameIcon}
|
||||
src={game.iconUrl}
|
||||
alt={game.title}
|
||||
/>
|
||||
) : (
|
||||
<SteamLogo className={styles.libraryGameIcon} />
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<small>{t("total_play_time", { amount: formatPlayTime() })}</small>
|
||||
<div
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "repeat(4, 1fr)",
|
||||
gap: `${SPACING_UNIT}px`,
|
||||
}}
|
||||
>
|
||||
{userProfile.libraryGames.map((game) => (
|
||||
|
||||
{(isMe ||
|
||||
(userProfile.friends && userProfile.friends.length > 0)) && (
|
||||
<div className={styles.friendsSection}>
|
||||
<button
|
||||
key={game.objectID}
|
||||
className={cn(styles.gameListItem, styles.profileContentBox)}
|
||||
onClick={() => handleGameClick(game)}
|
||||
title={game.title}
|
||||
className={styles.friendsSectionHeader}
|
||||
onClick={() => showFriendsModal(UserFriendModalTab.FriendsList)}
|
||||
>
|
||||
{game.iconUrl ? (
|
||||
<img
|
||||
className={styles.libraryGameIcon}
|
||||
src={game.iconUrl}
|
||||
alt={game.title}
|
||||
/>
|
||||
) : (
|
||||
<SteamLogo className={styles.libraryGameIcon} />
|
||||
)}
|
||||
<h2>{t("friends")}</h2>
|
||||
|
||||
<div
|
||||
style={{
|
||||
flex: 1,
|
||||
backgroundColor: vars.color.border,
|
||||
height: "1px",
|
||||
}}
|
||||
/>
|
||||
<h3 style={{ fontWeight: "400" }}>
|
||||
{userProfile.friends.length}
|
||||
</h3>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: `${SPACING_UNIT}px`,
|
||||
}}
|
||||
>
|
||||
{userProfile.friends.map((friend) => {
|
||||
return (
|
||||
<button
|
||||
key={friend.id}
|
||||
className={cn(
|
||||
styles.profileContentBox,
|
||||
styles.friendListContainer
|
||||
)}
|
||||
onClick={() => handleOnClickFriend(friend.id)}
|
||||
>
|
||||
<div className={styles.friendAvatarContainer}>
|
||||
{friend.profileImageUrl ? (
|
||||
<img
|
||||
className={styles.friendProfileIcon}
|
||||
src={friend.profileImageUrl}
|
||||
alt={friend.displayName}
|
||||
/>
|
||||
) : (
|
||||
<PersonIcon size={24} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<p className={styles.friendListDisplayName}>
|
||||
{friend.displayName}
|
||||
</p>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
|
||||
{isMe && (
|
||||
<Button
|
||||
theme="outline"
|
||||
onClick={() =>
|
||||
showFriendsModal(UserFriendModalTab.AddFriend)
|
||||
}
|
||||
>
|
||||
<PlusIcon /> {t("add")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
|
||||
@@ -11,6 +11,7 @@ export const wrapper = style({
|
||||
|
||||
export const profileContentBox = style({
|
||||
display: "flex",
|
||||
cursor: "pointer",
|
||||
gap: `${SPACING_UNIT * 3}px`,
|
||||
alignItems: "center",
|
||||
borderRadius: "4px",
|
||||
@@ -35,6 +36,29 @@ export const profileAvatarContainer = style({
|
||||
zIndex: 1,
|
||||
});
|
||||
|
||||
export const friendAvatarContainer = style({
|
||||
width: "35px",
|
||||
minWidth: "35px",
|
||||
height: "35px",
|
||||
borderRadius: "50%",
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
backgroundColor: vars.color.background,
|
||||
overflow: "hidden",
|
||||
border: `solid 1px ${vars.color.border}`,
|
||||
boxShadow: "0px 0px 5px 0px rgba(0, 0, 0, 0.7)",
|
||||
});
|
||||
|
||||
export const friendListDisplayName = style({
|
||||
fontWeight: "bold",
|
||||
fontSize: vars.size.body,
|
||||
textAlign: "left",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
});
|
||||
|
||||
export const profileAvatarEditContainer = style({
|
||||
width: "128px",
|
||||
height: "128px",
|
||||
@@ -53,8 +77,6 @@ export const profileAvatarEditContainer = style({
|
||||
export const profileAvatar = style({
|
||||
height: "100%",
|
||||
width: "100%",
|
||||
borderRadius: "50%",
|
||||
overflow: "hidden",
|
||||
objectFit: "cover",
|
||||
});
|
||||
|
||||
@@ -86,14 +108,36 @@ export const profileContent = style({
|
||||
|
||||
export const profileGameSection = style({
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: `${SPACING_UNIT * 2}px`,
|
||||
});
|
||||
|
||||
export const friendsSection = style({
|
||||
width: "100%",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: `${SPACING_UNIT * 2}px`,
|
||||
});
|
||||
|
||||
export const friendsSectionHeader = style({
|
||||
fontSize: vars.size.body,
|
||||
color: vars.color.body,
|
||||
cursor: "pointer",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
gap: `${SPACING_UNIT * 2}px`,
|
||||
":hover": {
|
||||
color: vars.color.muted,
|
||||
},
|
||||
});
|
||||
|
||||
export const contentSidebar = style({
|
||||
width: "100%",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: `${SPACING_UNIT * 3}px`,
|
||||
"@media": {
|
||||
"(min-width: 768px)": {
|
||||
width: "100%",
|
||||
@@ -116,12 +160,17 @@ export const libraryGameIcon = style({
|
||||
borderRadius: "4px",
|
||||
});
|
||||
|
||||
export const friendProfileIcon = style({
|
||||
height: "100%",
|
||||
});
|
||||
|
||||
export const feedItem = style({
|
||||
color: vars.color.body,
|
||||
display: "flex",
|
||||
flexDirection: "row",
|
||||
gap: `${SPACING_UNIT * 2}px`,
|
||||
width: "100%",
|
||||
overflow: "hidden",
|
||||
height: "72px",
|
||||
transition: "all ease 0.2s",
|
||||
cursor: "pointer",
|
||||
@@ -143,6 +192,19 @@ export const gameListItem = style({
|
||||
},
|
||||
});
|
||||
|
||||
export const friendListContainer = style({
|
||||
color: vars.color.body,
|
||||
width: "100%",
|
||||
height: "54px",
|
||||
padding: `0 ${SPACING_UNIT}px`,
|
||||
gap: `${SPACING_UNIT + SPACING_UNIT / 2}px`,
|
||||
transition: "all ease 0.2s",
|
||||
position: "relative",
|
||||
":hover": {
|
||||
backgroundColor: "rgba(255, 255, 255, 0.15)",
|
||||
},
|
||||
});
|
||||
|
||||
export const gameInformation = style({
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
|
||||
@@ -2,18 +2,23 @@ import { UserProfile } from "@types";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import { setHeaderTitle } from "@renderer/features";
|
||||
import { useAppDispatch } from "@renderer/hooks";
|
||||
import { useAppDispatch, useToast } from "@renderer/hooks";
|
||||
import { UserSkeleton } from "./user-skeleton";
|
||||
import { UserContent } from "./user-content";
|
||||
import { SkeletonTheme } from "react-loading-skeleton";
|
||||
import { vars } from "@renderer/theme.css";
|
||||
import * as styles from "./user.css";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
export const User = () => {
|
||||
const { userId } = useParams();
|
||||
const [userProfile, setUserProfile] = useState<UserProfile>();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const { t } = useTranslation("user_profile");
|
||||
|
||||
const { showErrorToast } = useToast();
|
||||
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const getUserProfile = useCallback(() => {
|
||||
@@ -22,10 +27,11 @@ export const User = () => {
|
||||
dispatch(setHeaderTitle(userProfile.displayName));
|
||||
setUserProfile(userProfile);
|
||||
} else {
|
||||
showErrorToast(t("user_not_found"));
|
||||
navigate(-1);
|
||||
}
|
||||
});
|
||||
}, [dispatch, userId]);
|
||||
}, [dispatch, userId, t]);
|
||||
|
||||
useEffect(() => {
|
||||
getUserProfile();
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
downloadSlice,
|
||||
windowSlice,
|
||||
librarySlice,
|
||||
collectionsSlice,
|
||||
searchSlice,
|
||||
userPreferencesSlice,
|
||||
toastSlice,
|
||||
@@ -15,6 +16,7 @@ export const store = configureStore({
|
||||
search: searchSlice.reducer,
|
||||
window: windowSlice.reducer,
|
||||
library: librarySlice.reducer,
|
||||
collections: collectionsSlice.reducer,
|
||||
userPreferences: userPreferencesSlice.reducer,
|
||||
download: downloadSlice.reducer,
|
||||
toast: toastSlice.reducer,
|
||||
|
||||
@@ -8,6 +8,13 @@ export enum DownloadSourceStatus {
|
||||
Errored,
|
||||
}
|
||||
|
||||
export class UserNotLoggedInError extends Error {
|
||||
constructor() {
|
||||
super("user not logged in");
|
||||
this.name = "UserNotLoggedInError";
|
||||
}
|
||||
}
|
||||
|
||||
const FORMAT = ["B", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"];
|
||||
|
||||
export const formatBytes = (bytes: number): string => {
|
||||
@@ -44,10 +51,15 @@ export const removeSpecialEditionFromName = (name: string) =>
|
||||
export const removeDuplicateSpaces = (name: string) =>
|
||||
name.replace(/\s{2,}/g, " ");
|
||||
|
||||
export const replaceUnderscoreWithSpace = (name: string) =>
|
||||
name.replace(/_/g, " ");
|
||||
|
||||
export const formatName = pipe<string>(
|
||||
removeReleaseYearFromName,
|
||||
removeSymbolsFromName,
|
||||
removeSpecialEditionFromName,
|
||||
replaceUnderscoreWithSpace,
|
||||
(str) => str.replace(/DIRECTOR'S CUT/g, ""),
|
||||
removeSymbolsFromName,
|
||||
removeDuplicateSpaces,
|
||||
(str) => str.trim()
|
||||
);
|
||||
|
||||
@@ -10,6 +10,8 @@ export type GameStatus =
|
||||
|
||||
export type GameShop = "steam" | "epic";
|
||||
|
||||
export type FriendRequestAction = "ACCEPTED" | "REFUSED" | "CANCEL";
|
||||
|
||||
export interface SteamGenre {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -134,6 +136,12 @@ export interface Game {
|
||||
|
||||
export type LibraryGame = Omit<Game, "repacks">;
|
||||
|
||||
export interface Collection {
|
||||
id: number;
|
||||
title: string;
|
||||
games: Game[];
|
||||
}
|
||||
|
||||
export interface GameRunning {
|
||||
id: number;
|
||||
title: string;
|
||||
@@ -269,14 +277,27 @@ export interface UserDetails {
|
||||
profileImageUrl: string | null;
|
||||
}
|
||||
|
||||
export interface UserFriend {
|
||||
id: string;
|
||||
displayName: string;
|
||||
profileImageUrl: string | null;
|
||||
}
|
||||
|
||||
export interface FriendRequest {
|
||||
id: string;
|
||||
displayName: string;
|
||||
profileImageUrl: string | null;
|
||||
type: "SENT" | "RECEIVED";
|
||||
}
|
||||
|
||||
export interface UserProfile {
|
||||
id: string;
|
||||
displayName: string;
|
||||
username: string;
|
||||
profileImageUrl: string | null;
|
||||
totalPlayTimeInSeconds: number;
|
||||
libraryGames: UserGame[];
|
||||
recentGames: UserGame[];
|
||||
friends: UserFriend[];
|
||||
}
|
||||
|
||||
export interface DownloadSource {
|
||||
|
||||
@@ -30,6 +30,16 @@ class Downloader:
|
||||
self.torrent_handles[game_id] = None
|
||||
self.downloading_game_id = -1
|
||||
|
||||
def abort_session(self):
|
||||
for game_id in self.torrent_handles:
|
||||
torrent_handle = self.torrent_handles[game_id]
|
||||
torrent_handle.pause()
|
||||
self.session.remove_torrent(torrent_handle)
|
||||
|
||||
self.session.abort()
|
||||
self.torrent_handles = {}
|
||||
self.downloading_game_id = -1
|
||||
|
||||
def get_download_status(self):
|
||||
if self.downloading_game_id == -1:
|
||||
return None
|
||||
|
||||
@@ -2,16 +2,20 @@ import sys
|
||||
from http.server import HTTPServer, BaseHTTPRequestHandler
|
||||
import json
|
||||
import urllib.parse
|
||||
import psutil
|
||||
from downloader import Downloader
|
||||
|
||||
torrent_port = sys.argv[1]
|
||||
http_port = sys.argv[2]
|
||||
rpc_password = sys.argv[3]
|
||||
initial_download = json.loads(urllib.parse.unquote(sys.argv[4]))
|
||||
start_download_payload = sys.argv[4]
|
||||
|
||||
downloader = Downloader(torrent_port)
|
||||
downloader = None
|
||||
|
||||
downloader.start_download(initial_download['game_id'], initial_download['magnet'], initial_download['save_path'])
|
||||
if start_download_payload:
|
||||
initial_download = json.loads(urllib.parse.unquote(start_download_payload))
|
||||
downloader = Downloader(torrent_port)
|
||||
downloader.start_download(initial_download['game_id'], initial_download['magnet'], initial_download['save_path'])
|
||||
|
||||
class Handler(BaseHTTPRequestHandler):
|
||||
rpc_password_header = 'x-hydra-rpc-password'
|
||||
@@ -30,11 +34,28 @@ class Handler(BaseHTTPRequestHandler):
|
||||
status = downloader.get_download_status()
|
||||
|
||||
self.wfile.write(json.dumps(status).encode('utf-8'))
|
||||
if self.path == "/healthcheck":
|
||||
|
||||
elif self.path == "/healthcheck":
|
||||
self.send_response(200)
|
||||
self.end_headers()
|
||||
|
||||
elif self.path == "/process-list":
|
||||
if self.headers.get(self.rpc_password_header) != rpc_password:
|
||||
self.send_response(401)
|
||||
self.end_headers()
|
||||
return
|
||||
|
||||
process_list = [proc.info for proc in psutil.process_iter(['exe', 'pid', 'username'])]
|
||||
|
||||
self.send_response(200)
|
||||
self.send_header("Content-type", "application/json")
|
||||
self.end_headers()
|
||||
|
||||
self.wfile.write(json.dumps(process_list).encode('utf-8'))
|
||||
|
||||
def do_POST(self):
|
||||
global downloader
|
||||
|
||||
if self.path == "/action":
|
||||
if self.headers.get(self.rpc_password_header) != rpc_password:
|
||||
self.send_response(401)
|
||||
@@ -45,13 +66,19 @@ class Handler(BaseHTTPRequestHandler):
|
||||
post_data = self.rfile.read(content_length)
|
||||
data = json.loads(post_data.decode('utf-8'))
|
||||
|
||||
if downloader is None:
|
||||
downloader = Downloader(torrent_port)
|
||||
|
||||
if data['action'] == 'start':
|
||||
downloader.start_download(data['game_id'], data['magnet'], data['save_path'])
|
||||
elif data['action'] == 'pause':
|
||||
downloader.pause_download(data['game_id'])
|
||||
elif data['action'] == 'cancel':
|
||||
downloader.cancel_download(data['game_id'])
|
||||
|
||||
elif data['action'] == 'kill-torrent':
|
||||
downloader.abort_session()
|
||||
downloader = None
|
||||
|
||||
self.send_response(200)
|
||||
self.end_headers()
|
||||
|
||||
|
||||
37
yarn.lock
37
yarn.lock
@@ -2433,6 +2433,13 @@
|
||||
modern-ahocorasick "^1.0.0"
|
||||
picocolors "^1.0.0"
|
||||
|
||||
"@vanilla-extract/dynamic@^2.1.1":
|
||||
version "2.1.1"
|
||||
resolved "https://registry.yarnpkg.com/@vanilla-extract/dynamic/-/dynamic-2.1.1.tgz#bc93a577b127a7dcb6f254973d13a863029a7faf"
|
||||
integrity sha512-iqf736036ujEIKsIq28UsBEMaLC2vR2DhwKyrG3NDb/fRy9qL9FKl1TqTtBV4daU30Uh3saeik4vRzN8bzQMbw==
|
||||
dependencies:
|
||||
"@vanilla-extract/private" "^1.0.5"
|
||||
|
||||
"@vanilla-extract/integration@^7.1.3":
|
||||
version "7.1.4"
|
||||
resolved "https://registry.npmjs.org/@vanilla-extract/integration/-/integration-7.1.4.tgz"
|
||||
@@ -2456,6 +2463,11 @@
|
||||
resolved "https://registry.npmjs.org/@vanilla-extract/private/-/private-1.0.4.tgz"
|
||||
integrity sha512-8FGD6AejeC/nXcblgNCM5rnZb9KXa4WNkR03HCWtdJBpANjTgjHEglNLFnhuvdQ78tC6afaxBPI+g7F2NX3tgg==
|
||||
|
||||
"@vanilla-extract/private@^1.0.5":
|
||||
version "1.0.5"
|
||||
resolved "https://registry.yarnpkg.com/@vanilla-extract/private/-/private-1.0.5.tgz#8c08ac4851f4cc89a3dcdb858d8938e69b1481c4"
|
||||
integrity sha512-6YXeOEKYTA3UV+RC8DeAjFk+/okoNz/h88R+McnzA2zpaVqTR/Ep+vszkWYlGBcMNO7vEkqbq5nT/JMMvhi+tw==
|
||||
|
||||
"@vanilla-extract/recipes@^0.5.2":
|
||||
version "0.5.2"
|
||||
resolved "https://registry.npmjs.org/@vanilla-extract/recipes/-/recipes-0.5.2.tgz"
|
||||
@@ -2665,6 +2677,14 @@ aria-query@^5.3.0:
|
||||
dependencies:
|
||||
dequal "^2.0.3"
|
||||
|
||||
aria2@^4.1.2:
|
||||
version "4.1.2"
|
||||
resolved "https://registry.yarnpkg.com/aria2/-/aria2-4.1.2.tgz#0ecbc50beea82856c88b4de71dac336154f67362"
|
||||
integrity sha512-qTBr2RY8RZQmiUmbj2KXFvkErNxU4aTHZszszzwhE8svy2PEVX+IYR/c4Rp9Tuw4QkeU8cylGy6McV6Yl8i7Qw==
|
||||
dependencies:
|
||||
node-fetch "^2.6.1"
|
||||
ws "^7.4.0"
|
||||
|
||||
array-buffer-byte-length@^1.0.1:
|
||||
version "1.0.1"
|
||||
resolved "https://registry.npmjs.org/array-buffer-byte-length/-/array-buffer-byte-length-1.0.1.tgz"
|
||||
@@ -5813,7 +5833,7 @@ node-domexception@^1.0.0:
|
||||
resolved "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz"
|
||||
integrity sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==
|
||||
|
||||
node-fetch@^2.6.7:
|
||||
node-fetch@^2.6.1, node-fetch@^2.6.7:
|
||||
version "2.7.0"
|
||||
resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-2.7.0.tgz#d0f0fa6e3e2dc1d27efcd8ad99d550bda94d187d"
|
||||
integrity sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==
|
||||
@@ -6311,11 +6331,6 @@ proxy-from-env@^1.1.0:
|
||||
resolved "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz"
|
||||
integrity sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==
|
||||
|
||||
ps-list@^8.1.1:
|
||||
version "8.1.1"
|
||||
resolved "https://registry.npmjs.org/ps-list/-/ps-list-8.1.1.tgz"
|
||||
integrity sha512-OPS9kEJYVmiO48u/B9qneqhkMvgCxT+Tm28VCEJpheTpl8cJ0ffZRRNgS5mrQRTrX5yRTpaJ+hRDeefXYmmorQ==
|
||||
|
||||
psl@^1.1.33:
|
||||
version "1.9.0"
|
||||
resolved "https://registry.npmjs.org/psl/-/psl-1.9.0.tgz"
|
||||
@@ -6999,6 +7014,11 @@ strtok3@^7.0.0:
|
||||
"@tokenizer/token" "^0.3.0"
|
||||
peek-readable "^5.0.0"
|
||||
|
||||
sudo-prompt@^9.2.1:
|
||||
version "9.2.1"
|
||||
resolved "https://registry.yarnpkg.com/sudo-prompt/-/sudo-prompt-9.2.1.tgz#77efb84309c9ca489527a4e749f287e6bdd52afd"
|
||||
integrity sha512-Mu7R0g4ig9TUuGSxJavny5Rv0egCEtpZRNMrZaYS1vxkiIxGiGUwoezU3LazIQ+KE04hTrTfNPgxU5gzi7F5Pw==
|
||||
|
||||
sumchecker@^3.0.1:
|
||||
version "3.0.1"
|
||||
resolved "https://registry.npmjs.org/sumchecker/-/sumchecker-3.0.1.tgz"
|
||||
@@ -7609,6 +7629,11 @@ wrappy@1:
|
||||
resolved "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz"
|
||||
integrity sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==
|
||||
|
||||
ws@^7.4.0:
|
||||
version "7.5.10"
|
||||
resolved "https://registry.yarnpkg.com/ws/-/ws-7.5.10.tgz#58b5c20dc281633f6c19113f39b349bd8bd558d9"
|
||||
integrity sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ==
|
||||
|
||||
ws@^8.16.0:
|
||||
version "8.17.0"
|
||||
resolved "https://registry.npmjs.org/ws/-/ws-8.17.0.tgz"
|
||||
|
||||
Reference in New Issue
Block a user