Merge branch 'main' of github.com:hydralauncher/hydra into fix/adding-sorting-to-repacks-modal

This commit is contained in:
Chubby Granny Chaser 2024-07-04 18:36:15 +01:00
commit d8158bb80e
No known key found for this signature in database
47 changed files with 584 additions and 464 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

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

@ -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) => {
RepacksManager.updateRepacks(); 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"