Merge pull request #722 from hydralauncher/rc/2.0.2

Rc/2.0.2
This commit is contained in:
Chubby Granny Chaser 2024-06-28 22:15:40 +01:00 committed by GitHub
commit 05ec01178b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
60 changed files with 3050 additions and 1402 deletions

View file

@ -1,3 +1,3 @@
MAIN_VITE_STEAMGRIDDB_API_KEY=YOUR_API_KEY
MAIN_VITE_API_URL=API_URL
MAIN_VITE_SENTRY_DSN=YOUR_SENTRY_DSN

View file

@ -22,6 +22,17 @@ jobs:
- name: Install dependencies
run: yarn
- name: Install Python
uses: actions/setup-python@v5
with:
python-version: 3.9
- name: Install dependencies
run: pip install -r requirements.txt
- name: Build with cx_Freeze
run: python torrent-client/setup.py build
- name: Build Linux
if: matrix.os == 'ubuntu-latest'
run: yarn build:linux
@ -29,6 +40,8 @@ jobs:
MAIN_VITE_ONLINEFIX_USERNAME: ${{ secrets.ONLINEFIX_USERNAME }}
MAIN_VITE_ONLINEFIX_PASSWORD: ${{ secrets.ONLINEFIX_PASSWORD }}
MAIN_VITE_API_URL: ${{ vars.MAIN_VITE_API_URL }}
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
MAIN_VITE_SENTRY_DSN: ${{ vars.MAIN_VITE_SENTRY_DSN }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Build Windows
@ -38,6 +51,8 @@ jobs:
MAIN_VITE_ONLINEFIX_USERNAME: ${{ secrets.ONLINEFIX_USERNAME }}
MAIN_VITE_ONLINEFIX_PASSWORD: ${{ secrets.ONLINEFIX_PASSWORD }}
MAIN_VITE_API_URL: ${{ vars.MAIN_VITE_API_URL }}
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
MAIN_VITE_SENTRY_DSN: ${{ vars.MAIN_VITE_SENTRY_DSN }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Create artifact

View file

@ -1,6 +1,6 @@
name: Lint
on: [pull_request, push]
on: pull_request
jobs:
lint:

View file

@ -24,6 +24,17 @@ jobs:
- name: Install dependencies
run: yarn
- name: Install Python
uses: actions/setup-python@v5
with:
python-version: 3.9
- name: Install dependencies
run: pip install -r requirements.txt
- name: Build with cx_Freeze
run: python torrent-client/setup.py build
- name: Build Linux
if: matrix.os == 'ubuntu-latest'
run: yarn build:linux
@ -31,6 +42,8 @@ jobs:
MAIN_VITE_ONLINEFIX_USERNAME: ${{ secrets.ONLINEFIX_USERNAME }}
MAIN_VITE_ONLINEFIX_PASSWORD: ${{ secrets.ONLINEFIX_PASSWORD }}
MAIN_VITE_API_URL: ${{ vars.MAIN_VITE_API_URL }}
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
MAIN_VITE_SENTRY_DSN: ${{ vars.MAIN_VITE_SENTRY_DSN }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Build Windows
@ -40,6 +53,8 @@ jobs:
MAIN_VITE_ONLINEFIX_USERNAME: ${{ secrets.ONLINEFIX_USERNAME }}
MAIN_VITE_ONLINEFIX_PASSWORD: ${{ secrets.ONLINEFIX_PASSWORD }}
MAIN_VITE_API_URL: ${{ vars.MAIN_VITE_API_URL }}
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
MAIN_VITE_SENTRY_DSN: ${{ vars.MAIN_VITE_SENTRY_DSN }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Release

3
.gitignore vendored
View file

@ -1,6 +1,6 @@
.vscode
node_modules
aria2/
hydra-download-manager/
fastlist.exe
__pycache__
dist
@ -9,3 +9,4 @@ out
*.log*
.env
.vite
sentry.properties

View file

@ -3,11 +3,12 @@ productName: Hydra
directories:
buildResources: build
extraResources:
- aria2
- hydra-download-manager
- seeds
- from: node_modules/ps-list/vendor/fastlist-0.3.0-x64.exe
to: fastlist.exe
- from: node_modules/create-desktop-shortcuts/src/windows.vbs
- from: resources/hydralauncher.vbs
files:
- "!**/.vscode/*"
- "!src/*"

View file

@ -6,9 +6,16 @@ import {
externalizeDepsPlugin,
} from "electron-vite";
import react from "@vitejs/plugin-react";
import { sentryVitePlugin } from "@sentry/vite-plugin";
import { vanillaExtractPlugin } from "@vanilla-extract/vite-plugin";
import svgr from "vite-plugin-svgr";
const sentryPlugin = sentryVitePlugin({
authToken: process.env.SENTRY_AUTH_TOKEN,
org: "hydra-launcher",
project: "hydra-launcher",
});
export default defineConfig(({ mode }) => {
loadEnv(mode);
@ -28,7 +35,7 @@ export default defineConfig(({ mode }) => {
"@shared": resolve("src/shared"),
},
},
plugins: [externalizeDepsPlugin(), swcPlugin()],
plugins: [externalizeDepsPlugin(), swcPlugin(), sentryPlugin],
},
preload: {
plugins: [externalizeDepsPlugin()],
@ -44,7 +51,7 @@ export default defineConfig(({ mode }) => {
"@shared": resolve("src/shared"),
},
},
plugins: [svgr(), react(), vanillaExtractPlugin()],
plugins: [svgr(), react(), vanillaExtractPlugin(), sentryPlugin],
},
};
});

View file

@ -1,6 +1,6 @@
{
"name": "hydralauncher",
"version": "2.0.1",
"version": "2.0.2",
"description": "Hydra",
"main": "./out/main/index.js",
"author": "Los Broxas",
@ -23,7 +23,7 @@
"start": "electron-vite preview",
"dev": "electron-vite dev",
"build": "npm run typecheck && electron-vite build",
"postinstall": "electron-builder install-app-deps && node ./postinstall.cjs",
"postinstall": "electron-builder install-app-deps",
"build:unpack": "npm run build && electron-builder --dir",
"build:win": "electron-vite build && electron-builder --win",
"build:mac": "electron-vite build && electron-builder --mac",
@ -38,9 +38,9 @@
"@fontsource/fira-sans": "^5.0.20",
"@primer/octicons-react": "^19.9.0",
"@reduxjs/toolkit": "^2.2.3",
"@sentry/electron": "^5.1.0",
"@vanilla-extract/css": "^1.14.2",
"@vanilla-extract/recipes": "^0.5.2",
"aria2": "^4.1.2",
"auto-launch": "^5.0.6",
"axios": "^1.6.8",
"better-sqlite3": "^9.5.0",
@ -81,6 +81,7 @@
"@electron-toolkit/eslint-config-prettier": "^2.0.0",
"@electron-toolkit/eslint-config-ts": "^1.0.1",
"@electron-toolkit/tsconfig": "^1.0.1",
"@sentry/vite-plugin": "^2.20.1",
"@swc/core": "^1.4.16",
"@types/auto-launch": "^5.0.5",
"@types/color": "^3.0.6",

View file

@ -1,50 +0,0 @@
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();

5
requirements.txt Normal file
View file

@ -0,0 +1,5 @@
libtorrent
cx_Freeze
cx_Logging; sys_platform == 'win32'
lief; sys_platform == 'win32'
pywin32; sys_platform == 'win32'

View file

@ -0,0 +1,3 @@
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": "No downloads in progress",
"downloading_metadata": "Downloading {{title}} metadata…",
"downloading": "Downloading {{title}}… ({{percentage}} complete) - Conclusion {{eta}} - {{speed}}",
"calculating_eta": "Downloading {{title}}… ({{percentage}} complete) - Calculating remaining time…"
"calculating_eta": "Downloading {{title}}… ({{percentage}} complete) - Calculating remaining time…",
"checking_files": "Checking {{title}} files… ({{percentage}} complete)"
},
"catalogue": {
"next_page": "Next page",
@ -144,7 +145,8 @@
"downloads_completed": "Completed",
"queued": "Queued",
"no_downloads_title": "Such empty",
"no_downloads_description": "You haven't downloaded anything with Hydra yet, but it's never too late to start."
"no_downloads_description": "You haven't downloaded anything with Hydra yet, but it's never too late to start.",
"checking_files": "Checking files…"
},
"settings": {
"downloads_path": "Downloads path",

View file

@ -36,7 +36,8 @@
"no_downloads_in_progress": "Sem downloads em andamento",
"downloading_metadata": "Baixando metadados de {{title}}…",
"downloading": "Baixando {{title}}… ({{percentage}} concluído) - Conclusão {{eta}} - {{speed}}",
"calculating_eta": "Baixando {{title}}… ({{percentage}} concluído) - Calculando tempo restante…"
"calculating_eta": "Baixando {{title}}… ({{percentage}} concluído) - Calculando tempo restante…",
"checking_files": "Verificando arquivos de {{title}}…"
},
"game_details": {
"open_download_options": "Ver opções de download",
@ -140,7 +141,8 @@
"downloads_completed": "Completo",
"queued": "Na fila",
"no_downloads_title": "Nada por aqui…",
"no_downloads_description": "Você ainda não baixou nada pelo Hydra, mas nunca é tarde para começar."
"no_downloads_description": "Você ainda não baixou nada pelo Hydra, mas nunca é tarde para começar.",
"checking_files": "Verificando arquivos…"
},
"settings": {
"downloads_path": "Diretório dos downloads",

View file

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

View file

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

@ -9,9 +9,8 @@ import {
} from "typeorm";
import { Repack } from "./repack.entity";
import type { GameShop } from "@types";
import type { GameShop, GameStatus } from "@types";
import { Downloader } from "@shared";
import type { Aria2Status } from "aria2";
import type { DownloadQueue } from "./download-queue.entity";
@Entity("game")
@ -47,7 +46,7 @@ export class Game {
shop: GameShop;
@Column("text", { nullable: true })
status: Aria2Status | null;
status: GameStatus | null;
@Column("int", { default: Downloader.Torrent })
downloader: Downloader;

View file

@ -1,4 +1,5 @@
import jwt from "jsonwebtoken";
import * as Sentry from "@sentry/electron/main";
import { userAuthRepository } from "@main/repository";
import { registerEvent } from "../register-event";
@ -8,6 +9,9 @@ const getSessionHash = async (_event: Electron.IpcMainInvokeEvent) => {
if (!auth) return null;
const payload = jwt.decode(auth.accessToken) as jwt.JwtPayload;
Sentry.setContext("sessionId", payload.sessionId);
return payload.sessionId;
};

View file

@ -1,5 +1,6 @@
import { registerEvent } from "../register-event";
import { DownloadManager, HydraApi, gamesPlaytime } from "@main/services";
import * as Sentry from "@sentry/electron/main";
import { HydraApi, TorrentDownloader, gamesPlaytime } from "@main/services";
import { dataSource } from "@main/data-source";
import { DownloadQueue, Game, UserAuth } from "@main/entity";
@ -19,8 +20,11 @@ const signOut = async (_event: Electron.IpcMainInvokeEvent) => {
gamesPlaytime.clear();
});
/* Disconnects aria2 */
DownloadManager.disconnect();
/* Removes user from Sentry */
Sentry.setUser(null);
/* Disconnects libtorrent */
TorrentDownloader.kill();
await Promise.all([
databaseOperations,

View file

@ -0,0 +1,10 @@
import { shell } from "electron";
export const parseExecutablePath = (path: string) => {
if (process.platform === "win32" && path.endsWith(".lnk")) {
const { target } = shell.readShortcutLink(path);
return target;
}
return path;
};

View file

@ -49,4 +49,8 @@ import "./profile/update-profile";
ipcMain.handle("ping", () => "pong");
ipcMain.handle("getVersion", () => app.getVersion());
ipcMain.handle(
"isPortableVersion",
() => process.env.PORTABLE_EXECUTABLE_FILE != null
);
ipcMain.handle("getDefaultDownloadsPath", () => defaultDownloadsPath);

View file

@ -45,10 +45,6 @@ const deleteGameFolder = async (
reject();
}
const aria2ControlFilePath = `${folderPath}.aria2`;
if (fs.existsSync(aria2ControlFilePath))
fs.rmSync(aria2ControlFilePath);
resolve();
}
);

View file

@ -2,15 +2,18 @@ import { gameRepository } from "@main/repository";
import { registerEvent } from "../register-event";
import { shell } from "electron";
import { parseExecutablePath } from "../helpers/parse-executable-path";
const openGame = async (
_event: Electron.IpcMainInvokeEvent,
gameId: number,
executablePath: string
) => {
await gameRepository.update({ id: gameId }, { executablePath });
const parsedPath = parseExecutablePath(executablePath);
shell.openPath(executablePath);
await gameRepository.update({ id: gameId }, { executablePath: parsedPath });
shell.openPath(parsedPath);
};
registerEvent("openGame", openGame);

View file

@ -1,6 +1,7 @@
import { gameRepository } from "@main/repository";
import { registerEvent } from "../register-event";
import { parseExecutablePath } from "../helpers/parse-executable-path";
const updateExecutablePath = async (
_event: Electron.IpcMainInvokeEvent,
@ -12,7 +13,7 @@ const updateExecutablePath = async (
id,
},
{
executablePath,
executablePath: parseExecutablePath(executablePath),
}
);
};

View file

@ -1,4 +1,5 @@
import { registerEvent } from "../register-event";
import * as Sentry from "@sentry/electron/main";
import { HydraApi } from "@main/services";
import { UserProfile } from "@types";
import { userAuthRepository } from "@main/repository";
@ -21,10 +22,12 @@ const getMe = async (
["id"]
);
Sentry.setUser({ id: me.id, username: me.username });
return me;
})
.catch((err) => {
logger.error("getMe", err);
logger.error("getMe", err.message);
return userAuthRepository.findOne({ where: { id: 1 } });
});
};

View file

@ -1,18 +1,37 @@
import { windowsStartupPath } from "@main/constants";
import { registerEvent } from "../register-event";
import AutoLaunch from "auto-launch";
import { app } from "electron";
import fs from "node:fs";
import path from "node:path";
const autoLaunch = async (
_event: Electron.IpcMainInvokeEvent,
enabled: boolean
) => {
if (!app.isPackaged) return;
const appLauncher = new AutoLaunch({
name: app.getName(),
});
if (enabled) {
appLauncher.enable().catch();
if (process.platform == "win32") {
const destination = path.join(windowsStartupPath, "Hydra.vbs");
if (enabled) {
const scriptPath = path.join(process.resourcesPath, "hydralauncher.vbs");
fs.copyFileSync(scriptPath, destination);
} else {
appLauncher.disable().catch();
fs.rmSync(destination);
}
} else {
appLauncher.disable().catch();
if (enabled) {
appLauncher.enable().catch();
} else {
appLauncher.disable().catch();
}
}
};

View file

@ -11,7 +11,15 @@ export const getProcesses = async () => {
if (process.platform == "win32") {
const binaryPath = app.isPackaged
? path.join(process.resourcesPath, "fastlist.exe")
: path.join(__dirname, "..", "..", "fastlist.exe");
: path.join(
__dirname,
"..",
"..",
"node_modules",
"ps-list",
"vendor",
"fastlist-0.3.0-x64.exe"
);
const { stdout } = await execFile(binaryPath, {
maxBuffer: TEN_MEGABYTES,

View file

@ -1,10 +1,11 @@
import { app, BrowserWindow, net, protocol } from "electron";
import { init } from "@sentry/electron/main";
import updater from "electron-updater";
import i18n from "i18next";
import path from "node:path";
import url from "node:url";
import { electronApp, optimizer } from "@electron-toolkit/utils";
import { DownloadManager, logger, WindowManager } from "@main/services";
import { logger, TorrentDownloader, WindowManager } from "@main/services";
import { dataSource } from "@main/data-source";
import * as resources from "@locales";
import { userPreferencesRepository } from "@main/repository";
@ -22,6 +23,12 @@ autoUpdater.logger = logger;
const gotTheLock = app.requestSingleInstanceLock();
if (!gotTheLock) app.quit();
if (import.meta.env.MAIN_VITE_SENTRY_DSN) {
init({
dsn: import.meta.env.MAIN_VITE_SENTRY_DSN,
});
}
app.commandLine.appendSwitch("--no-sandbox");
i18n.init({
@ -108,7 +115,8 @@ app.on("window-all-closed", () => {
});
app.on("before-quit", () => {
DownloadManager.disconnect();
/* Disconnects libtorrent */
TorrentDownloader.kill();
});
app.on("activate", () => {

View file

@ -1,20 +0,0 @@
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,304 +0,0 @@
import Aria2, { StatusResponse } from "aria2";
import path from "node:path";
import { downloadQueueRepository, gameRepository } from "@main/repository";
import { WindowManager } from "./window-manager";
import { RealDebridClient } from "./real-debrid";
import { Downloader } from "@shared";
import { DownloadProgress } from "@types";
import { QueryDeepPartialEntity } from "typeorm/query-builder/QueryPartialEntity";
import { Game } from "@main/entity";
import { startAria2 } from "./aria2c";
import { sleep } from "@main/helpers";
import { logger } from "./logger";
import type { ChildProcess } from "node:child_process";
import { publishDownloadCompleteNotification } from "./notifications";
export class DownloadManager {
private static downloads = new Map<number, string>();
private static connected = false;
private static gid: string | null = null;
private static game: Game | null = null;
private static realDebridTorrentId: string | null = null;
private static aria2c: ChildProcess | null = null;
private static aria2 = new Aria2({});
private static async connect() {
this.aria2c = startAria2();
let retries = 0;
while (retries < 4 && !this.connected) {
try {
await this.aria2.open();
logger.log("Connected to aria2");
this.connected = true;
} catch (err) {
await sleep(100);
logger.log("Failed to connect to aria2, retrying...");
retries++;
}
}
}
public static disconnect() {
if (this.aria2c) {
this.aria2c.kill();
this.connected = false;
}
}
private static getETA(
totalLength: number,
completedLength: number,
speed: number
) {
const remainingBytes = totalLength - completedLength;
if (remainingBytes >= 0 && speed > 0) {
return (remainingBytes / speed) * 1000;
}
return -1;
}
private static getFolderName(status: StatusResponse) {
if (status.bittorrent?.info) return status.bittorrent.info.name;
const [file] = status.files;
if (file) return path.win32.basename(file.path);
return null;
}
private static async getRealDebridDownloadUrl() {
if (this.realDebridTorrentId) {
const torrentInfo = await RealDebridClient.getTorrentInfo(
this.realDebridTorrentId
);
const { status, links } = torrentInfo;
if (status === "waiting_files_selection") {
await RealDebridClient.selectAllFiles(this.realDebridTorrentId);
return null;
}
if (status === "downloaded") {
const [link] = links;
const { download } = await RealDebridClient.unrestrictLink(link);
return decodeURIComponent(download);
}
if (WindowManager.mainWindow) {
const progress = torrentInfo.progress / 100;
const totalDownloaded = progress * torrentInfo.bytes;
WindowManager.mainWindow.setProgressBar(progress === 1 ? -1 : progress);
const payload = {
numPeers: 0,
numSeeds: torrentInfo.seeders,
downloadSpeed: torrentInfo.speed,
timeRemaining: this.getETA(
torrentInfo.bytes,
totalDownloaded,
torrentInfo.speed
),
isDownloadingMetadata: status === "magnet_conversion",
game: {
...this.game,
bytesDownloaded: progress * torrentInfo.bytes,
progress,
},
} as DownloadProgress;
WindowManager.mainWindow.webContents.send(
"on-download-progress",
JSON.parse(JSON.stringify(payload))
);
}
}
return null;
}
public static async watchDownloads() {
if (!this.game) return;
if (!this.gid && this.realDebridTorrentId) {
const options = { dir: this.game.downloadPath! };
const downloadUrl = await this.getRealDebridDownloadUrl();
if (downloadUrl) {
this.gid = await this.aria2.call("addUri", [downloadUrl], options);
this.downloads.set(this.game.id, this.gid);
this.realDebridTorrentId = null;
}
}
if (!this.gid) return;
const status = await this.aria2.call("tellStatus", this.gid);
const isDownloadingMetadata = status.bittorrent && !status.bittorrent?.info;
if (status.followedBy?.length) {
this.gid = status.followedBy[0];
this.downloads.set(this.game.id, this.gid);
return;
}
const progress =
Number(status.completedLength) / Number(status.totalLength);
if (!isDownloadingMetadata) {
const update: QueryDeepPartialEntity<Game> = {
bytesDownloaded: Number(status.completedLength),
fileSize: Number(status.totalLength),
status: status.status,
};
if (!isNaN(progress)) update.progress = progress;
await gameRepository.update(
{ id: this.game.id },
{
...update,
status: status.status,
folderName: this.getFolderName(status),
}
);
}
const game = await gameRepository.findOne({
where: { id: this.game.id, isDeleted: false },
});
if (WindowManager.mainWindow && game) {
if (!isNaN(progress))
WindowManager.mainWindow.setProgressBar(progress === 1 ? -1 : progress);
const payload = {
numPeers: Number(status.connections),
numSeeds: Number(status.numSeeders ?? 0),
downloadSpeed: Number(status.downloadSpeed),
timeRemaining: this.getETA(
Number(status.totalLength),
Number(status.completedLength),
Number(status.downloadSpeed)
),
isDownloadingMetadata: !!isDownloadingMetadata,
game,
} as DownloadProgress;
WindowManager.mainWindow.webContents.send(
"on-download-progress",
JSON.parse(JSON.stringify(payload))
);
}
if (progress === 1 && this.game && !isDownloadingMetadata) {
publishDownloadCompleteNotification(this.game);
await downloadQueueRepository.delete({ game: this.game });
/*
Only cancel bittorrent downloads to stop seeding
*/
if (status.bittorrent) {
await this.cancelDownload(this.game.id);
} else {
this.clearCurrentDownload();
}
const [nextQueueItem] = await downloadQueueRepository.find({
order: {
id: "DESC",
},
relations: {
game: true,
},
});
if (nextQueueItem) {
this.resumeDownload(nextQueueItem.game);
}
}
}
private static clearCurrentDownload() {
if (this.game) {
this.downloads.delete(this.game.id);
this.gid = null;
this.game = null;
this.realDebridTorrentId = null;
}
}
static async cancelDownload(gameId: number) {
const gid = this.downloads.get(gameId);
if (gid) {
await this.aria2.call("forceRemove", gid);
if (this.gid === gid) {
this.clearCurrentDownload();
WindowManager.mainWindow?.setProgressBar(-1);
} else {
this.downloads.delete(gameId);
}
}
}
static async pauseDownload() {
if (this.gid) {
await this.aria2.call("forcePause", this.gid);
this.gid = null;
}
this.game = null;
this.realDebridTorrentId = null;
WindowManager.mainWindow?.setProgressBar(-1);
}
static async resumeDownload(game: Game) {
if (this.downloads.has(game.id)) {
const gid = this.downloads.get(game.id)!;
await this.aria2.call("unpause", gid);
this.gid = gid;
this.game = game;
this.realDebridTorrentId = null;
} else {
return this.startDownload(game);
}
}
static async startDownload(game: Game) {
if (!this.connected) await this.connect();
const options = {
dir: game.downloadPath!,
};
if (game.downloader === Downloader.RealDebrid) {
this.realDebridTorrentId = await RealDebridClient.getTorrentId(
game!.uri!
);
} else {
this.gid = await this.aria2.call("addUri", [game.uri!], options);
this.downloads.set(game.id, this.gid);
}
this.game = game;
}
}

View file

@ -0,0 +1,105 @@
import { Game } from "@main/entity";
import { Downloader } from "@shared";
import { TorrentDownloader } from "./torrent-downloader";
import { WindowManager } from "../window-manager";
import { downloadQueueRepository, gameRepository } from "@main/repository";
import { publishDownloadCompleteNotification } from "../notifications";
import { RealDebridDownloader } from "./real-debrid-downloader";
import type { DownloadProgress } from "@types";
export class DownloadManager {
private static currentDownloader: Downloader | null = null;
public static async watchDownloads() {
let status: DownloadProgress | null = null;
if (this.currentDownloader === Downloader.RealDebrid) {
status = await RealDebridDownloader.getStatus();
} else {
status = await TorrentDownloader.getStatus();
}
if (status) {
const { gameId, progress } = status;
const game = await gameRepository.findOne({
where: { id: gameId, isDeleted: false },
});
if (WindowManager.mainWindow && game) {
WindowManager.mainWindow.setProgressBar(progress === 1 ? -1 : progress);
WindowManager.mainWindow.webContents.send(
"on-download-progress",
JSON.parse(
JSON.stringify({
...status,
game,
})
)
);
}
if (progress === 1 && game) {
publishDownloadCompleteNotification(game);
await downloadQueueRepository.delete({ game });
const [nextQueueItem] = await downloadQueueRepository.find({
order: {
id: "DESC",
},
relations: {
game: true,
},
});
if (nextQueueItem) {
this.resumeDownload(nextQueueItem.game);
}
}
}
}
static async pauseDownload() {
if (this.currentDownloader === Downloader.RealDebrid) {
RealDebridDownloader.pauseDownload();
} else {
await TorrentDownloader.pauseDownload();
}
WindowManager.mainWindow?.setProgressBar(-1);
this.currentDownloader = null;
}
static async resumeDownload(game: Game) {
if (game.downloader === Downloader.RealDebrid) {
RealDebridDownloader.startDownload(game);
this.currentDownloader = Downloader.RealDebrid;
} else {
TorrentDownloader.startDownload(game);
this.currentDownloader = Downloader.Torrent;
}
}
static async cancelDownload(gameId: number) {
if (this.currentDownloader === Downloader.RealDebrid) {
RealDebridDownloader.cancelDownload();
} else {
TorrentDownloader.cancelDownload(gameId);
}
WindowManager.mainWindow?.setProgressBar(-1);
this.currentDownloader = null;
}
static async startDownload(game: Game) {
if (game.downloader === Downloader.RealDebrid) {
RealDebridDownloader.startDownload(game);
this.currentDownloader = Downloader.RealDebrid;
} else {
TorrentDownloader.startDownload(game);
this.currentDownloader = Downloader.Torrent;
}
}
}

View file

@ -0,0 +1,13 @@
export const calculateETA = (
totalLength: number,
completedLength: number,
speed: number
) => {
const remainingBytes = totalLength - completedLength;
if (remainingBytes >= 0 && speed > 0) {
return (remainingBytes / speed) * 1000;
}
return -1;
};

View file

@ -0,0 +1,123 @@
import path from "node:path";
import fs from "node:fs";
import crypto from "node:crypto";
import axios, { type AxiosProgressEvent } from "axios";
import { app } from "electron";
import { logger } from "../logger";
export class HttpDownload {
private abortController: AbortController;
public lastProgressEvent: AxiosProgressEvent;
private trackerFilePath: string;
private trackerProgressEvent: AxiosProgressEvent | null = null;
private downloadPath: string;
private downloadTrackersPath = path.join(
app.getPath("documents"),
"Hydra",
"Downloads"
);
constructor(
private url: string,
private savePath: string
) {
this.abortController = new AbortController();
const sha256Hasher = crypto.createHash("sha256");
const hash = sha256Hasher.update(url).digest("hex");
this.trackerFilePath = path.join(
this.downloadTrackersPath,
`${hash}.hydradownload`
);
const filename = path.win32.basename(this.url);
this.downloadPath = path.join(this.savePath, filename);
}
private updateTrackerFile() {
if (!fs.existsSync(this.downloadTrackersPath)) {
fs.mkdirSync(this.downloadTrackersPath, {
recursive: true,
});
}
fs.writeFileSync(
this.trackerFilePath,
JSON.stringify(this.lastProgressEvent),
{ encoding: "utf-8" }
);
}
private removeTrackerFile() {
if (fs.existsSync(this.trackerFilePath)) {
fs.rm(this.trackerFilePath, (err) => {
logger.error(err);
});
}
}
public async startDownload() {
// Check if there's already a tracker file and download file
if (
fs.existsSync(this.trackerFilePath) &&
fs.existsSync(this.downloadPath)
) {
this.trackerProgressEvent = JSON.parse(
fs.readFileSync(this.trackerFilePath, { encoding: "utf-8" })
);
}
const response = await axios.get(this.url, {
responseType: "stream",
signal: this.abortController.signal,
headers: {
Range: `bytes=${this.trackerProgressEvent?.loaded ?? 0}-`,
},
onDownloadProgress: (progressEvent) => {
const total =
this.trackerProgressEvent?.total ?? progressEvent.total ?? 0;
const loaded =
(this.trackerProgressEvent?.loaded ?? 0) + progressEvent.loaded;
const progress = loaded / total;
this.lastProgressEvent = {
...progressEvent,
total,
progress,
loaded,
};
this.updateTrackerFile();
if (progressEvent.progress === 1) {
this.removeTrackerFile();
}
},
});
response.data.pipe(
fs.createWriteStream(this.downloadPath, {
flags: "a",
})
);
}
public async pauseDownload() {
this.abortController.abort();
}
public cancelDownload() {
this.pauseDownload();
fs.rm(this.downloadPath, (err) => {
if (err) logger.error(err);
});
fs.rm(this.trackerFilePath, (err) => {
if (err) logger.error(err);
});
}
}

View file

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

View file

@ -0,0 +1,125 @@
import { Game } from "@main/entity";
import { RealDebridClient } from "../real-debrid";
import { gameRepository } from "@main/repository";
import { calculateETA } from "./helpers";
import { DownloadProgress } from "@types";
import { HttpDownload } from "./http-download";
export class RealDebridDownloader {
private static downloadingGame: Game | null = null;
private static realDebridTorrentId: string | null = null;
private static httpDownload: HttpDownload | null = null;
private static async getRealDebridDownloadUrl() {
if (this.realDebridTorrentId) {
const torrentInfo = await RealDebridClient.getTorrentInfo(
this.realDebridTorrentId
);
const { status, links } = torrentInfo;
if (status === "waiting_files_selection") {
await RealDebridClient.selectAllFiles(this.realDebridTorrentId);
return null;
}
if (status === "downloaded") {
const [link] = links;
const { download } = await RealDebridClient.unrestrictLink(link);
return decodeURIComponent(download);
}
}
return null;
}
public static async getStatus() {
const lastProgressEvent = this.httpDownload?.lastProgressEvent;
if (lastProgressEvent) {
await gameRepository.update(
{ id: this.downloadingGame!.id },
{
bytesDownloaded: lastProgressEvent.loaded,
fileSize: lastProgressEvent.total,
progress: lastProgressEvent.progress,
status: "active",
}
);
const progress = {
numPeers: 0,
numSeeds: 0,
downloadSpeed: lastProgressEvent.rate,
timeRemaining: calculateETA(
lastProgressEvent.total ?? 0,
lastProgressEvent.loaded,
lastProgressEvent.rate ?? 0
),
isDownloadingMetadata: false,
isCheckingFiles: false,
progress: lastProgressEvent.progress,
gameId: this.downloadingGame!.id,
} as DownloadProgress;
if (lastProgressEvent.progress === 1) {
this.pauseDownload();
}
return progress;
}
if (this.realDebridTorrentId && this.downloadingGame) {
const torrentInfo = await RealDebridClient.getTorrentInfo(
this.realDebridTorrentId
);
const { status } = torrentInfo;
if (status === "downloaded") {
this.startDownload(this.downloadingGame);
}
const progress = torrentInfo.progress / 100;
const totalDownloaded = progress * torrentInfo.bytes;
return {
numPeers: 0,
numSeeds: torrentInfo.seeders,
downloadSpeed: torrentInfo.speed,
timeRemaining: calculateETA(
torrentInfo.bytes,
totalDownloaded,
torrentInfo.speed
),
isDownloadingMetadata: status === "magnet_conversion",
} as DownloadProgress;
}
return null;
}
static async pauseDownload() {
this.httpDownload?.pauseDownload();
this.realDebridTorrentId = null;
this.downloadingGame = null;
}
static async startDownload(game: Game) {
this.realDebridTorrentId = await RealDebridClient.getTorrentId(game!.uri!);
this.downloadingGame = game;
const downloadUrl = await this.getRealDebridDownloadUrl();
if (downloadUrl) {
this.realDebridTorrentId = null;
this.httpDownload = new HttpDownload(downloadUrl, game!.downloadPath!);
this.httpDownload.startDownload();
}
}
static cancelDownload() {
return this.httpDownload?.cancelDownload();
}
}

View file

@ -0,0 +1,60 @@
import path from "node:path";
import cp from "node:child_process";
import crypto from "node:crypto";
import fs from "node:fs";
import { app, dialog } from "electron";
import type { StartDownloadPayload } from "./types";
const binaryNameByPlatform: Partial<Record<NodeJS.Platform, string>> = {
darwin: "hydra-download-manager",
linux: "hydra-download-manager",
win32: "hydra-download-manager.exe",
};
export const BITTORRENT_PORT = "5881";
export const RPC_PORT = "8084";
export const RPC_PASSWORD = crypto.randomBytes(32).toString("hex");
export const startTorrentClient = (args: StartDownloadPayload) => {
const commonArgs = [
BITTORRENT_PORT,
RPC_PORT,
RPC_PASSWORD,
encodeURIComponent(JSON.stringify(args)),
];
if (app.isPackaged) {
const binaryName = binaryNameByPlatform[process.platform]!;
const binaryPath = path.join(
process.resourcesPath,
"hydra-download-manager",
binaryName
);
if (!fs.existsSync(binaryPath)) {
dialog.showErrorBox(
"Fatal",
"Hydra Download Manager binary not found. Please check if it has been removed by Windows Defender."
);
app.quit();
}
return cp.spawn(binaryPath, commonArgs, {
stdio: "inherit",
windowsHide: true,
});
} else {
const scriptPath = path.join(
__dirname,
"..",
"..",
"torrent-client",
"main.py"
);
return cp.spawn("python3", [scriptPath, ...commonArgs], {
stdio: "inherit",
});
}
};

View file

@ -0,0 +1,144 @@
import cp from "node:child_process";
import { Game } from "@main/entity";
import { RPC_PASSWORD, RPC_PORT, startTorrentClient } from "./torrent-client";
import { gameRepository } from "@main/repository";
import { DownloadProgress } from "@types";
import { QueryDeepPartialEntity } from "typeorm/query-builder/QueryPartialEntity";
import { calculateETA } from "./helpers";
import axios from "axios";
import {
CancelDownloadPayload,
StartDownloadPayload,
PauseDownloadPayload,
LibtorrentStatus,
LibtorrentPayload,
} from "./types";
export class TorrentDownloader {
private static torrentClient: cp.ChildProcess | null = null;
private static downloadingGameId = -1;
private static rpc = axios.create({
baseURL: `http://localhost:${RPC_PORT}`,
headers: {
"x-hydra-rpc-password": RPC_PASSWORD,
},
});
private static spawn(args: StartDownloadPayload) {
this.torrentClient = startTorrentClient(args);
}
public static kill() {
if (this.torrentClient) {
this.torrentClient.kill();
this.torrentClient = null;
this.downloadingGameId = -1;
}
}
public static async getStatus() {
if (this.downloadingGameId === -1) return null;
const response = await this.rpc.get<LibtorrentPayload | null>("/status");
if (response.data === null) return null;
try {
const {
progress,
numPeers,
numSeeds,
downloadSpeed,
bytesDownloaded,
fileSize,
folderName,
status,
gameId,
} = response.data;
this.downloadingGameId = gameId;
const isDownloadingMetadata =
status === LibtorrentStatus.DownloadingMetadata;
const isCheckingFiles = status === LibtorrentStatus.CheckingFiles;
if (!isDownloadingMetadata && !isCheckingFiles) {
const update: QueryDeepPartialEntity<Game> = {
bytesDownloaded,
fileSize,
progress,
status: "active",
};
await gameRepository.update(
{ id: gameId },
{
...update,
folderName,
}
);
}
if (progress === 1 && !isCheckingFiles) {
this.downloadingGameId = -1;
}
return {
numPeers,
numSeeds,
downloadSpeed,
timeRemaining: calculateETA(fileSize, bytesDownloaded, downloadSpeed),
isDownloadingMetadata,
isCheckingFiles,
progress,
gameId,
} as DownloadProgress;
} catch (err) {
return null;
}
}
static async pauseDownload() {
await this.rpc
.post("/action", {
action: "pause",
game_id: this.downloadingGameId,
} as PauseDownloadPayload)
.catch(() => {});
this.downloadingGameId = -1;
}
static async startDownload(game: Game) {
if (!this.torrentClient) {
this.spawn({
game_id: game.id,
magnet: game.uri!,
save_path: game.downloadPath!,
});
} else {
await this.rpc.post("/action", {
action: "start",
game_id: game.id,
magnet: game.uri,
save_path: game.downloadPath,
} as StartDownloadPayload);
}
this.downloadingGameId = game.id;
}
static async cancelDownload(gameId: number) {
await this.rpc
.post("/action", {
action: "cancel",
game_id: gameId,
} as CancelDownloadPayload)
.catch(() => {});
this.downloadingGameId = -1;
}
}

View file

@ -0,0 +1,33 @@
export interface StartDownloadPayload {
game_id: number;
magnet: string;
save_path: string;
}
export interface PauseDownloadPayload {
game_id: number;
}
export interface CancelDownloadPayload {
game_id: number;
}
export enum LibtorrentStatus {
CheckingFiles = 1,
DownloadingMetadata = 2,
Downloading = 3,
Finished = 4,
Seeding = 5,
}
export interface LibtorrentPayload {
progress: number;
numPeers: number;
numSeeds: number;
downloadSpeed: number;
bytesDownloaded: number;
fileSize: number;
folderName: string;
status: LibtorrentStatus;
gameId: number;
}

View file

@ -90,7 +90,21 @@ export class HydraApi {
return response;
},
(error) => {
logger.error("response error", error);
logger.error(" ---- RESPONSE ERROR -----");
const { config } = error;
logger.error(config.method, config.baseURL, config.url, config.headers);
if (error.response) {
logger.error(error.response.status, error.response.data);
} else if (error.request) {
logger.error(error.request);
} else {
logger.error("Error", error.message);
}
logger.error(" ----- END RESPONSE ERROR -------");
return Promise.reject(error);
}
);
@ -106,10 +120,17 @@ export class HydraApi {
};
}
private static sendSignOutEvent() {
if (WindowManager.mainWindow) {
WindowManager.mainWindow.webContents.send("on-signout");
}
}
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");
}
@ -139,26 +160,7 @@ export class HydraApi {
["id"]
);
} catch (err) {
if (
err instanceof AxiosError &&
(err?.response?.status === 401 || err?.response?.status === 403)
) {
this.userAuth = {
authToken: "",
expirationTimestamp: 0,
refreshToken: "",
};
userAuthRepository.delete({ id: 1 });
if (WindowManager.mainWindow) {
WindowManager.mainWindow.webContents.send("on-signout");
}
logger.log("user refresh token expired");
}
throw err;
this.handleUnauthorizedError(err);
}
}
}
@ -171,28 +173,54 @@ export class HydraApi {
};
}
private static handleUnauthorizedError = (err) => {
if (err instanceof AxiosError && err.response?.status === 401) {
this.userAuth = {
authToken: "",
expirationTimestamp: 0,
refreshToken: "",
};
userAuthRepository.delete({ id: 1 });
this.sendSignOutEvent();
}
throw err;
};
static async get(url: string) {
await this.revalidateAccessTokenIfExpired();
return this.instance.get(url, this.getAxiosConfig());
return this.instance
.get(url, this.getAxiosConfig())
.catch(this.handleUnauthorizedError);
}
static async post(url: string, data?: any) {
await this.revalidateAccessTokenIfExpired();
return this.instance.post(url, data, this.getAxiosConfig());
return this.instance
.post(url, data, this.getAxiosConfig())
.catch(this.handleUnauthorizedError);
}
static async put(url: string, data?: any) {
await this.revalidateAccessTokenIfExpired();
return this.instance.put(url, data, this.getAxiosConfig());
return this.instance
.put(url, data, this.getAxiosConfig())
.catch(this.handleUnauthorizedError);
}
static async patch(url: string, data?: any) {
await this.revalidateAccessTokenIfExpired();
return this.instance.patch(url, data, this.getAxiosConfig());
return this.instance
.patch(url, data, this.getAxiosConfig())
.catch(this.handleUnauthorizedError);
}
static async delete(url: string) {
await this.revalidateAccessTokenIfExpired();
return this.instance.delete(url, this.getAxiosConfig());
return this.instance
.delete(url, this.getAxiosConfig())
.catch(this.handleUnauthorizedError);
}
}

View file

@ -3,7 +3,7 @@ export * from "./steam";
export * from "./steam-250";
export * from "./steam-grid";
export * from "./window-manager";
export * from "./download-manager";
export * from "./download";
export * from "./how-long-to-beat";
export * from "./process-watcher";
export * from "./main-loop";

View file

@ -64,7 +64,7 @@ export const mergeWithRemoteGames = async () => {
}
} catch (err) {
if (err instanceof AxiosError) {
logger.error("getRemoteGames", err.response, err.message);
logger.error("getRemoteGames", err.message);
} else {
logger.error("getRemoteGames", err);
}

View file

@ -1,5 +1,5 @@
import { sleep } from "@main/helpers";
import { DownloadManager } from "./download-manager";
import { DownloadManager } from "./download";
import { watchProcesses } from "./process-watcher";
export const startMainLoop = async () => {

View file

@ -3,6 +3,7 @@
interface ImportMetaEnv {
readonly MAIN_VITE_STEAMGRIDDB_API_KEY: string;
readonly MAIN_VITE_API_URL: string;
readonly MAIN_VITE_SENTRY_DSN: string;
}
interface ImportMeta {

View file

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

View file

@ -32,8 +32,17 @@ export function BottomPanel() {
const status = useMemo(() => {
if (isGameDownloading) {
if (lastPacket?.isCheckingFiles)
return t("checking_files", {
title: lastPacket?.game.title,
percentage: progress,
});
if (lastPacket?.isDownloadingMetadata)
return t("downloading_metadata", { title: lastPacket?.game.title });
return t("downloading_metadata", {
title: lastPacket?.game.title,
percentage: progress,
});
if (!eta) {
return t("calculating_eta", {
@ -56,6 +65,7 @@ export function BottomPanel() {
isGameDownloading,
lastPacket?.game,
lastPacket?.isDownloadingMetadata,
lastPacket?.isCheckingFiles,
progress,
eta,
downloadSpeed,

View file

@ -1,4 +1,4 @@
import { useEffect, useRef, useState } from "react";
import { useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { useLocation, useNavigate } from "react-router-dom";
@ -14,6 +14,7 @@ import { buildGameDetailsPath } from "@renderer/helpers";
import SteamLogo from "@renderer/assets/steam-logo.svg?react";
import { SidebarProfile } from "./sidebar-profile";
import { sortBy } from "lodash-es";
const SIDEBAR_MIN_WIDTH = 200;
const SIDEBAR_INITIAL_WIDTH = 250;
@ -35,6 +36,10 @@ export function Sidebar() {
const location = useLocation();
const sortedLibrary = useMemo(() => {
return sortBy(library, (game) => game.title);
}, [library]);
const { lastPacket, progress } = useDownload();
const { showWarningToast } = useToast();
@ -43,7 +48,7 @@ export function Sidebar() {
updateLibrary();
}, [lastPacket?.game.id, updateLibrary]);
const isDownloading = library.some(
const isDownloading = sortedLibrary.some(
(game) => game.status === "active" && game.progress !== 1
);
@ -63,7 +68,7 @@ export function Sidebar() {
const handleFilter: React.ChangeEventHandler<HTMLInputElement> = (event) => {
setFilteredLibrary(
library.filter((game) =>
sortedLibrary.filter((game) =>
game.title
.toLowerCase()
.includes(event.target.value.toLocaleLowerCase())
@ -72,8 +77,8 @@ export function Sidebar() {
};
useEffect(() => {
setFilteredLibrary(library);
}, [library]);
setFilteredLibrary(sortedLibrary);
}, [sortedLibrary]);
useEffect(() => {
window.onmousemove = (event: MouseEvent) => {

View file

@ -140,7 +140,7 @@ export function GameDetailsContextProvider({
filters: [
{
name: "Game executable",
extensions: ["exe"],
extensions: ["exe", "lnk"],
},
],
})

View file

@ -104,6 +104,7 @@ declare global {
getVersion: () => Promise<string>;
ping: () => string;
getDefaultDownloadsPath: () => Promise<string>;
isPortableVersion: () => Promise<boolean>;
showOpenDialog: (
options: Electron.OpenDialogOptions
) => Promise<Electron.OpenDialogReturnValue>;

View file

@ -22,13 +22,14 @@ export function useDownload() {
);
const dispatch = useAppDispatch();
const startDownload = (payload: StartGameDownloadPayload) =>
const startDownload = (payload: StartGameDownloadPayload) => {
dispatch(clearDownload());
window.electron.startGameDownload(payload).then((game) => {
dispatch(clearDownload());
updateLibrary();
return game;
});
};
const pauseDownload = async (gameId: number) => {
await window.electron.pauseGameDownload(gameId);
@ -65,7 +66,7 @@ export function useDownload() {
updateLibrary();
});
const getETA = () => {
const calculateETA = () => {
if (!lastPacket || lastPacket.timeRemaining < 0) return "";
try {
@ -85,9 +86,9 @@ export function useDownload() {
return {
downloadSpeed: `${formatBytes(lastPacket?.downloadSpeed ?? 0)}/s`,
progress: formatDownloadProgress(lastPacket?.game.progress),
progress: formatDownloadProgress(lastPacket?.progress ?? 0),
lastPacket,
eta: getETA(),
eta: calculateETA(),
startDownload,
pauseDownload,
resumeDownload,

View file

@ -6,6 +6,8 @@ import { Provider } from "react-redux";
import LanguageDetector from "i18next-browser-languagedetector";
import { HashRouter, Route, Routes } from "react-router-dom";
import * as Sentry from "@sentry/electron/renderer";
import "@fontsource/fira-mono/400.css";
import "@fontsource/fira-mono/500.css";
import "@fontsource/fira-mono/700.css";
@ -29,6 +31,8 @@ import { store } from "./store";
import * as resources from "@locales";
import { User } from "./pages/user/user";
Sentry.init({});
i18n
.use(LanguageDetector)
.use(initReactI18next)

View file

@ -67,6 +67,19 @@ export function DownloadGroup({
}
if (isGameDownloading) {
if (lastPacket?.isDownloadingMetadata) {
return <p>{t("downloading_metadata")}</p>;
}
if (lastPacket?.isCheckingFiles) {
return (
<>
<p>{progress}</p>
<p>{t("checking_files")}</p>
</>
);
}
return (
<>
<p>{progress}</p>
@ -110,7 +123,7 @@ export function DownloadGroup({
);
}
return <p>{t(game.status)}</p>;
return <p>{t(game.status as string)}</p>;
};
const getGameActions = (game: LibraryGame) => {

View file

@ -54,7 +54,7 @@ export function HeroPanelPlaytime() {
if (!game) return null;
const hasDownload =
["active", "paused"].includes(game.status) && game.progress !== 1;
["active", "paused"].includes(game.status as string) && game.progress !== 1;
const isGameDownloading =
game.status === "active" && lastPacket?.game.id === game.id;

View file

@ -23,7 +23,7 @@ export function GameOptionsModal({
const { showSuccessToast, showErrorToast } = useToast();
const { updateGame, setShowRepacksModal, selectGameExecutable } =
const { updateGame, setShowRepacksModal, repacks, selectGameExecutable } =
useContext(gameDetailsContext);
const [showDeleteModal, setShowDeleteModal] = useState(false);
@ -156,7 +156,7 @@ export function GameOptionsModal({
<Button
onClick={() => setShowRepacksModal(true)}
theme="outline"
disabled={deleting || isGameDownloading}
disabled={deleting || isGameDownloading || !repacks.length}
>
{t("open_download_options")}
</Button>

View file

@ -10,6 +10,8 @@ export function SettingsBehavior() {
(state) => state.userPreferences.value
);
const [showRunAtStartup, setShowRunAtStartup] = useState(false);
const { updateUserPreferences } = useContext(settingsContext);
const [form, setForm] = useState({
@ -28,6 +30,12 @@ export function SettingsBehavior() {
}
}, [userPreferences]);
useEffect(() => {
window.electron.isPortableVersion().then((isPortableVersion) => {
setShowRunAtStartup(!isPortableVersion);
});
}, []);
const handleChange = (values: Partial<typeof form>) => {
setForm((prev) => ({ ...prev, ...values }));
updateUserPreferences(values);
@ -45,14 +53,16 @@ export function SettingsBehavior() {
}
/>
<CheckboxField
label={t("launch_with_system")}
onChange={() => {
handleChange({ runAtStartup: !form.runAtStartup });
window.electron.autoLaunch(!form.runAtStartup);
}}
checked={form.runAtStartup}
/>
{showRunAtStartup && (
<CheckboxField
label={t("launch_with_system")}
onChange={() => {
handleChange({ runAtStartup: !form.runAtStartup });
window.electron.autoLaunch(!form.runAtStartup);
}}
checked={form.runAtStartup}
/>
)}
</>
);
}

View file

@ -1,6 +1,6 @@
import { UserProfile } from "@types";
import { useCallback, useEffect, useState } from "react";
import { useParams } from "react-router-dom";
import { useNavigate, useParams } from "react-router-dom";
import { setHeaderTitle } from "@renderer/features";
import { useAppDispatch } from "@renderer/hooks";
import { UserSkeleton } from "./user-skeleton";
@ -12,6 +12,7 @@ import * as styles from "./user.css";
export const User = () => {
const { userId } = useParams();
const [userProfile, setUserProfile] = useState<UserProfile>();
const navigate = useNavigate();
const dispatch = useAppDispatch();
@ -20,6 +21,8 @@ export const User = () => {
if (userProfile) {
dispatch(setHeaderTitle(userProfile.displayName));
setUserProfile(userProfile);
} else {
navigate(-1);
}
});
}, [dispatch, userId]);

View file

@ -1,6 +1,2 @@
/// <reference types="vite/client" />
/// <reference types="vite-plugin-svgr/client" />
interface ImportMeta {
readonly env: ImportMetaEnv;
}

View file

@ -1,6 +1,13 @@
import type { Aria2Status } from "aria2";
import type { DownloadSourceStatus, Downloader } from "@shared";
export type GameStatus =
| "active"
| "waiting"
| "paused"
| "error"
| "complete"
| "removed";
export type GameShop = "steam" | "epic";
export interface SteamGenre {
@ -106,7 +113,7 @@ export interface Game {
id: number;
title: string;
iconUrl: string;
status: Aria2Status | null;
status: GameStatus | null;
folderName: string;
downloadPath: string | null;
repacks: GameRepack[];
@ -142,6 +149,9 @@ export interface DownloadProgress {
numPeers: number;
numSeeds: number;
isDownloadingMetadata: boolean;
isCheckingFiles: boolean;
progress: number;
gameId: number;
game: LibraryGame;
}
@ -262,6 +272,7 @@ export interface UserDetails {
export interface UserProfile {
id: string;
displayName: string;
username: string;
profileImageUrl: string | null;
totalPlayTimeInSeconds: number;
libraryGames: UserGame[];

View file

@ -0,0 +1,52 @@
import libtorrent as lt
class Downloader:
def __init__(self, port: str):
self.torrent_handles = {}
self.downloading_game_id = -1
self.session = lt.session({'listen_interfaces': '0.0.0.0:{port}'.format(port=port)})
def start_download(self, game_id: int, magnet: str, save_path: str):
params = {'url': magnet, 'save_path': save_path}
torrent_handle = self.session.add_torrent(params)
self.torrent_handles[game_id] = torrent_handle
torrent_handle.set_flags(lt.torrent_flags.auto_managed)
torrent_handle.resume()
self.downloading_game_id = game_id
def pause_download(self, game_id: int):
torrent_handle = self.torrent_handles.get(game_id)
if torrent_handle:
torrent_handle.pause()
torrent_handle.unset_flags(lt.torrent_flags.auto_managed)
self.downloading_game_id = -1
def cancel_download(self, game_id: int):
torrent_handle = self.torrent_handles.get(game_id)
if torrent_handle:
torrent_handle.pause()
self.session.remove_torrent(torrent_handle)
self.torrent_handles[game_id] = None
self.downloading_game_id = -1
def get_download_status(self):
if self.downloading_game_id == -1:
return None
torrent_handle = self.torrent_handles.get(self.downloading_game_id)
status = torrent_handle.status()
info = torrent_handle.get_torrent_info()
return {
'folderName': info.name() if info else "",
'fileSize': info.total_size() if info else 0,
'gameId': self.downloading_game_id,
'progress': status.progress,
'downloadSpeed': status.download_rate,
'numPeers': status.num_peers,
'numSeeds': status.num_seeds,
'status': status.state,
'bytesDownloaded': status.progress * info.total_size() if info else status.all_time_download,
}

61
torrent-client/main.py Normal file
View file

@ -0,0 +1,61 @@
import sys
from http.server import HTTPServer, BaseHTTPRequestHandler
import json
import urllib.parse
from downloader import Downloader
torrent_port = sys.argv[1]
http_port = sys.argv[2]
rpc_password = sys.argv[3]
initial_download = json.loads(urllib.parse.unquote(sys.argv[4]))
downloader = Downloader(torrent_port)
downloader.start_download(initial_download['game_id'], initial_download['magnet'], initial_download['save_path'])
class Handler(BaseHTTPRequestHandler):
rpc_password_header = 'x-hydra-rpc-password'
def do_GET(self):
if self.path == "/status":
if self.headers.get(self.rpc_password_header) != rpc_password:
self.send_response(401)
self.end_headers()
return
self.send_response(200)
self.send_header("Content-type", "application/json")
self.end_headers()
status = downloader.get_download_status()
self.wfile.write(json.dumps(status).encode('utf-8'))
if self.path == "/healthcheck":
self.send_response(200)
self.end_headers()
def do_POST(self):
if self.path == "/action":
if self.headers.get(self.rpc_password_header) != rpc_password:
self.send_response(401)
self.end_headers()
return
content_length = int(self.headers['Content-Length'])
post_data = self.rfile.read(content_length)
data = json.loads(post_data.decode('utf-8'))
if data['action'] == 'start':
downloader.start_download(data['game_id'], data['magnet'], data['save_path'])
elif data['action'] == 'pause':
downloader.pause_download(data['game_id'])
elif data['action'] == 'cancel':
downloader.cancel_download(data['game_id'])
self.send_response(200)
self.end_headers()
if __name__ == "__main__":
httpd = HTTPServer(("", int(http_port)), Handler)
httpd.serve_forever()

20
torrent-client/setup.py Normal file
View file

@ -0,0 +1,20 @@
from cx_Freeze import setup, Executable
# Dependencies are automatically detected, but it might need fine tuning.
build_exe_options = {
"packages": ["libtorrent"],
"build_exe": "hydra-download-manager",
"include_msvcr": True
}
setup(
name="hydra-download-manager",
version="0.1",
description="Hydra",
options={"build_exe": build_exe_options},
executables=[Executable(
"torrent-client/main.py",
target_name="hydra-download-manager",
icon="build/icon.ico"
)]
)

2870
yarn.lock

File diff suppressed because it is too large Load diff