Compare commits

..

66 Commits

Author SHA1 Message Date
Chubby Granny Chaser
6fce60f9f7 ci: increasing version 2024-07-05 17:10:04 +01:00
Chubby Granny Chaser
c8aa9fd681 Merge branch 'main' of github.com:hydralauncher/hydra 2024-07-05 17:09:43 +01:00
Chubby Granny Chaser
0f12dfae88 ci: increasing version 2024-07-05 17:08:27 +01:00
Zamitto
be48306ca2 Merge pull request #768 from hydralauncher/hyd-229-improve-visibility-of-update-message
feat: add color to update icon and notify when update is ready to install
2024-07-05 12:45:53 -03:00
Zamitto
ab81e21341 feat: update font size 2024-07-05 12:38:53 -03:00
Zamitto
b7f94102da feat: add version string to notification 2024-07-05 12:30:34 -03:00
Zamitto
9e7b27afe6 feat: undo change 2024-07-05 12:22:13 -03:00
Zamitto
c24523e8e6 feat: update i18n 2024-07-05 12:18:37 -03:00
Zamitto
b58330ed35 feat: undo change 2024-07-05 12:13:47 -03:00
Zamitto
dde40f39e9 Merge branch 'main' into hyd-229-improve-visibility-of-update-message 2024-07-05 12:10:30 -03:00
Zamitto
d2b3017de9 feat: show notification only when update is ready to install 2024-07-05 12:10:19 -03:00
Chubby Granny Chaser
64f4dad7cc Merge pull request #783 from hydralauncher/fix/replacing-underscore-with-whitespace
feat: replacing underscore with whitespace
2024-07-05 16:04:49 +01:00
Chubby Granny Chaser
154d211b21 Merge branch 'main' into fix/replacing-underscore-with-whitespace 2024-07-05 15:54:42 +01:00
Chubby Granny Chaser
7905ef6c10 feat: replacing underscore with whitespace 2024-07-05 15:53:32 +01:00
Zamitto
b09f2c055f feat: creating notification for update available 2024-07-04 20:00:20 -03:00
Chubby Granny Chaser
2c5b3b4ffa Merge pull request #778 from hydralauncher/feature/adding-directors-cut-filter
Feature/adding directors cut filter
2024-07-04 23:41:07 +01:00
Chubby Granny Chaser
fdefc0c165 feat: adding directors cut filter 2024-07-04 23:14:09 +01:00
Chubby Granny Chaser
47ca2535e3 feat: adding directors cut filter 2024-07-04 23:12:20 +01:00
Chubby Granny Chaser
f706836a43 feat: adding directors cut filter 2024-07-04 23:11:21 +01:00
Chubby Granny Chaser
d8158bb80e Merge branch 'main' of github.com:hydralauncher/hydra into fix/adding-sorting-to-repacks-modal 2024-07-04 18:36:15 +01:00
Chubby Granny Chaser
4e422bdf91 feat: migrating download source validation to worker thread 2024-07-04 18:35:47 +01:00
Zamitto
4be3db8007 feat: add error logs 2024-07-03 18:03:11 -03:00
Zamitto
29b64237ed feat: remove old vbs file 2024-07-03 17:57:03 -03:00
Zamitto
d481164bf3 feat: add color to update icon and notify 2024-07-03 17:44:04 -03:00
Zamitto
138f33e0c3 Merge pull request #752 from hydralauncher/hyd-228-investigate-why-users-are-being-logged-out-when-updating
feat: prevent api calls when user is not logged in or auth is not loaded yet
2024-07-03 17:05:23 -03:00
Chubby Granny Chaser
be3c78f584 Merge branch 'main' into hyd-228-investigate-why-users-are-being-logged-out-when-updating 2024-07-03 20:52:44 +01:00
Chubby Granny Chaser
be1d9825d3 Merge pull request #755 from hydralauncher/feature/aria2-for-http-downloads
Feature/aria2 for http downloads
2024-07-03 20:52:34 +01:00
Zamitto
981116f221 Merge branch 'main' into hyd-228-investigate-why-users-are-being-logged-out-when-updating
# Conflicts:
#	src/main/events/user-preferences/auto-launch.ts
2024-07-03 16:32:25 -03:00
Chubby Granny Chaser
26aad178ee Merge branch 'main' into feature/aria2-for-http-downloads 2024-07-03 20:30:52 +01:00
Zamitto
56c8349899 Merge pull request #767 from hydralauncher/hyd-226-investigate-if-its-possible-to-use-psutil-to-list-processes
remove UAC; replace ps-list with psutil
2024-07-03 16:30:01 -03:00
Chubby Granny Chaser
0b2c407770 Merge branch 'main' into feature/aria2-for-http-downloads 2024-07-03 20:26:28 +01:00
Zamitto
d2e3d48ef8 Merge branch 'main' into hyd-226-investigate-if-its-possible-to-use-psutil-to-list-processes 2024-07-03 16:20:55 -03:00
Zamitto
153291f89f feat: apply suggestions 2024-07-03 16:10:23 -03:00
Zamitto
ae3daa4c79 fix: remove symbols from name before creating game shortcut 2024-07-03 15:52:25 -03:00
Zamitto
1397e3932d feat: remove pslist and use sudo-prompt to close game if needed 2024-07-03 15:31:56 -03:00
Zamitto
0f5db4f34e feat: crete kill-torrent 2024-07-03 12:06:26 -03:00
Zamitto
75c8f69e81 feat: get process list from rpc 2024-07-03 11:25:32 -03:00
Zamitto
aa253466a3 feat: refactor 2024-07-02 23:31:07 -03:00
Zamitto
b8bd786c45 feat: refactor 2024-07-02 15:42:23 -03:00
Zamitto
c9c585f820 Merge branch 'main' into hyd-228-investigate-why-users-are-being-logged-out-when-updating 2024-07-02 14:56:21 -03:00
Zamitto
9e11d6c098 feat: refactor hydra api 2024-07-02 14:56:01 -03:00
Zamitto
2f83c2c9da Merge pull request #739 from hydralauncher/fix/not-updating-i18n-in-main-process
fix: update i18n in updateUserPreferences and in hydra startup
2024-07-02 14:55:46 -03:00
Chubby Granny Chaser
dc94a886e6 fix: sorting repacks modal 2024-07-02 17:34:46 +01:00
Chubby Granny Chaser
7deabc4889 Merge branch 'main' into feature/aria2-for-http-downloads 2024-07-02 17:25:38 +01:00
Zamitto
e57200d024 Merge branch 'main' into fix/not-updating-i18n-in-main-process 2024-07-02 13:18:00 -03:00
Chubby Granny Chaser
7a13739d49 Merge pull request #747 from Carvalho286/main
Update translation.json
2024-07-02 17:16:51 +01:00
Chubby Granny Chaser
f8cbbc64f0 Merge branch 'feature/aria2-for-http-downloads' of github.com:hydralauncher/hydra into feature/aria2-for-http-downloads 2024-07-02 17:10:31 +01:00
Chubby Granny Chaser
9096eb5e0e fix: removing aria2 source folder 2024-07-02 17:10:04 +01:00
Chubby Granny Chaser
eebb5fec61 fix: removing aria2 source folder 2024-07-02 17:07:47 +01:00
Chubby Granny Chaser
88cfd0d095 Merge branch 'main' into feature/aria2-for-http-downloads 2024-07-02 17:06:58 +01:00
Chubby Granny Chaser
a43768ce67 feat: supporting queue using aria2 2024-07-02 17:06:30 +01:00
Chubby Granny Chaser
16a8c28935 feat: disabling bittorrent on aria2 2024-07-02 15:50:17 +01:00
Chubby Granny Chaser
1cc5a5b209 fix: adding real debrid real time tracking 2024-07-02 15:38:36 +01:00
Chubby Granny Chaser
a39082d326 feat: using aria2 for http downloads 2024-07-02 15:33:26 +01:00
Zamitto
0c1a75eedd poc: psutil for process watcher 2024-07-01 20:23:39 -03:00
Zamitto
dd23358a95 feat: prevent api calls when user is not logged in 2024-07-01 15:48:52 -03:00
Zamitto
8f00254dc2 Merge branch 'main' into fix/not-updating-i18n-in-main-process 2024-07-01 11:25:51 -03:00
Miguel Carvalho
449b34d3dd Update translation.json 2024-06-30 21:26:31 +01:00
Zamitto
9870213fff Merge pull request #732 from Lianela/main
Spanish translation little updates
2024-06-29 15:37:12 -03:00
Zamitto
de237b7c39 Merge branch 'main' into main 2024-06-29 14:47:18 -03:00
Zamitto
8a5d4e38b6 fix: update i18n in updateUserPreferences and in hydra startup 2024-06-29 14:34:30 -03:00
Zamitto
77152a32ab Merge pull request #736 from zxcsixx/patch-1
Update RU translation.json
2024-06-29 13:24:31 -03:00
Roman
c57c8dc477 Update RU translation.json
Fixed some mistakes
2024-06-29 17:39:05 +03:00
Lianela
455d80da3e Changed some strings
To make more friendly or better to understand some things, I change a few words
2024-06-29 01:10:56 -06:00
Lianela
d61c535c6f Fixed little typo
Nothing interesting, just a fix...
2024-06-29 01:03:00 -06:00
Lianela
23308a7780 added translation for file verification message 2024-06-29 01:01:29 -06:00
59 changed files with 699 additions and 511 deletions

1
.gitignore vendored
View File

@@ -1,6 +1,7 @@
.vscode .vscode
node_modules node_modules
hydra-download-manager/ hydra-download-manager/
aria2/
fastlist.exe fastlist.exe
__pycache__ __pycache__
dist dist

View File

@@ -83,7 +83,7 @@ Puedes unirte a nuestra conversación y discusiones en nuestro canal de [Telegra
### Haz un fork y clona tu repositorio ### 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` 2. Clona el código forkeado `git clone https://github.com/tu_nombredeusuario/hydra`
3. Crea una nueva rama 3. Crea una nueva rama
4. Sube tus commits 4. Sube tus commits

View File

@@ -3,12 +3,10 @@ productName: Hydra
directories: directories:
buildResources: build buildResources: build
extraResources: extraResources:
- aria2
- hydra-download-manager - hydra-download-manager
- seeds - 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: node_modules/create-desktop-shortcuts/src/windows.vbs
- from: resources/hydralauncher.vbs
files: files:
- "!**/.vscode/*" - "!**/.vscode/*"
- "!src/*" - "!src/*"
@@ -20,7 +18,6 @@ asarUnpack:
- resources/** - resources/**
win: win:
executableName: Hydra executableName: Hydra
requestedExecutionLevel: requireAdministrator
target: target:
- nsis - nsis
- portable - portable
@@ -33,7 +30,6 @@ nsis:
allowToChangeInstallationDirectory: true allowToChangeInstallationDirectory: true
portable: portable:
artifactName: ${name}-${version}-portable.${ext} artifactName: ${name}-${version}-portable.${ext}
requestExecutionLevel: admin
mac: mac:
entitlementsInherit: build/entitlements.mac.plist entitlementsInherit: build/entitlements.mac.plist
extendInfo: extendInfo:

View File

@@ -1,6 +1,6 @@
{ {
"name": "hydralauncher", "name": "hydralauncher",
"version": "2.0.2", "version": "2.0.3",
"description": "Hydra", "description": "Hydra",
"main": "./out/main/index.js", "main": "./out/main/index.js",
"author": "Los Broxas", "author": "Los Broxas",
@@ -23,7 +23,7 @@
"start": "electron-vite preview", "start": "electron-vite preview",
"dev": "electron-vite dev", "dev": "electron-vite dev",
"build": "npm run typecheck && electron-vite build", "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:unpack": "npm run build && electron-builder --dir",
"build:win": "electron-vite build && electron-builder --win", "build:win": "electron-vite build && electron-builder --win",
"build:mac": "electron-vite build && electron-builder --mac", "build:mac": "electron-vite build && electron-builder --mac",
@@ -41,6 +41,7 @@
"@sentry/electron": "^5.1.0", "@sentry/electron": "^5.1.0",
"@vanilla-extract/css": "^1.14.2", "@vanilla-extract/css": "^1.14.2",
"@vanilla-extract/recipes": "^0.5.2", "@vanilla-extract/recipes": "^0.5.2",
"aria2": "^4.1.2",
"auto-launch": "^5.0.6", "auto-launch": "^5.0.6",
"axios": "^1.6.8", "axios": "^1.6.8",
"better-sqlite3": "^9.5.0", "better-sqlite3": "^9.5.0",
@@ -65,11 +66,11 @@
"lottie-react": "^2.4.0", "lottie-react": "^2.4.0",
"parse-torrent": "^11.0.16", "parse-torrent": "^11.0.16",
"piscina": "^4.5.1", "piscina": "^4.5.1",
"ps-list": "^8.1.1",
"react-i18next": "^14.1.0", "react-i18next": "^14.1.0",
"react-loading-skeleton": "^3.4.0", "react-loading-skeleton": "^3.4.0",
"react-redux": "^9.1.1", "react-redux": "^9.1.1",
"react-router-dom": "^6.22.3", "react-router-dom": "^6.22.3",
"sudo-prompt": "^9.2.1",
"typeorm": "^0.3.20", "typeorm": "^0.3.20",
"user-agents": "^1.1.193", "user-agents": "^1.1.193",
"yaml": "^2.4.1", "yaml": "^2.4.1",

50
postinstall.cjs Normal file
View 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();

View File

@@ -3,3 +3,4 @@ cx_Freeze
cx_Logging; sys_platform == 'win32' cx_Logging; sys_platform == 'win32'
lief; sys_platform == 'win32' lief; sys_platform == 'win32'
pywin32; sys_platform == 'win32' pywin32; sys_platform == 'win32'
psutil

View File

@@ -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

View File

@@ -199,7 +199,9 @@
"game_ready_to_install": "{{title}} is ready to install", "game_ready_to_install": "{{title}} is ready to install",
"repack_list_updated": "Repack list updated", "repack_list_updated": "Repack list updated",
"repack_count_one": "{{count}} repack added", "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": { "system_tray": {
"open": "Open Hydra", "open": "Open Hydra",

View File

@@ -36,7 +36,8 @@
"no_downloads_in_progress": "Sin descargas en progreso", "no_downloads_in_progress": "Sin descargas en progreso",
"downloading_metadata": "Descargando metadatos de {{title}}…", "downloading_metadata": "Descargando metadatos de {{title}}…",
"downloading": "Descargando {{title}}… ({{percentage}} completado) - Finalizando {{eta}} - {{speed}}", "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": { "catalogue": {
"next_page": "Siguiente página", "next_page": "Siguiente página",
@@ -92,7 +93,7 @@
"screenshot": "Captura {{number}}", "screenshot": "Captura {{number}}",
"open_screenshot": "Abrir captura {{number}}", "open_screenshot": "Abrir captura {{number}}",
"download_settings": "Ajustes de descarga", "download_settings": "Ajustes de descarga",
"downloader": "Descargador", "downloader": "Método de descarga",
"select_executable": "Seleccionar", "select_executable": "Seleccionar",
"no_executable_selected": "No se seleccionó un ejecutable", "no_executable_selected": "No se seleccionó un ejecutable",
"open_folder": "Abrir carpeta", "open_folder": "Abrir carpeta",
@@ -144,7 +145,8 @@
"downloads_completed": "Completado", "downloads_completed": "Completado",
"queued": "En cola", "queued": "En cola",
"no_downloads_title": "Esto está tan... vacío", "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": { "settings": {
"downloads_path": "Ruta de descarga", "downloads_path": "Ruta de descarga",
@@ -161,7 +163,7 @@
"language": "Idioma", "language": "Idioma",
"real_debrid_api_token": "Token API", "real_debrid_api_token": "Token API",
"enable_real_debrid": "Activar Real-Debrid", "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_invalid_token": "Token de API inválido",
"real_debrid_api_token_hint": "Puedes obtener tu clave de API <0>aquí</0>", "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", "real_debrid_free_account_error": "La cuenta \"{{username}}\" es una cuenta gratuita. Por favor, suscríbete a Real-Debrid",
@@ -197,7 +199,8 @@
"game_ready_to_install": "{{title}} está listo para instalarse", "game_ready_to_install": "{{title}} está listo para instalarse",
"repack_list_updated": "Lista de repacks actualizadas", "repack_list_updated": "Lista de repacks actualizadas",
"repack_count_one": "{{count}} repack ha sido añadido", "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"
}, },
"system_tray": { "system_tray": {
"open": "Abrir Hydra", "open": "Abrir Hydra",

View File

@@ -1,9 +1,9 @@
{ {
"app": { "app": {
"successfully_signed_in": "Logado com sucesso" "successfully_signed_in": "Autenticado com sucesso"
}, },
"home": { "home": {
"featured": "Destaque", "featured": "Destaques",
"trending": "Populares", "trending": "Populares",
"surprise_me": "Surpreenda-me", "surprise_me": "Surpreenda-me",
"no_results": "Nenhum resultado encontrado" "no_results": "Nenhum resultado encontrado"
@@ -195,7 +195,9 @@
"game_ready_to_install": "{{title}} está pronto para ser instalado", "game_ready_to_install": "{{title}} está pronto para ser instalado",
"repack_list_updated": "Lista de repacks atualizada", "repack_list_updated": "Lista de repacks atualizada",
"repack_count_one": "{{count}} novo repack", "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": { "system_tray": {
"open": "Abrir Hydra", "open": "Abrir Hydra",

View File

@@ -197,7 +197,8 @@
"game_ready_to_install": "{{title}} готова к установке", "game_ready_to_install": "{{title}} готова к установке",
"repack_list_updated": "Список репаков обновлен", "repack_list_updated": "Список репаков обновлен",
"repack_count_one": "{{count}} репак добавлен", "repack_count_one": "{{count}} репак добавлен",
"repack_count_other": "{{count}} репаков добавлено" "repack_count_other": "{{count}} репаков добавлено",
"new_update_available": "Доступна версия {{version}}"
}, },
"system_tray": { "system_tray": {
"open": "Открыть Hydra", "open": "Открыть Hydra",
@@ -228,7 +229,7 @@
"no_recent_activity_description": "Вы давно ни во что не играли. Пора это изменить!", "no_recent_activity_description": "Вы давно ни во что не играли. Пора это изменить!",
"display_name": "Отображаемое имя", "display_name": "Отображаемое имя",
"saving": "Сохранение", "saving": "Сохранение",
"save": "Сохранено", "save": "Сохранить",
"edit_profile": "Редактировать Профиль", "edit_profile": "Редактировать Профиль",
"saved_successfully": "Успешно сохранено", "saved_successfully": "Успешно сохранено",
"try_again": "Пожалуйста, попробуйте ещё раз", "try_again": "Пожалуйста, попробуйте ещё раз",

View File

@@ -14,12 +14,3 @@ export const logsPath = path.join(app.getPath("appData"), "hydra", "logs");
export const seedsPath = app.isPackaged export const seedsPath = app.isPackaged
? path.join(process.resourcesPath, "seeds") ? path.join(process.resourcesPath, "seeds")
: path.join(__dirname, "..", "..", "seeds"); : path.join(__dirname, "..", "..", "seeds");
export const windowsStartupPath = path.join(
app.getPath("appData"),
"Microsoft",
"Windows",
"Start Menu",
"Programs",
"Startup"
);

80
src/main/declaration.d.ts vendored Normal file
View 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;
}
}

View File

@@ -1,6 +1,6 @@
import { registerEvent } from "../register-event"; import { registerEvent } from "../register-event";
import * as Sentry from "@sentry/electron/main"; 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 { dataSource } from "@main/data-source";
import { DownloadQueue, Game, UserAuth } from "@main/entity"; import { DownloadQueue, Game, UserAuth } from "@main/entity";
@@ -24,11 +24,11 @@ const signOut = async (_event: Electron.IpcMainInvokeEvent) => {
Sentry.setUser(null); Sentry.setUser(null);
/* Disconnects libtorrent */ /* Disconnects libtorrent */
TorrentDownloader.kill(); PythonInstance.killTorrent();
await Promise.all([ await Promise.all([
databaseOperations, databaseOperations,
HydraApi.post("/auth/logout").catch(), HydraApi.post("/auth/logout").catch(() => {}),
]); ]);
}; };

View File

@@ -3,6 +3,7 @@ import { registerEvent } from "../register-event";
import updater, { UpdateInfo } from "electron-updater"; import updater, { UpdateInfo } from "electron-updater";
import { WindowManager } from "@main/services"; import { WindowManager } from "@main/services";
import { app } from "electron"; import { app } from "electron";
import { publishNotificationUpdateReadyToInstall } from "@main/services/notifications";
const { autoUpdater } = updater; const { autoUpdater } = updater;
@@ -20,13 +21,17 @@ const mockValuesForDebug = () => {
sendEvent({ type: "update-downloaded" }); sendEvent({ type: "update-downloaded" });
}; };
const newVersionInfo = { version: "" };
const checkForUpdates = async (_event: Electron.IpcMainInvokeEvent) => { const checkForUpdates = async (_event: Electron.IpcMainInvokeEvent) => {
autoUpdater autoUpdater
.once("update-available", (info: UpdateInfo) => { .once("update-available", (info: UpdateInfo) => {
sendEvent({ type: "update-available", info }); sendEvent({ type: "update-available", info });
newVersionInfo.version = info.version;
}) })
.once("update-downloaded", () => { .once("update-downloaded", () => {
sendEvent({ type: "update-downloaded" }); sendEvent({ type: "update-downloaded" });
publishNotificationUpdateReadyToInstall(newVersionInfo.version);
}); });
if (app.isPackaged) { if (app.isPackaged) {

View File

@@ -1,17 +1,12 @@
import { registerEvent } from "../register-event"; import { registerEvent } from "../register-event";
import axios from "axios";
import { downloadSourceRepository } from "@main/repository"; import { downloadSourceRepository } from "@main/repository";
import { downloadSourceSchema } from "../helpers/validators";
import { RepacksManager } from "@main/services"; import { RepacksManager } from "@main/services";
import { downloadSourceWorker } from "@main/workers";
const validateDownloadSource = async ( const validateDownloadSource = async (
_event: Electron.IpcMainInvokeEvent, _event: Electron.IpcMainInvokeEvent,
url: string url: string
) => { ) => {
const response = await axios.get(url);
const source = downloadSourceSchema.parse(response.data);
const existingSource = await downloadSourceRepository.findOne({ const existingSource = await downloadSourceRepository.findOne({
where: { url }, where: { url },
}); });
@@ -21,14 +16,12 @@ const validateDownloadSource = async (
const repacks = RepacksManager.repacks; const repacks = RepacksManager.repacks;
const existingUris = source.downloads return downloadSourceWorker.run(
.flatMap((download) => download.uris) { url, repacks },
.filter((uri) => repacks.some((repack) => repack.magnet === uri)); {
name: "validateDownloadSource",
return { }
name: source.name, );
downloadCount: source.downloads.length - existingUris.length,
};
}; };
registerEvent("validateDownloadSource", validateDownloadSource); registerEvent("validateDownloadSource", validateDownloadSource);

View File

@@ -22,7 +22,6 @@ import "./library/open-game-installer-path";
import "./library/update-executable-path"; import "./library/update-executable-path";
import "./library/remove-game"; import "./library/remove-game";
import "./library/remove-game-from-library"; import "./library/remove-game-from-library";
import "./misc/is-user-logged-in";
import "./misc/open-external"; import "./misc/open-external";
import "./misc/show-open-dialog"; import "./misc/show-open-dialog";
import "./torrenting/cancel-game-download"; import "./torrenting/cancel-game-download";

View File

@@ -53,18 +53,7 @@ const addGameToLibrary = async (
const game = await gameRepository.findOne({ where: { objectID } }); const game = await gameRepository.findOne({ where: { objectID } });
createGame(game!).then((response) => { createGame(game!);
const {
id: remoteId,
playTimeInMilliseconds,
lastTimePlayed,
} = response.data;
gameRepository.update(
{ objectID },
{ remoteId, playTimeInMilliseconds, lastTimePlayed }
);
});
}); });
}; };

View File

@@ -1,39 +1,45 @@
import path from "node:path";
import { gameRepository } from "@main/repository"; import { gameRepository } from "@main/repository";
import { getProcesses } from "@main/helpers";
import { registerEvent } from "../register-event"; 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 ( const closeGame = async (
_event: Electron.IpcMainInvokeEvent, _event: Electron.IpcMainInvokeEvent,
gameId: number gameId: number
) => { ) => {
const processes = await getProcesses(); const processes = await PythonInstance.getProcessList();
const game = await gameRepository.findOne({ const game = await gameRepository.findOne({
where: { id: gameId, isDeleted: false }, where: { id: gameId, isDeleted: false },
}); });
if (!game) return false; if (!game) return;
const executablePath = game.executablePath!;
const basename = path.win32.basename(executablePath);
const basenameWithoutExtension = path.win32.basename(
executablePath,
path.extname(executablePath)
);
const gameProcess = processes.find((runningProcess) => { const gameProcess = processes.find((runningProcess) => {
if (process.platform === "win32") { return runningProcess.exe === game.executablePath;
return runningProcess.name === basename;
}
return [basename, basenameWithoutExtension].includes(runningProcess.name);
}); });
if (gameProcess) return process.kill(gameProcess.pid); if (gameProcess) {
return false; try {
process.kill(gameProcess.pid);
} catch (err) {
sudo.exec(
getKillCommand(gameProcess.pid),
{ name: app.getName() },
(error, _stdout, _stderr) => {
logger.error(error);
}
);
}
}
}; };
registerEvent("closeGame", closeGame); registerEvent("closeGame", closeGame);

View File

@@ -4,6 +4,7 @@ import { IsNull, Not } from "typeorm";
import createDesktopShortcut from "create-desktop-shortcuts"; import createDesktopShortcut from "create-desktop-shortcuts";
import path from "node:path"; import path from "node:path";
import { app } from "electron"; import { app } from "electron";
import { removeSymbolsFromName } from "@shared";
const createGameShortcut = async ( const createGameShortcut = async (
_event: Electron.IpcMainInvokeEvent, _event: Electron.IpcMainInvokeEvent,
@@ -22,7 +23,7 @@ const createGameShortcut = async (
const options = { const options = {
filePath, filePath,
name: game.title, name: removeSymbolsFromName(game.title),
}; };
return createDesktopShortcut({ return createDesktopShortcut({

View File

@@ -20,7 +20,7 @@ const removeRemoveGameFromLibrary = async (gameId: number) => {
const game = await gameRepository.findOne({ where: { id: gameId } }); const game = await gameRepository.findOne({ where: { id: gameId } });
if (game?.remoteId) { if (game?.remoteId) {
HydraApi.delete(`/games/${game.remoteId}`); HydraApi.delete(`/games/${game.remoteId}`).catch(() => {});
} }
}; };

View File

@@ -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);

View File

@@ -3,7 +3,7 @@ import * as Sentry from "@sentry/electron/main";
import { HydraApi } from "@main/services"; import { HydraApi } from "@main/services";
import { UserProfile } from "@types"; import { UserProfile } from "@types";
import { userAuthRepository } from "@main/repository"; import { userAuthRepository } from "@main/repository";
import { logger } from "@main/services"; import { UserNotLoggedInError } from "@shared";
const getMe = async ( const getMe = async (
_event: Electron.IpcMainInvokeEvent _event: Electron.IpcMainInvokeEvent
@@ -27,7 +27,10 @@ const getMe = async (
return me; return me;
}) })
.catch((err) => { .catch((err) => {
logger.error("getMe", err.message); if (err instanceof UserNotLoggedInError) {
return null;
}
return userAuthRepository.findOne({ where: { id: 1 } }); return userAuthRepository.findOne({ where: { id: 1 } });
}); });
}; };

View File

@@ -26,9 +26,11 @@ const updateProfile = async (
_event: Electron.IpcMainInvokeEvent, _event: Electron.IpcMainInvokeEvent,
displayName: string, displayName: string,
newProfileImagePath: string | null newProfileImagePath: string | null
): Promise<UserProfile> => { ) => {
if (!newProfileImagePath) { if (!newProfileImagePath) {
return (await patchUserProfile(displayName)).data; return patchUserProfile(displayName).then(
(response) => response.data as UserProfile
);
} }
const stats = fs.statSync(newProfileImagePath); const stats = fs.statSync(newProfileImagePath);
@@ -51,11 +53,11 @@ const updateProfile = async (
}); });
return profileImageUrl; return profileImageUrl;
}) })
.catch(() => { .catch(() => undefined);
return undefined;
});
return (await patchUserProfile(displayName, profileImageUrl)).data; return patchUserProfile(displayName, profileImageUrl).then(
(response) => response.data as UserProfile
);
}; };
registerEvent("updateProfile", updateProfile); registerEvent("updateProfile", updateProfile);

View File

@@ -95,18 +95,7 @@ const startGameDownload = async (
}, },
}); });
createGame(updatedGame!).then((response) => { createGame(updatedGame!);
const {
id: remoteId,
playTimeInMilliseconds,
lastTimePlayed,
} = response.data;
gameRepository.update(
{ objectID },
{ remoteId, playTimeInMilliseconds, lastTimePlayed }
);
});
await downloadQueueRepository.delete({ game: { id: updatedGame!.id } }); await downloadQueueRepository.delete({ game: { id: updatedGame!.id } });
await downloadQueueRepository.insert({ game: { id: updatedGame!.id } }); await downloadQueueRepository.insert({ game: { id: updatedGame!.id } });

View File

@@ -1,9 +1,18 @@
import { windowsStartupPath } from "@main/constants";
import { registerEvent } from "../register-event"; import { registerEvent } from "../register-event";
import AutoLaunch from "auto-launch"; import AutoLaunch from "auto-launch";
import { app } from "electron"; import { app } from "electron";
import path from "path";
import fs from "node:fs"; 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 ( const autoLaunch = async (
_event: Electron.IpcMainInvokeEvent, _event: Electron.IpcMainInvokeEvent,
@@ -15,23 +24,18 @@ const autoLaunch = async (
name: app.getName(), name: app.getName(),
}); });
if (process.platform == "win32") { if (enabled) {
const destination = path.join(windowsStartupPath, "Hydra.vbs"); appLauncher.enable().catch((err) => {
logger.error(err);
if (enabled) { });
const scriptPath = path.join(process.resourcesPath, "hydralauncher.vbs");
fs.copyFileSync(scriptPath, destination);
} else {
appLauncher.disable().catch();
fs.rmSync(destination);
}
} else { } else {
if (enabled) { if (process.platform == "win32") {
appLauncher.enable().catch(); fs.rm(path.join(windowsStartupPath, "Hydra.vbs"), () => {});
} else {
appLauncher.disable().catch();
} }
appLauncher.disable().catch((err) => {
logger.error(err);
});
} }
}; };

View File

@@ -2,17 +2,23 @@ import { userPreferencesRepository } from "@main/repository";
import { registerEvent } from "../register-event"; import { registerEvent } from "../register-event";
import type { UserPreferences } from "@types"; import type { UserPreferences } from "@types";
import i18next from "i18next";
const updateUserPreferences = async ( const updateUserPreferences = async (
_event: Electron.IpcMainInvokeEvent, _event: Electron.IpcMainInvokeEvent,
preferences: Partial<UserPreferences> preferences: Partial<UserPreferences>
) => ) => {
userPreferencesRepository.upsert( if (preferences.language) {
i18next.changeLanguage(preferences.language);
}
return userPreferencesRepository.upsert(
{ {
id: 1, id: 1,
...preferences, ...preferences,
}, },
["id"] ["id"]
); );
};
registerEvent("updateUserPreferences", updateUserPreferences); registerEvent("updateUserPreferences", updateUserPreferences);

View File

@@ -57,5 +57,4 @@ export const requestWebPage = async (url: string) => {
.then((response) => response.data); .then((response) => response.data);
}; };
export * from "./ps";
export * from "./download-source"; export * from "./download-source";

View File

@@ -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();
}
};

View File

@@ -5,7 +5,7 @@ import i18n from "i18next";
import path from "node:path"; import path from "node:path";
import url from "node:url"; import url from "node:url";
import { electronApp, optimizer } from "@electron-toolkit/utils"; 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 { dataSource } from "@main/data-source";
import * as resources from "@locales"; import * as resources from "@locales";
import { userPreferencesRepository } from "@main/repository"; import { userPreferencesRepository } from "@main/repository";
@@ -72,6 +72,10 @@ app.whenReady().then(async () => {
where: { id: 1 }, where: { id: 1 },
}); });
if (userPreferences?.language) {
i18n.changeLanguage(userPreferences.language);
}
WindowManager.createMainWindow(); WindowManager.createMainWindow();
WindowManager.createSystemTray(userPreferences?.language || "en"); WindowManager.createSystemTray(userPreferences?.language || "en");
}); });
@@ -116,7 +120,7 @@ app.on("window-all-closed", () => {
app.on("before-quit", () => { app.on("before-quit", () => {
/* Disconnects libtorrent */ /* Disconnects libtorrent */
TorrentDownloader.kill(); PythonInstance.kill();
}); });
app.on("activate", () => { app.on("activate", () => {

View File

@@ -1,4 +1,9 @@
import { DownloadManager, RepacksManager, startMainLoop } from "./services"; import {
DownloadManager,
RepacksManager,
PythonInstance,
startMainLoop,
} from "./services";
import { import {
downloadQueueRepository, downloadQueueRepository,
repackRepository, repackRepository,
@@ -12,18 +17,16 @@ import { MoreThan } from "typeorm";
import { HydraApi } from "./services/hydra-api"; import { HydraApi } from "./services/hydra-api";
import { uploadGamesBatch } from "./services/library-sync"; import { uploadGamesBatch } from "./services/library-sync";
startMainLoop();
const loadState = async (userPreferences: UserPreferences | null) => { const loadState = async (userPreferences: UserPreferences | null) => {
await RepacksManager.updateRepacks(); RepacksManager.updateRepacks();
import("./events"); import("./events");
if (userPreferences?.realDebridApiToken) if (userPreferences?.realDebridApiToken)
RealDebridClient.authorize(userPreferences?.realDebridApiToken); RealDebridClient.authorize(userPreferences?.realDebridApiToken);
HydraApi.setupApi().then(async () => { HydraApi.setupApi().then(() => {
if (HydraApi.isLoggedIn()) uploadGamesBatch(); uploadGamesBatch();
}); });
const [nextQueueItem] = await downloadQueueRepository.find({ 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); DownloadManager.startDownload(nextQueueItem.game);
} else {
PythonInstance.spawn();
}
startMainLoop();
const now = new Date(); const now = new Date();

View 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 }
);
};

View File

@@ -1,6 +1,6 @@
import { Game } from "@main/entity"; import { Game } from "@main/entity";
import { Downloader } from "@shared"; import { Downloader } from "@shared";
import { TorrentDownloader } from "./torrent-downloader"; import { PythonInstance } from "./python-instance";
import { WindowManager } from "../window-manager"; import { WindowManager } from "../window-manager";
import { downloadQueueRepository, gameRepository } from "@main/repository"; import { downloadQueueRepository, gameRepository } from "@main/repository";
import { publishDownloadCompleteNotification } from "../notifications"; import { publishDownloadCompleteNotification } from "../notifications";
@@ -16,7 +16,7 @@ export class DownloadManager {
if (this.currentDownloader === Downloader.RealDebrid) { if (this.currentDownloader === Downloader.RealDebrid) {
status = await RealDebridDownloader.getStatus(); status = await RealDebridDownloader.getStatus();
} else { } else {
status = await TorrentDownloader.getStatus(); status = await PythonInstance.getStatus();
} }
if (status) { if (status) {
@@ -63,9 +63,9 @@ export class DownloadManager {
static async pauseDownload() { static async pauseDownload() {
if (this.currentDownloader === Downloader.RealDebrid) { if (this.currentDownloader === Downloader.RealDebrid) {
RealDebridDownloader.pauseDownload(); await RealDebridDownloader.pauseDownload();
} else { } else {
await TorrentDownloader.pauseDownload(); await PythonInstance.pauseDownload();
} }
WindowManager.mainWindow?.setProgressBar(-1); WindowManager.mainWindow?.setProgressBar(-1);
@@ -77,16 +77,16 @@ export class DownloadManager {
RealDebridDownloader.startDownload(game); RealDebridDownloader.startDownload(game);
this.currentDownloader = Downloader.RealDebrid; this.currentDownloader = Downloader.RealDebrid;
} else { } else {
TorrentDownloader.startDownload(game); PythonInstance.startDownload(game);
this.currentDownloader = Downloader.Torrent; this.currentDownloader = Downloader.Torrent;
} }
} }
static async cancelDownload(gameId: number) { static async cancelDownload(gameId: number) {
if (this.currentDownloader === Downloader.RealDebrid) { if (this.currentDownloader === Downloader.RealDebrid) {
RealDebridDownloader.cancelDownload(); RealDebridDownloader.cancelDownload(gameId);
} else { } else {
TorrentDownloader.cancelDownload(gameId); PythonInstance.cancelDownload(gameId);
} }
WindowManager.mainWindow?.setProgressBar(-1); WindowManager.mainWindow?.setProgressBar(-1);
@@ -98,7 +98,7 @@ export class DownloadManager {
RealDebridDownloader.startDownload(game); RealDebridDownloader.startDownload(game);
this.currentDownloader = Downloader.RealDebrid; this.currentDownloader = Downloader.RealDebrid;
} else { } else {
TorrentDownloader.startDownload(game); PythonInstance.startDownload(game);
this.currentDownloader = Downloader.Torrent; this.currentDownloader = Downloader.Torrent;
} }
} }

View File

@@ -1,123 +1,68 @@
import path from "node:path"; import type { ChildProcess } from "node:child_process";
import fs from "node:fs";
import crypto from "node:crypto";
import axios, { type AxiosProgressEvent } from "axios";
import { app } from "electron";
import { logger } from "../logger"; import { logger } from "../logger";
import { sleep } from "@main/helpers";
import { startAria2 } from "../aria2c";
import Aria2 from "aria2";
export class HttpDownload { export class HttpDownload {
private abortController: AbortController; private static connected = false;
public lastProgressEvent: AxiosProgressEvent; private static aria2c: ChildProcess | null = null;
private trackerFilePath: string;
private trackerProgressEvent: AxiosProgressEvent | null = null; private static aria2 = new Aria2({});
private downloadPath: string;
private downloadTrackersPath = path.join( private static async connect() {
app.getPath("documents"), this.aria2c = startAria2();
"Hydra",
"Downloads"
);
constructor( let retries = 0;
private url: string,
private savePath: string
) {
this.abortController = new AbortController();
const sha256Hasher = crypto.createHash("sha256"); while (retries < 4 && !this.connected) {
const hash = sha256Hasher.update(url).digest("hex"); try {
await this.aria2.open();
logger.log("Connected to aria2");
this.trackerFilePath = path.join( this.connected = true;
this.downloadTrackersPath, } catch (err) {
`${hash}.hydradownload` await sleep(100);
); logger.log("Failed to connect to aria2, retrying...");
retries++;
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);
});
} }
} }
public async startDownload() { public static getStatus(gid: string) {
// Check if there's already a tracker file and download file if (this.connected) {
if ( return this.aria2.call("tellStatus", gid);
fs.existsSync(this.trackerFilePath) &&
fs.existsSync(this.downloadPath)
) {
this.trackerProgressEvent = JSON.parse(
fs.readFileSync(this.trackerFilePath, { encoding: "utf-8" })
);
} }
const response = await axios.get(this.url, { return null;
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",
})
);
} }
public async pauseDownload() { public static disconnect() {
this.abortController.abort(); if (this.aria2c) {
this.aria2c.kill();
this.connected = false;
}
} }
public cancelDownload() { static async cancelDownload(gid: string) {
this.pauseDownload(); await this.aria2.call("forceRemove", gid);
}
fs.rm(this.downloadPath, (err) => { static async pauseDownload(gid: string) {
if (err) logger.error(err); await this.aria2.call("forcePause", gid);
}); }
fs.rm(this.trackerFilePath, (err) => {
if (err) logger.error(err); 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);
} }
} }

View File

@@ -1,2 +1,2 @@
export * from "./download-manager"; export * from "./download-manager";
export * from "./torrent-downloader"; export * from "./python-instance";

View File

@@ -1,7 +1,11 @@
import cp from "node:child_process"; import cp from "node:child_process";
import { Game } from "@main/entity"; 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 { gameRepository } from "@main/repository";
import { DownloadProgress } from "@types"; import { DownloadProgress } from "@types";
import { QueryDeepPartialEntity } from "typeorm/query-builder/QueryPartialEntity"; import { QueryDeepPartialEntity } from "typeorm/query-builder/QueryPartialEntity";
@@ -13,10 +17,11 @@ import {
PauseDownloadPayload, PauseDownloadPayload,
LibtorrentStatus, LibtorrentStatus,
LibtorrentPayload, LibtorrentPayload,
ProcessPayload,
} from "./types"; } from "./types";
export class TorrentDownloader { export class PythonInstance {
private static torrentClient: cp.ChildProcess | null = null; private static pythonProcess: cp.ChildProcess | null = null;
private static downloadingGameId = -1; private static downloadingGameId = -1;
private static rpc = axios.create({ private static rpc = axios.create({
@@ -26,18 +31,31 @@ export class TorrentDownloader {
}, },
}); });
private static spawn(args: StartDownloadPayload) { public static spawn(args?: StartDownloadPayload) {
this.torrentClient = startTorrentClient(args); this.pythonProcess = startRPCClient(args);
} }
public static kill() { public static kill() {
if (this.torrentClient) { if (this.pythonProcess) {
this.torrentClient.kill(); this.pythonProcess.kill();
this.torrentClient = null; this.pythonProcess = null;
this.downloadingGameId = -1; 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() { public static async getStatus() {
if (this.downloadingGameId === -1) return null; if (this.downloadingGameId === -1) return null;
@@ -113,7 +131,7 @@ export class TorrentDownloader {
} }
static async startDownload(game: Game) { static async startDownload(game: Game) {
if (!this.torrentClient) { if (!this.pythonProcess) {
this.spawn({ this.spawn({
game_id: game.id, game_id: game.id,
magnet: game.uri!, magnet: game.uri!,

View File

@@ -6,10 +6,10 @@ import { DownloadProgress } from "@types";
import { HttpDownload } from "./http-download"; import { HttpDownload } from "./http-download";
export class RealDebridDownloader { export class RealDebridDownloader {
private static downloads = new Map<number, string>();
private static downloadingGame: Game | null = null; private static downloadingGame: Game | null = null;
private static realDebridTorrentId: string | null = null; private static realDebridTorrentId: string | null = null;
private static httpDownload: HttpDownload | null = null;
private static async getRealDebridDownloadUrl() { private static async getRealDebridDownloadUrl() {
if (this.realDebridTorrentId) { if (this.realDebridTorrentId) {
@@ -35,39 +35,47 @@ export class RealDebridDownloader {
} }
public static async getStatus() { 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) { if (status) {
await gameRepository.update( const progress =
{ id: this.downloadingGame!.id }, Number(status.completedLength) / Number(status.totalLength);
{
bytesDownloaded: lastProgressEvent.loaded, await gameRepository.update(
fileSize: lastProgressEvent.total, { id: this.downloadingGame!.id },
progress: lastProgressEvent.progress, {
status: "active", 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 = { return result;
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 progress;
} }
if (this.realDebridTorrentId && this.downloadingGame) { if (this.realDebridTorrentId && this.downloadingGame) {
@@ -101,25 +109,54 @@ export class RealDebridDownloader {
} }
static async pauseDownload() { static async pauseDownload() {
this.httpDownload?.pauseDownload(); const gid = this.downloads.get(this.downloadingGame!.id!);
if (gid) {
await HttpDownload.pauseDownload(gid);
}
this.realDebridTorrentId = null; this.realDebridTorrentId = null;
this.downloadingGame = null; this.downloadingGame = null;
} }
static async startDownload(game: Game) { static async startDownload(game: Game) {
this.realDebridTorrentId = await RealDebridClient.getTorrentId(game!.uri!);
this.downloadingGame = game; 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(); const downloadUrl = await this.getRealDebridDownloadUrl();
if (downloadUrl) { if (downloadUrl) {
this.realDebridTorrentId = null; 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() { static async cancelDownload(gameId: number) {
return this.httpDownload?.cancelDownload(); 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);
}
} }
} }

View File

@@ -15,12 +15,12 @@ export const BITTORRENT_PORT = "5881";
export const RPC_PORT = "8084"; export const RPC_PORT = "8084";
export const RPC_PASSWORD = crypto.randomBytes(32).toString("hex"); export const RPC_PASSWORD = crypto.randomBytes(32).toString("hex");
export const startTorrentClient = (args: StartDownloadPayload) => { export const startTorrentClient = (args?: StartDownloadPayload) => {
const commonArgs = [ const commonArgs = [
BITTORRENT_PORT, BITTORRENT_PORT,
RPC_PORT, RPC_PORT,
RPC_PASSWORD, RPC_PASSWORD,
encodeURIComponent(JSON.stringify(args)), args ? encodeURIComponent(JSON.stringify(args)) : "",
]; ];
if (app.isPackaged) { if (app.isPackaged) {

View File

@@ -31,3 +31,8 @@ export interface LibtorrentPayload {
status: LibtorrentStatus; status: LibtorrentStatus;
gameId: number; gameId: number;
} }
export interface ProcessPayload {
exe: string;
pid: number;
}

View File

@@ -5,6 +5,7 @@ import url from "url";
import { uploadGamesBatch } from "./library-sync"; import { uploadGamesBatch } from "./library-sync";
import { clearGamesRemoteIds } from "./library-sync/clear-games-remote-id"; import { clearGamesRemoteIds } from "./library-sync/clear-games-remote-id";
import { logger } from "./logger"; import { logger } from "./logger";
import { UserNotLoggedInError } from "@shared";
export class HydraApi { export class HydraApi {
private static instance: AxiosInstance; private static instance: AxiosInstance;
@@ -19,7 +20,7 @@ export class HydraApi {
expirationTimestamp: 0, expirationTimestamp: 0,
}; };
static isLoggedIn() { private static isLoggedIn() {
return this.userAuth.authToken !== ""; return this.userAuth.authToken !== "";
} }
@@ -127,14 +128,8 @@ export class HydraApi {
} }
private static async revalidateAccessTokenIfExpired() { 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(); const now = new Date();
if (this.userAuth.expirationTimestamp < now.getTime()) { if (this.userAuth.expirationTimestamp < now.getTime()) {
try { try {
const response = await this.instance.post(`/auth/refresh`, { const response = await this.instance.post(`/auth/refresh`, {
@@ -190,6 +185,8 @@ export class HydraApi {
}; };
static async get(url: string) { static async get(url: string) {
if (!this.isLoggedIn()) throw new UserNotLoggedInError();
await this.revalidateAccessTokenIfExpired(); await this.revalidateAccessTokenIfExpired();
return this.instance return this.instance
.get(url, this.getAxiosConfig()) .get(url, this.getAxiosConfig())
@@ -197,6 +194,8 @@ export class HydraApi {
} }
static async post(url: string, data?: any) { static async post(url: string, data?: any) {
if (!this.isLoggedIn()) throw new UserNotLoggedInError();
await this.revalidateAccessTokenIfExpired(); await this.revalidateAccessTokenIfExpired();
return this.instance return this.instance
.post(url, data, this.getAxiosConfig()) .post(url, data, this.getAxiosConfig())
@@ -204,6 +203,8 @@ export class HydraApi {
} }
static async put(url: string, data?: any) { static async put(url: string, data?: any) {
if (!this.isLoggedIn()) throw new UserNotLoggedInError();
await this.revalidateAccessTokenIfExpired(); await this.revalidateAccessTokenIfExpired();
return this.instance return this.instance
.put(url, data, this.getAxiosConfig()) .put(url, data, this.getAxiosConfig())
@@ -211,6 +212,8 @@ export class HydraApi {
} }
static async patch(url: string, data?: any) { static async patch(url: string, data?: any) {
if (!this.isLoggedIn()) throw new UserNotLoggedInError();
await this.revalidateAccessTokenIfExpired(); await this.revalidateAccessTokenIfExpired();
return this.instance return this.instance
.patch(url, data, this.getAxiosConfig()) .patch(url, data, this.getAxiosConfig())
@@ -218,6 +221,8 @@ export class HydraApi {
} }
static async delete(url: string) { static async delete(url: string) {
if (!this.isLoggedIn()) throw new UserNotLoggedInError();
await this.revalidateAccessTokenIfExpired(); await this.revalidateAccessTokenIfExpired();
return this.instance return this.instance
.delete(url, this.getAxiosConfig()) .delete(url, this.getAxiosConfig())

View File

@@ -1,11 +1,25 @@
import { Game } from "@main/entity"; import { Game } from "@main/entity";
import { HydraApi } from "../hydra-api"; import { HydraApi } from "../hydra-api";
import { gameRepository } from "@main/repository";
export const createGame = async (game: Game) => { export const createGame = async (game: Game) => {
return HydraApi.post(`/games`, { HydraApi.post(`/games`, {
objectId: game.objectID, objectId: game.objectID,
playTimeInMilliseconds: Math.trunc(game.playTimeInMilliseconds), playTimeInMilliseconds: Math.trunc(game.playTimeInMilliseconds),
shop: game.shop, shop: game.shop,
lastTimePlayed: game.lastTimePlayed, lastTimePlayed: game.lastTimePlayed,
}); })
.then((response) => {
const {
id: remoteId,
playTimeInMilliseconds,
lastTimePlayed,
} = response.data;
gameRepository.update(
{ objectID: game.objectID },
{ remoteId, playTimeInMilliseconds, lastTimePlayed }
);
})
.catch(() => {});
}; };

View File

@@ -2,71 +2,63 @@ import { gameRepository } from "@main/repository";
import { HydraApi } from "../hydra-api"; import { HydraApi } from "../hydra-api";
import { steamGamesWorker } from "@main/workers"; import { steamGamesWorker } from "@main/workers";
import { getSteamAppAsset } from "@main/helpers"; import { getSteamAppAsset } from "@main/helpers";
import { logger } from "../logger";
import { AxiosError } from "axios";
export const mergeWithRemoteGames = async () => { export const mergeWithRemoteGames = async () => {
try { return HydraApi.get("/games")
const games = await HydraApi.get("/games"); .then(async (response) => {
for (const game of response.data) {
for (const game of games.data) { const localGame = await gameRepository.findOne({
const localGame = await gameRepository.findOne({ where: {
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(
{
objectID: game.objectId, 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) { if (localGame) {
const iconUrl = steamGame?.clientIcon const updatedLastTimePlayed =
? getSteamAppAsset("icon", game.objectId, steamGame.clientIcon) localGame.lastTimePlayed == null ||
: null; (game.lastTimePlayed &&
new Date(game.lastTimePlayed) > localGame.lastTimePlayed)
? game.lastTimePlayed
: localGame.lastTimePlayed;
gameRepository.insert({ const updatedPlayTime =
objectID: game.objectId, localGame.playTimeInMilliseconds < game.playTimeInMilliseconds
title: steamGame?.name, ? game.playTimeInMilliseconds
remoteId: game.id, : localGame.playTimeInMilliseconds;
shop: game.shop,
iconUrl, gameRepository.update(
lastTimePlayed: game.lastTimePlayed, {
playTimeInMilliseconds: game.playTimeInMilliseconds, 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) { .catch(() => {});
if (err instanceof AxiosError) {
logger.error("getRemoteGames", err.message);
} else {
logger.error("getRemoteGames", err);
}
}
}; };

View File

@@ -6,8 +6,8 @@ export const updateGamePlaytime = async (
deltaInMillis: number, deltaInMillis: number,
lastTimePlayed: Date lastTimePlayed: Date
) => { ) => {
return HydraApi.put(`/games/${game.remoteId}`, { HydraApi.put(`/games/${game.remoteId}`, {
playTimeDeltaInSeconds: Math.trunc(deltaInMillis / 1000), playTimeDeltaInSeconds: Math.trunc(deltaInMillis / 1000),
lastTimePlayed, lastTimePlayed,
}); }).catch(() => {});
}; };

View File

@@ -2,43 +2,32 @@ import { gameRepository } from "@main/repository";
import { chunk } from "lodash-es"; import { chunk } from "lodash-es";
import { IsNull } from "typeorm"; import { IsNull } from "typeorm";
import { HydraApi } from "../hydra-api"; import { HydraApi } from "../hydra-api";
import { logger } from "../logger";
import { AxiosError } from "axios";
import { mergeWithRemoteGames } from "./merge-with-remote-games"; import { mergeWithRemoteGames } from "./merge-with-remote-games";
import { WindowManager } from "../window-manager"; import { WindowManager } from "../window-manager";
export const uploadGamesBatch = async () => { export const uploadGamesBatch = async () => {
try { const games = await gameRepository.find({
const games = await gameRepository.find({ where: { remoteId: IsNull(), isDeleted: false },
where: { remoteId: IsNull(), isDeleted: false }, });
});
const gamesChunks = chunk(games, 200); const gamesChunks = chunk(games, 200);
for (const chunk of gamesChunks) { for (const chunk of gamesChunks) {
await HydraApi.post( await HydraApi.post(
"/games/batch", "/games/batch",
chunk.map((game) => { chunk.map((game) => {
return { return {
objectId: game.objectID, objectId: game.objectID,
playTimeInMilliseconds: Math.trunc(game.playTimeInMilliseconds), playTimeInMilliseconds: Math.trunc(game.playTimeInMilliseconds),
shop: game.shop, shop: game.shop,
lastTimePlayed: game.lastTimePlayed, lastTimePlayed: game.lastTimePlayed,
}; };
}) })
); ).catch(() => {});
}
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);
}
} }
await mergeWithRemoteGames();
if (WindowManager.mainWindow)
WindowManager.mainWindow.webContents.send("on-library-batch-complete");
}; };

View File

@@ -1,7 +1,7 @@
import { Notification, nativeImage } from "electron"; import { Notification, nativeImage } from "electron";
import { t } from "i18next"; import { t } from "i18next";
import { parseICO } from "icojs"; import { parseICO } from "icojs";
import trayIcon from "@resources/tray-icon.png?asset";
import { Game } from "@main/entity"; import { Game } from "@main/entity";
import { gameRepository, userPreferencesRepository } from "@main/repository"; import { gameRepository, userPreferencesRepository } from "@main/repository";
@@ -39,11 +39,9 @@ export const publishDownloadCompleteNotification = async (game: Game) => {
new Notification({ new Notification({
title: t("download_complete", { title: t("download_complete", {
ns: "notifications", ns: "notifications",
lng: userPreferences.language,
}), }),
body: t("game_ready_to_install", { body: t("game_ready_to_install", {
ns: "notifications", ns: "notifications",
lng: userPreferences.language,
title: game.title, title: game.title,
}), }),
icon, icon,
@@ -60,13 +58,26 @@ export const publishNewRepacksNotifications = async (count: number) => {
new Notification({ new Notification({
title: t("repack_list_updated", { title: t("repack_list_updated", {
ns: "notifications", ns: "notifications",
lng: userPreferences?.language || "en",
}), }),
body: t("repack_count", { body: t("repack_count", {
ns: "notifications", ns: "notifications",
lng: userPreferences?.language || "en",
count: count, count: count,
}), }),
}).show(); }).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();
};

View File

@@ -1,11 +1,9 @@
import path from "node:path";
import { IsNull, Not } from "typeorm"; import { IsNull, Not } from "typeorm";
import { gameRepository } from "@main/repository"; import { gameRepository } from "@main/repository";
import { getProcesses } from "@main/helpers";
import { WindowManager } from "./window-manager"; import { WindowManager } from "./window-manager";
import { createGame, updateGamePlaytime } from "./library-sync"; import { createGame, updateGamePlaytime } from "./library-sync";
import { GameRunning } from "@types"; import { GameRunning } from "@types";
import { PythonInstance } from "./download";
export const gamesPlaytime = new Map< export const gamesPlaytime = new Map<
number, number,
@@ -21,23 +19,13 @@ export const watchProcesses = async () => {
}); });
if (games.length === 0) return; if (games.length === 0) return;
const processes = await PythonInstance.getProcessList();
const processes = await getProcesses();
for (const game of games) { for (const game of games) {
const executablePath = game.executablePath!; const executablePath = game.executablePath!;
const basename = path.win32.basename(executablePath);
const basenameWithoutExtension = path.win32.basename(
executablePath,
path.extname(executablePath)
);
const gameProcess = processes.find((runningProcess) => { const gameProcess = processes.find((runningProcess) => {
if (process.platform === "win32") { return executablePath == runningProcess.exe;
return runningProcess.name === basename;
}
return [basename, basenameWithoutExtension].includes(runningProcess.name);
}); });
if (gameProcess) { if (gameProcess) {
@@ -60,12 +48,7 @@ export const watchProcesses = async () => {
if (game.remoteId) { if (game.remoteId) {
updateGamePlaytime(game, 0, new Date()); updateGamePlaytime(game, 0, new Date());
} else { } else {
createGame({ ...game, lastTimePlayed: new Date() }).then( createGame({ ...game, lastTimePlayed: new Date() });
(response) => {
const { id: remoteId } = response.data;
gameRepository.update({ objectID: game.objectID }, { remoteId });
}
);
} }
gamesPlaytime.set(game.id, { gamesPlaytime.set(game.id, {
@@ -84,10 +67,7 @@ export const watchProcesses = async () => {
game.lastTimePlayed! game.lastTimePlayed!
); );
} else { } else {
createGame(game).then((response) => { createGame(game);
const { id: remoteId } = response.data;
gameRepository.update({ objectID: game.objectID }, { remoteId });
});
} }
} }
} }

View File

@@ -1,6 +1,6 @@
import { downloadSourceSchema } from "@main/events/helpers/validators"; import { downloadSourceSchema } from "@main/events/helpers/validators";
import { DownloadSourceStatus } from "@shared"; import { DownloadSourceStatus } from "@shared";
import type { DownloadSource } from "@types"; import type { DownloadSource, GameRepack } from "@types";
import axios, { AxiosError, AxiosHeaders } from "axios"; import axios, { AxiosError, AxiosHeaders } from "axios";
import { z } from "zod"; import { z } from "zod";
@@ -48,3 +48,24 @@ export const getUpdatedRepacks = async (downloadSources: DownloadSource[]) => {
return results; 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,
};
};

View File

@@ -112,7 +112,6 @@ contextBridge.exposeInMainWorld("electron", {
getDefaultDownloadsPath: () => ipcRenderer.invoke("getDefaultDownloadsPath"), getDefaultDownloadsPath: () => ipcRenderer.invoke("getDefaultDownloadsPath"),
isPortableVersion: () => ipcRenderer.invoke("isPortableVersion"), isPortableVersion: () => ipcRenderer.invoke("isPortableVersion"),
openExternal: (src: string) => ipcRenderer.invoke("openExternal", src), openExternal: (src: string) => ipcRenderer.invoke("openExternal", src),
isUserLoggedIn: () => ipcRenderer.invoke("isUserLoggedIn"),
showOpenDialog: (options: Electron.OpenDialogOptions) => showOpenDialog: (options: Electron.OpenDialogOptions) =>
ipcRenderer.invoke("showOpenDialog", options), ipcRenderer.invoke("showOpenDialog", options),
platform: process.platform, platform: process.platform,

View File

@@ -93,12 +93,8 @@ export function App() {
dispatch(setProfileBackground(profileBackground)); dispatch(setProfileBackground(profileBackground));
} }
window.electron.isUserLoggedIn().then((isLoggedIn) => { fetchUserDetails().then((response) => {
if (isLoggedIn) { if (response) updateUserDetails(response);
fetchUserDetails().then((response) => {
if (response) updateUserDetails(response);
});
}
}); });
}, [fetchUserDetails, updateUserDetails, dispatch]); }, [fetchUserDetails, updateUserDetails, dispatch]);

View File

@@ -47,10 +47,8 @@ export function AutoUpdateSubHeader() {
return ( return (
<header className={styles.subheader}> <header className={styles.subheader}>
<Link to={releasesPageUrl} className={styles.newVersionLink}> <Link to={releasesPageUrl} className={styles.newVersionLink}>
<SyncIcon size={12} /> <SyncIcon className={styles.newVersionIcon} size={12} />
<small> {t("version_available_download", { version: newVersion })}
{t("version_available_download", { version: newVersion })}
</small>
</Link> </Link>
</header> </header>
); );
@@ -64,10 +62,8 @@ export function AutoUpdateSubHeader() {
className={styles.newVersionButton} className={styles.newVersionButton}
onClick={handleClickInstallUpdate} onClick={handleClickInstallUpdate}
> >
<SyncIcon size={12} /> <SyncIcon className={styles.newVersionIcon} size={12} />
<small> {t("version_available_install", { version: newVersion })}
{t("version_available_install", { version: newVersion })}
</small>
</button> </button>
</header> </header>
); );

View File

@@ -157,7 +157,7 @@ export const newVersionButton = style({
justifyContent: "center", justifyContent: "center",
gap: `${SPACING_UNIT}px`, gap: `${SPACING_UNIT}px`,
color: vars.color.body, color: vars.color.body,
fontSize: "13px", fontSize: "12px",
":hover": { ":hover": {
textDecoration: "underline", textDecoration: "underline",
cursor: "pointer", cursor: "pointer",
@@ -169,5 +169,9 @@ export const newVersionLink = style({
alignItems: "center", alignItems: "center",
gap: `${SPACING_UNIT}px`, gap: `${SPACING_UNIT}px`,
color: "#8e919b", color: "#8e919b",
fontSize: "13px", fontSize: "12px",
});
export const newVersionIcon = style({
color: vars.color.success,
}); });

View File

@@ -100,7 +100,6 @@ declare global {
/* Misc */ /* Misc */
openExternal: (src: string) => Promise<void>; openExternal: (src: string) => Promise<void>;
isUserLoggedIn: () => Promise<boolean>;
getVersion: () => Promise<string>; getVersion: () => Promise<string>;
ping: () => string; ping: () => string;
getDefaultDownloadsPath: () => Promise<string>; getDefaultDownloadsPath: () => Promise<string>;

View File

@@ -57,8 +57,14 @@ export function useUserDetails() {
); );
const fetchUserDetails = useCallback(async () => { const fetchUserDetails = useCallback(async () => {
return window.electron.getMe(); return window.electron.getMe().then((userDetails) => {
}, []); if (userDetails == null) {
clearUserDetails();
}
return userDetails;
});
}, [clearUserDetails]);
const patchUser = useCallback( const patchUser = useCallback(
async (displayName: string, imageProfileUrl: string | null) => { async (displayName: string, imageProfileUrl: string | null) => {

View File

@@ -122,7 +122,7 @@ export function HeroPanelActions() {
<Button <Button
onClick={() => setShowGameOptionsModal(true)} onClick={() => setShowGameOptionsModal(true)}
theme="outline" theme="outline"
disabled={deleting || isGameRunning} disabled={deleting}
className={styles.heroPanelAction} className={styles.heroPanelAction}
> >
<GearIcon /> <GearIcon />

View File

@@ -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 { useTranslation } from "react-i18next";
import parseTorrent from "parse-torrent"; import parseTorrent from "parse-torrent";
@@ -12,6 +12,7 @@ import { format } from "date-fns";
import { DownloadSettingsModal } from "./download-settings-modal"; import { DownloadSettingsModal } from "./download-settings-modal";
import { gameDetailsContext } from "@renderer/context"; import { gameDetailsContext } from "@renderer/context";
import { Downloader } from "@shared"; import { Downloader } from "@shared";
import { orderBy } from "lodash-es";
export interface RepacksModalProps { export interface RepacksModalProps {
visible: boolean; visible: boolean;
@@ -38,16 +39,20 @@ export function RepacksModal({
const { t } = useTranslation("game_details"); const { t } = useTranslation("game_details");
const sortedRepacks = useMemo(() => {
return orderBy(repacks, (repack) => repack.uploadDate, "desc");
}, [repacks]);
const getInfoHash = useCallback(async () => { const getInfoHash = useCallback(async () => {
const torrent = await parseTorrent(game?.uri ?? ""); const torrent = await parseTorrent(game?.uri ?? "");
if (torrent.infoHash) setInfoHash(torrent.infoHash); if (torrent.infoHash) setInfoHash(torrent.infoHash);
}, [game]); }, [game]);
useEffect(() => { useEffect(() => {
setFilteredRepacks(repacks); setFilteredRepacks(sortedRepacks);
if (game?.uri) getInfoHash(); if (game?.uri) getInfoHash();
}, [repacks, visible, game, getInfoHash]); }, [sortedRepacks, visible, game, getInfoHash]);
const handleRepackClick = (repack: GameRepack) => { const handleRepackClick = (repack: GameRepack) => {
setRepack(repack); setRepack(repack);
@@ -58,7 +63,7 @@ export function RepacksModal({
const term = event.target.value.toLocaleLowerCase(); const term = event.target.value.toLocaleLowerCase();
setFilteredRepacks( setFilteredRepacks(
repacks.filter((repack) => { sortedRepacks.filter((repack) => {
const lowerCaseTitle = repack.title.toLowerCase(); const lowerCaseTitle = repack.title.toLowerCase();
const lowerCaseRepacker = repack.repacker.toLowerCase(); const lowerCaseRepacker = repack.repacker.toLowerCase();

View File

@@ -8,6 +8,13 @@ export enum DownloadSourceStatus {
Errored, 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"]; const FORMAT = ["B", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"];
export const formatBytes = (bytes: number): string => { export const formatBytes = (bytes: number): string => {
@@ -44,10 +51,15 @@ export const removeSpecialEditionFromName = (name: string) =>
export const removeDuplicateSpaces = (name: string) => export const removeDuplicateSpaces = (name: string) =>
name.replace(/\s{2,}/g, " "); name.replace(/\s{2,}/g, " ");
export const replaceUnderscoreWithSpace = (name: string) =>
name.replace(/_/g, " ");
export const formatName = pipe<string>( export const formatName = pipe<string>(
removeReleaseYearFromName, removeReleaseYearFromName,
removeSymbolsFromName,
removeSpecialEditionFromName, removeSpecialEditionFromName,
replaceUnderscoreWithSpace,
(str) => str.replace(/DIRECTOR'S CUT/g, ""),
removeSymbolsFromName,
removeDuplicateSpaces, removeDuplicateSpaces,
(str) => str.trim() (str) => str.trim()
); );

View File

@@ -30,6 +30,16 @@ class Downloader:
self.torrent_handles[game_id] = None self.torrent_handles[game_id] = None
self.downloading_game_id = -1 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): def get_download_status(self):
if self.downloading_game_id == -1: if self.downloading_game_id == -1:
return None return None

View File

@@ -2,16 +2,20 @@ import sys
from http.server import HTTPServer, BaseHTTPRequestHandler from http.server import HTTPServer, BaseHTTPRequestHandler
import json import json
import urllib.parse import urllib.parse
import psutil
from downloader import Downloader from downloader import Downloader
torrent_port = sys.argv[1] torrent_port = sys.argv[1]
http_port = sys.argv[2] http_port = sys.argv[2]
rpc_password = sys.argv[3] 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): class Handler(BaseHTTPRequestHandler):
rpc_password_header = 'x-hydra-rpc-password' rpc_password_header = 'x-hydra-rpc-password'
@@ -30,11 +34,28 @@ class Handler(BaseHTTPRequestHandler):
status = downloader.get_download_status() status = downloader.get_download_status()
self.wfile.write(json.dumps(status).encode('utf-8')) self.wfile.write(json.dumps(status).encode('utf-8'))
if self.path == "/healthcheck":
elif self.path == "/healthcheck":
self.send_response(200) self.send_response(200)
self.end_headers() 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): def do_POST(self):
global downloader
if self.path == "/action": if self.path == "/action":
if self.headers.get(self.rpc_password_header) != rpc_password: if self.headers.get(self.rpc_password_header) != rpc_password:
self.send_response(401) self.send_response(401)
@@ -45,13 +66,19 @@ class Handler(BaseHTTPRequestHandler):
post_data = self.rfile.read(content_length) post_data = self.rfile.read(content_length)
data = json.loads(post_data.decode('utf-8')) data = json.loads(post_data.decode('utf-8'))
if downloader is None:
downloader = Downloader(torrent_port)
if data['action'] == 'start': if data['action'] == 'start':
downloader.start_download(data['game_id'], data['magnet'], data['save_path']) downloader.start_download(data['game_id'], data['magnet'], data['save_path'])
elif data['action'] == 'pause': elif data['action'] == 'pause':
downloader.pause_download(data['game_id']) downloader.pause_download(data['game_id'])
elif data['action'] == 'cancel': elif data['action'] == 'cancel':
downloader.cancel_download(data['game_id']) downloader.cancel_download(data['game_id'])
elif data['action'] == 'kill-torrent':
downloader.abort_session()
downloader = None
self.send_response(200) self.send_response(200)
self.end_headers() self.end_headers()

View File

@@ -2665,6 +2665,14 @@ aria-query@^5.3.0:
dependencies: dependencies:
dequal "^2.0.3" 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: array-buffer-byte-length@^1.0.1:
version "1.0.1" version "1.0.1"
resolved "https://registry.npmjs.org/array-buffer-byte-length/-/array-buffer-byte-length-1.0.1.tgz" resolved "https://registry.npmjs.org/array-buffer-byte-length/-/array-buffer-byte-length-1.0.1.tgz"
@@ -5813,7 +5821,7 @@ node-domexception@^1.0.0:
resolved "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz" resolved "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz"
integrity sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ== 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" version "2.7.0"
resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-2.7.0.tgz#d0f0fa6e3e2dc1d27efcd8ad99d550bda94d187d" resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-2.7.0.tgz#d0f0fa6e3e2dc1d27efcd8ad99d550bda94d187d"
integrity sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A== integrity sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==
@@ -6311,11 +6319,6 @@ proxy-from-env@^1.1.0:
resolved "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz" resolved "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz"
integrity sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg== 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: psl@^1.1.33:
version "1.9.0" version "1.9.0"
resolved "https://registry.npmjs.org/psl/-/psl-1.9.0.tgz" resolved "https://registry.npmjs.org/psl/-/psl-1.9.0.tgz"
@@ -6999,6 +7002,11 @@ strtok3@^7.0.0:
"@tokenizer/token" "^0.3.0" "@tokenizer/token" "^0.3.0"
peek-readable "^5.0.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: sumchecker@^3.0.1:
version "3.0.1" version "3.0.1"
resolved "https://registry.npmjs.org/sumchecker/-/sumchecker-3.0.1.tgz" resolved "https://registry.npmjs.org/sumchecker/-/sumchecker-3.0.1.tgz"
@@ -7609,6 +7617,11 @@ wrappy@1:
resolved "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz" resolved "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz"
integrity sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ== 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: ws@^8.16.0:
version "8.17.0" version "8.17.0"
resolved "https://registry.npmjs.org/ws/-/ws-8.17.0.tgz" resolved "https://registry.npmjs.org/ws/-/ws-8.17.0.tgz"