diff --git a/electron-builder.yml b/electron-builder.yml index c6f97df9..be300d36 100644 --- a/electron-builder.yml +++ b/electron-builder.yml @@ -6,10 +6,7 @@ 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/*" @@ -21,7 +18,6 @@ asarUnpack: - resources/** win: executableName: Hydra - requestedExecutionLevel: requireAdministrator target: - nsis - portable @@ -34,7 +30,6 @@ nsis: allowToChangeInstallationDirectory: true portable: artifactName: ${name}-${version}-portable.${ext} - requestExecutionLevel: admin mac: entitlementsInherit: build/entitlements.mac.plist extendInfo: diff --git a/package.json b/package.json index cde6b26a..daaef390 100644 --- a/package.json +++ b/package.json @@ -66,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", diff --git a/requirements.txt b/requirements.txt index 6cee730a..b1488003 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,3 +3,4 @@ cx_Freeze cx_Logging; sys_platform == 'win32' lief; sys_platform == 'win32' pywin32; sys_platform == 'win32' +psutil diff --git a/resources/hydralauncher.vbs b/resources/hydralauncher.vbs deleted file mode 100644 index ff611acf..00000000 --- a/resources/hydralauncher.vbs +++ /dev/null @@ -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 diff --git a/src/main/constants.ts b/src/main/constants.ts index 62713a2c..850c9ada 100644 --- a/src/main/constants.ts +++ b/src/main/constants.ts @@ -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" -); diff --git a/src/main/events/auth/sign-out.ts b/src/main/events/auth/sign-out.ts index 9c2ce2e0..0ba23e2b 100644 --- a/src/main/events/auth/sign-out.ts +++ b/src/main/events/auth/sign-out.ts @@ -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,7 +24,7 @@ const signOut = async (_event: Electron.IpcMainInvokeEvent) => { Sentry.setUser(null); /* Disconnects libtorrent */ - TorrentDownloader.kill(); + PythonInstance.killTorrent(); await Promise.all([ databaseOperations, diff --git a/src/main/events/library/close-game.ts b/src/main/events/library/close-game.ts index ccf446d3..9c431d06 100644 --- a/src/main/events/library/close-game.ts +++ b/src/main/events/library/close-game.ts @@ -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); diff --git a/src/main/events/library/create-game-shortcut.ts b/src/main/events/library/create-game-shortcut.ts index 30dfbf7c..a27317f0 100644 --- a/src/main/events/library/create-game-shortcut.ts +++ b/src/main/events/library/create-game-shortcut.ts @@ -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({ diff --git a/src/main/events/user-preferences/auto-launch.ts b/src/main/events/user-preferences/auto-launch.ts index cb40a969..ade7833d 100644 --- a/src/main/events/user-preferences/auto-launch.ts +++ b/src/main/events/user-preferences/auto-launch.ts @@ -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(); } }; diff --git a/src/main/helpers/index.ts b/src/main/helpers/index.ts index db1d13ff..902b927d 100644 --- a/src/main/helpers/index.ts +++ b/src/main/helpers/index.ts @@ -57,5 +57,4 @@ export const requestWebPage = async (url: string) => { .then((response) => response.data); }; -export * from "./ps"; export * from "./download-source"; diff --git a/src/main/helpers/ps.ts b/src/main/helpers/ps.ts deleted file mode 100644 index 5b326dfa..00000000 --- a/src/main/helpers/ps.ts +++ /dev/null @@ -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(); - } -}; diff --git a/src/main/index.ts b/src/main/index.ts index 7fac1c81..e288302b 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -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"; @@ -120,7 +120,7 @@ app.on("window-all-closed", () => { app.on("before-quit", () => { /* Disconnects libtorrent */ - TorrentDownloader.kill(); + PythonInstance.kill(); }); app.on("activate", () => { diff --git a/src/main/main.ts b/src/main/main.ts index 1abd8c2f..c80f0368 100644 --- a/src/main/main.ts +++ b/src/main/main.ts @@ -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(); @@ -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(); diff --git a/src/main/services/download/download-manager.ts b/src/main/services/download/download-manager.ts index 35ff0f2d..31f28992 100644 --- a/src/main/services/download/download-manager.ts +++ b/src/main/services/download/download-manager.ts @@ -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) { @@ -65,7 +65,7 @@ export class DownloadManager { if (this.currentDownloader === Downloader.RealDebrid) { await RealDebridDownloader.pauseDownload(); } else { - await TorrentDownloader.pauseDownload(); + await PythonInstance.pauseDownload(); } WindowManager.mainWindow?.setProgressBar(-1); @@ -77,7 +77,7 @@ export class DownloadManager { RealDebridDownloader.startDownload(game); this.currentDownloader = Downloader.RealDebrid; } else { - TorrentDownloader.startDownload(game); + PythonInstance.startDownload(game); this.currentDownloader = Downloader.Torrent; } } @@ -86,7 +86,7 @@ export class DownloadManager { if (this.currentDownloader === Downloader.RealDebrid) { 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; } } diff --git a/src/main/services/download/index.ts b/src/main/services/download/index.ts index 6244b81a..26d21799 100644 --- a/src/main/services/download/index.ts +++ b/src/main/services/download/index.ts @@ -1,2 +1,2 @@ export * from "./download-manager"; -export * from "./torrent-downloader"; +export * from "./python-instance"; diff --git a/src/main/services/download/torrent-downloader.ts b/src/main/services/download/python-instance.ts similarity index 80% rename from src/main/services/download/torrent-downloader.ts rename to src/main/services/download/python-instance.ts index e35bb617..c534e41d 100644 --- a/src/main/services/download/torrent-downloader.ts +++ b/src/main/services/download/python-instance.ts @@ -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("/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!, diff --git a/src/main/services/download/torrent-client.ts b/src/main/services/download/torrent-client.ts index 176ec664..93d20b7f 100644 --- a/src/main/services/download/torrent-client.ts +++ b/src/main/services/download/torrent-client.ts @@ -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) { diff --git a/src/main/services/download/types.ts b/src/main/services/download/types.ts index 516cd84f..fd8009a2 100644 --- a/src/main/services/download/types.ts +++ b/src/main/services/download/types.ts @@ -31,3 +31,8 @@ export interface LibtorrentPayload { status: LibtorrentStatus; gameId: number; } + +export interface ProcessPayload { + exe: string; + pid: number; +} diff --git a/src/main/services/process-watcher.ts b/src/main/services/process-watcher.ts index f20b4128..4ee78334 100644 --- a/src/main/services/process-watcher.ts +++ b/src/main/services/process-watcher.ts @@ -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) { diff --git a/src/renderer/src/pages/game-details/hero/hero-panel-actions.tsx b/src/renderer/src/pages/game-details/hero/hero-panel-actions.tsx index 4741668e..795aa8af 100644 --- a/src/renderer/src/pages/game-details/hero/hero-panel-actions.tsx +++ b/src/renderer/src/pages/game-details/hero/hero-panel-actions.tsx @@ -122,7 +122,7 @@ export function HeroPanelActions() {