Merge branch 'main' into sort-library

This commit is contained in:
discollizard 2024-07-04 15:26:23 -03:00 committed by GitHub
commit 1663662b14
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
51 changed files with 594 additions and 472 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

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

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

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"

View File

@ -228,7 +228,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

@ -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,6 @@
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 fs from "node:fs";
import path from "node:path";
const autoLaunch = async ( const autoLaunch = async (
_event: Electron.IpcMainInvokeEvent, _event: Electron.IpcMainInvokeEvent,
@ -15,23 +12,10 @@ 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(() => {});
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) { appLauncher.disable().catch(() => {});
appLauncher.enable().catch();
} else {
appLauncher.disable().catch();
}
} }
}; };

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,8 +17,6 @@ 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(); await RepacksManager.updateRepacks();
@ -22,8 +25,8 @@ const loadState = async (userPreferences: UserPreferences | null) => {
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,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

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

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

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

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"