Merge branch 'main' into fix/adding-sorting-to-repacks-modal

This commit is contained in:
Chubby Granny Chaser 2024-07-04 22:58:03 +01:00 committed by GitHub
commit f4ab2d76c9
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
47 changed files with 584 additions and 464 deletions

1
.gitignore vendored
View File

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

View File

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

View File

@ -23,7 +23,7 @@
"start": "electron-vite preview",
"dev": "electron-vite dev",
"build": "npm run typecheck && electron-vite build",
"postinstall": "electron-builder install-app-deps",
"postinstall": "electron-builder install-app-deps && node ./postinstall.cjs",
"build:unpack": "npm run build && electron-builder --dir",
"build:win": "electron-vite build && electron-builder --win",
"build:mac": "electron-vite build && electron-builder --mac",
@ -41,6 +41,7 @@
"@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",
@ -65,11 +66,11 @@
"lottie-react": "^2.4.0",
"parse-torrent": "^11.0.16",
"piscina": "^4.5.1",
"ps-list": "^8.1.1",
"react-i18next": "^14.1.0",
"react-loading-skeleton": "^3.4.0",
"react-redux": "^9.1.1",
"react-router-dom": "^6.22.3",
"sudo-prompt": "^9.2.1",
"typeorm": "^0.3.20",
"user-agents": "^1.1.193",
"yaml": "^2.4.1",

50
postinstall.cjs Normal file
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'
lief; 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
? path.join(process.resourcesPath, "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 * as Sentry from "@sentry/electron/main";
import { HydraApi, TorrentDownloader, gamesPlaytime } from "@main/services";
import { HydraApi, PythonInstance, gamesPlaytime } from "@main/services";
import { dataSource } from "@main/data-source";
import { DownloadQueue, Game, UserAuth } from "@main/entity";
@ -24,11 +24,11 @@ const signOut = async (_event: Electron.IpcMainInvokeEvent) => {
Sentry.setUser(null);
/* Disconnects libtorrent */
TorrentDownloader.kill();
PythonInstance.killTorrent();
await Promise.all([
databaseOperations,
HydraApi.post("/auth/logout").catch(),
HydraApi.post("/auth/logout").catch(() => {}),
]);
};

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

@ -1,9 +1,6 @@
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,
@ -15,23 +12,10 @@ const autoLaunch = async (
name: app.getName(),
});
if (process.platform == "win32") {
const destination = path.join(windowsStartupPath, "Hydra.vbs");
if (enabled) {
const scriptPath = path.join(process.resourcesPath, "hydralauncher.vbs");
fs.copyFileSync(scriptPath, destination);
} else {
appLauncher.disable().catch();
fs.rmSync(destination);
}
if (enabled) {
appLauncher.enable().catch(() => {});
} else {
if (enabled) {
appLauncher.enable().catch();
} else {
appLauncher.disable().catch();
}
appLauncher.disable().catch(() => {});
}
};

View File

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

View File

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

View File

@ -1,4 +1,9 @@
import { DownloadManager, RepacksManager, startMainLoop } from "./services";
import {
DownloadManager,
RepacksManager,
PythonInstance,
startMainLoop,
} from "./services";
import {
downloadQueueRepository,
repackRepository,
@ -12,8 +17,6 @@ import { MoreThan } from "typeorm";
import { HydraApi } from "./services/hydra-api";
import { uploadGamesBatch } from "./services/library-sync";
startMainLoop();
const loadState = async (userPreferences: UserPreferences | null) => {
await RepacksManager.updateRepacks();
@ -22,8 +25,8 @@ const loadState = async (userPreferences: UserPreferences | null) => {
if (userPreferences?.realDebridApiToken)
RealDebridClient.authorize(userPreferences?.realDebridApiToken);
HydraApi.setupApi().then(async () => {
if (HydraApi.isLoggedIn()) uploadGamesBatch();
HydraApi.setupApi().then(() => {
uploadGamesBatch();
});
const [nextQueueItem] = await downloadQueueRepository.find({
@ -35,8 +38,13 @@ const loadState = async (userPreferences: UserPreferences | null) => {
},
});
if (nextQueueItem?.game.status === "active")
if (nextQueueItem?.game.status === "active") {
DownloadManager.startDownload(nextQueueItem.game);
} else {
PythonInstance.spawn();
}
startMainLoop();
const now = new Date();

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

View File

@ -1,123 +1,68 @@
import path from "node:path";
import fs from "node:fs";
import crypto from "node:crypto";
import axios, { type AxiosProgressEvent } from "axios";
import { app } from "electron";
import type { ChildProcess } from "node:child_process";
import { logger } from "../logger";
import { sleep } from "@main/helpers";
import { startAria2 } from "../aria2c";
import Aria2 from "aria2";
export class HttpDownload {
private abortController: AbortController;
public lastProgressEvent: AxiosProgressEvent;
private trackerFilePath: string;
private static connected = false;
private static aria2c: ChildProcess | null = null;
private trackerProgressEvent: AxiosProgressEvent | null = null;
private downloadPath: string;
private static aria2 = new Aria2({});
private downloadTrackersPath = path.join(
app.getPath("documents"),
"Hydra",
"Downloads"
);
private static async connect() {
this.aria2c = startAria2();
constructor(
private url: string,
private savePath: string
) {
this.abortController = new AbortController();
let retries = 0;
const sha256Hasher = crypto.createHash("sha256");
const hash = sha256Hasher.update(url).digest("hex");
while (retries < 4 && !this.connected) {
try {
await this.aria2.open();
logger.log("Connected to aria2");
this.trackerFilePath = path.join(
this.downloadTrackersPath,
`${hash}.hydradownload`
);
const filename = path.win32.basename(this.url);
this.downloadPath = path.join(this.savePath, filename);
}
private updateTrackerFile() {
if (!fs.existsSync(this.downloadTrackersPath)) {
fs.mkdirSync(this.downloadTrackersPath, {
recursive: true,
});
}
fs.writeFileSync(
this.trackerFilePath,
JSON.stringify(this.lastProgressEvent),
{ encoding: "utf-8" }
);
}
private removeTrackerFile() {
if (fs.existsSync(this.trackerFilePath)) {
fs.rm(this.trackerFilePath, (err) => {
logger.error(err);
});
this.connected = true;
} catch (err) {
await sleep(100);
logger.log("Failed to connect to aria2, retrying...");
retries++;
}
}
}
public async startDownload() {
// Check if there's already a tracker file and download file
if (
fs.existsSync(this.trackerFilePath) &&
fs.existsSync(this.downloadPath)
) {
this.trackerProgressEvent = JSON.parse(
fs.readFileSync(this.trackerFilePath, { encoding: "utf-8" })
);
public static getStatus(gid: string) {
if (this.connected) {
return this.aria2.call("tellStatus", gid);
}
const response = await axios.get(this.url, {
responseType: "stream",
signal: this.abortController.signal,
headers: {
Range: `bytes=${this.trackerProgressEvent?.loaded ?? 0}-`,
},
onDownloadProgress: (progressEvent) => {
const total =
this.trackerProgressEvent?.total ?? progressEvent.total ?? 0;
const loaded =
(this.trackerProgressEvent?.loaded ?? 0) + progressEvent.loaded;
const progress = loaded / total;
this.lastProgressEvent = {
...progressEvent,
total,
progress,
loaded,
};
this.updateTrackerFile();
if (progressEvent.progress === 1) {
this.removeTrackerFile();
}
},
});
response.data.pipe(
fs.createWriteStream(this.downloadPath, {
flags: "a",
})
);
return null;
}
public async pauseDownload() {
this.abortController.abort();
public static disconnect() {
if (this.aria2c) {
this.aria2c.kill();
this.connected = false;
}
}
public cancelDownload() {
this.pauseDownload();
static async cancelDownload(gid: string) {
await this.aria2.call("forceRemove", gid);
}
fs.rm(this.downloadPath, (err) => {
if (err) logger.error(err);
});
fs.rm(this.trackerFilePath, (err) => {
if (err) logger.error(err);
});
static async pauseDownload(gid: string) {
await this.aria2.call("forcePause", gid);
}
static async resumeDownload(gid: string) {
await this.aria2.call("unpause", gid);
}
static async startDownload(downloadPath: string, downloadUrl: string) {
if (!this.connected) await this.connect();
const options = {
dir: downloadPath,
};
return this.aria2.call("addUri", [downloadUrl], options);
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -8,6 +8,13 @@ export enum DownloadSourceStatus {
Errored,
}
export class UserNotLoggedInError extends Error {
constructor() {
super("user not logged in");
this.name = "UserNotLoggedInError";
}
}
const FORMAT = ["B", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"];
export const formatBytes = (bytes: number): string => {

View File

@ -30,6 +30,16 @@ class Downloader:
self.torrent_handles[game_id] = None
self.downloading_game_id = -1
def abort_session(self):
for game_id in self.torrent_handles:
torrent_handle = self.torrent_handles[game_id]
torrent_handle.pause()
self.session.remove_torrent(torrent_handle)
self.session.abort()
self.torrent_handles = {}
self.downloading_game_id = -1
def get_download_status(self):
if self.downloading_game_id == -1:
return None

View File

@ -2,16 +2,20 @@ import sys
from http.server import HTTPServer, BaseHTTPRequestHandler
import json
import urllib.parse
import psutil
from downloader import Downloader
torrent_port = sys.argv[1]
http_port = sys.argv[2]
rpc_password = sys.argv[3]
initial_download = json.loads(urllib.parse.unquote(sys.argv[4]))
start_download_payload = sys.argv[4]
downloader = Downloader(torrent_port)
downloader = None
downloader.start_download(initial_download['game_id'], initial_download['magnet'], initial_download['save_path'])
if start_download_payload:
initial_download = json.loads(urllib.parse.unquote(start_download_payload))
downloader = Downloader(torrent_port)
downloader.start_download(initial_download['game_id'], initial_download['magnet'], initial_download['save_path'])
class Handler(BaseHTTPRequestHandler):
rpc_password_header = 'x-hydra-rpc-password'
@ -30,11 +34,28 @@ class Handler(BaseHTTPRequestHandler):
status = downloader.get_download_status()
self.wfile.write(json.dumps(status).encode('utf-8'))
if self.path == "/healthcheck":
elif self.path == "/healthcheck":
self.send_response(200)
self.end_headers()
elif self.path == "/process-list":
if self.headers.get(self.rpc_password_header) != rpc_password:
self.send_response(401)
self.end_headers()
return
process_list = [proc.info for proc in psutil.process_iter(['exe', 'pid', 'username'])]
self.send_response(200)
self.send_header("Content-type", "application/json")
self.end_headers()
self.wfile.write(json.dumps(process_list).encode('utf-8'))
def do_POST(self):
global downloader
if self.path == "/action":
if self.headers.get(self.rpc_password_header) != rpc_password:
self.send_response(401)
@ -45,13 +66,19 @@ class Handler(BaseHTTPRequestHandler):
post_data = self.rfile.read(content_length)
data = json.loads(post_data.decode('utf-8'))
if downloader is None:
downloader = Downloader(torrent_port)
if data['action'] == 'start':
downloader.start_download(data['game_id'], data['magnet'], data['save_path'])
elif data['action'] == 'pause':
downloader.pause_download(data['game_id'])
elif data['action'] == 'cancel':
downloader.cancel_download(data['game_id'])
elif data['action'] == 'kill-torrent':
downloader.abort_session()
downloader = None
self.send_response(200)
self.end_headers()

View File

@ -2665,6 +2665,14 @@ aria-query@^5.3.0:
dependencies:
dequal "^2.0.3"
aria2@^4.1.2:
version "4.1.2"
resolved "https://registry.yarnpkg.com/aria2/-/aria2-4.1.2.tgz#0ecbc50beea82856c88b4de71dac336154f67362"
integrity sha512-qTBr2RY8RZQmiUmbj2KXFvkErNxU4aTHZszszzwhE8svy2PEVX+IYR/c4Rp9Tuw4QkeU8cylGy6McV6Yl8i7Qw==
dependencies:
node-fetch "^2.6.1"
ws "^7.4.0"
array-buffer-byte-length@^1.0.1:
version "1.0.1"
resolved "https://registry.npmjs.org/array-buffer-byte-length/-/array-buffer-byte-length-1.0.1.tgz"
@ -5813,7 +5821,7 @@ node-domexception@^1.0.0:
resolved "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz"
integrity sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==
node-fetch@^2.6.7:
node-fetch@^2.6.1, node-fetch@^2.6.7:
version "2.7.0"
resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-2.7.0.tgz#d0f0fa6e3e2dc1d27efcd8ad99d550bda94d187d"
integrity sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==
@ -6311,11 +6319,6 @@ proxy-from-env@^1.1.0:
resolved "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz"
integrity sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==
ps-list@^8.1.1:
version "8.1.1"
resolved "https://registry.npmjs.org/ps-list/-/ps-list-8.1.1.tgz"
integrity sha512-OPS9kEJYVmiO48u/B9qneqhkMvgCxT+Tm28VCEJpheTpl8cJ0ffZRRNgS5mrQRTrX5yRTpaJ+hRDeefXYmmorQ==
psl@^1.1.33:
version "1.9.0"
resolved "https://registry.npmjs.org/psl/-/psl-1.9.0.tgz"
@ -6999,6 +7002,11 @@ strtok3@^7.0.0:
"@tokenizer/token" "^0.3.0"
peek-readable "^5.0.0"
sudo-prompt@^9.2.1:
version "9.2.1"
resolved "https://registry.yarnpkg.com/sudo-prompt/-/sudo-prompt-9.2.1.tgz#77efb84309c9ca489527a4e749f287e6bdd52afd"
integrity sha512-Mu7R0g4ig9TUuGSxJavny5Rv0egCEtpZRNMrZaYS1vxkiIxGiGUwoezU3LazIQ+KE04hTrTfNPgxU5gzi7F5Pw==
sumchecker@^3.0.1:
version "3.0.1"
resolved "https://registry.npmjs.org/sumchecker/-/sumchecker-3.0.1.tgz"
@ -7609,6 +7617,11 @@ wrappy@1:
resolved "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz"
integrity sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==
ws@^7.4.0:
version "7.5.10"
resolved "https://registry.yarnpkg.com/ws/-/ws-7.5.10.tgz#58b5c20dc281633f6c19113f39b349bd8bd558d9"
integrity sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ==
ws@^8.16.0:
version "8.17.0"
resolved "https://registry.npmjs.org/ws/-/ws-8.17.0.tgz"