mirror of
https://github.com/hydralauncher/hydra.git
synced 2026-01-22 18:33:56 +00:00
183 lines
4.7 KiB
TypeScript
183 lines
4.7 KiB
TypeScript
import axios from "axios";
|
|
import http from "node:http";
|
|
import getPort, { portNumbers } from "get-port";
|
|
|
|
import cp from "node:child_process";
|
|
import fs from "node:fs";
|
|
import path from "node:path";
|
|
import crypto from "node:crypto";
|
|
|
|
import { pythonRpcLogger } from "./logger";
|
|
import { Readable } from "node:stream";
|
|
import { app, dialog } from "electron";
|
|
import { db, levelKeys } from "@main/level";
|
|
|
|
interface GamePayload {
|
|
action: string;
|
|
game_id: string;
|
|
url: string | string[];
|
|
save_path: string;
|
|
header?: string;
|
|
out?: string;
|
|
total_size?: number;
|
|
}
|
|
|
|
const binaryNameByPlatform: Partial<Record<NodeJS.Platform, string>> = {
|
|
darwin: "hydra-python-rpc",
|
|
linux: "hydra-python-rpc",
|
|
win32: "hydra-python-rpc.exe",
|
|
};
|
|
|
|
const RPC_PORT_RANGE_START = 8080;
|
|
const RPC_PORT_RANGE_END = 9000;
|
|
const DEFAULT_RPC_PORT = 8084;
|
|
const HEALTH_CHECK_INTERVAL_MS = 100;
|
|
const HEALTH_CHECK_TIMEOUT_MS = 10000;
|
|
|
|
export class PythonRPC {
|
|
public static readonly BITTORRENT_PORT = "5881";
|
|
|
|
public static readonly rpc = axios.create({
|
|
baseURL: `http://localhost:${DEFAULT_RPC_PORT}`,
|
|
httpAgent: new http.Agent({
|
|
family: 4, // Force IPv4
|
|
}),
|
|
});
|
|
|
|
private static pythonProcess: cp.ChildProcess | null = null;
|
|
|
|
private static logStderr(readable: Readable | null) {
|
|
if (!readable) return;
|
|
|
|
readable.setEncoding("utf-8");
|
|
readable.on("data", pythonRpcLogger.log);
|
|
}
|
|
|
|
private static async getRPCPassword() {
|
|
const existingPassword = await db.get(levelKeys.rpcPassword, {
|
|
valueEncoding: "utf8",
|
|
});
|
|
|
|
if (existingPassword) return existingPassword;
|
|
|
|
const newPassword = crypto.randomBytes(32).toString("hex");
|
|
|
|
await db.put(levelKeys.rpcPassword, newPassword, {
|
|
valueEncoding: "utf8",
|
|
});
|
|
|
|
return newPassword;
|
|
}
|
|
|
|
private static async waitForHealthCheck(): Promise<void> {
|
|
const startTime = Date.now();
|
|
|
|
while (Date.now() - startTime < HEALTH_CHECK_TIMEOUT_MS) {
|
|
try {
|
|
const response = await this.rpc.get("/healthcheck", { timeout: 1000 });
|
|
if (response.status === 200) {
|
|
pythonRpcLogger.log("RPC health check passed");
|
|
return;
|
|
}
|
|
} catch {
|
|
// Server not ready yet, continue polling
|
|
}
|
|
await new Promise((resolve) =>
|
|
setTimeout(resolve, HEALTH_CHECK_INTERVAL_MS)
|
|
);
|
|
}
|
|
|
|
throw new Error("RPC health check timed out");
|
|
}
|
|
|
|
public static async spawn(
|
|
initialDownload?: GamePayload,
|
|
initialSeeding?: GamePayload[]
|
|
) {
|
|
const rpcPassword = await this.getRPCPassword();
|
|
|
|
const port = await getPort({
|
|
port: [
|
|
DEFAULT_RPC_PORT,
|
|
...portNumbers(RPC_PORT_RANGE_START, RPC_PORT_RANGE_END),
|
|
],
|
|
});
|
|
|
|
this.rpc.defaults.baseURL = `http://localhost:${port}`;
|
|
pythonRpcLogger.log(`Using RPC port: ${port}`);
|
|
|
|
const commonArgs = [
|
|
this.BITTORRENT_PORT,
|
|
String(port),
|
|
rpcPassword,
|
|
initialDownload ? JSON.stringify(initialDownload) : "",
|
|
initialSeeding ? JSON.stringify(initialSeeding) : "",
|
|
];
|
|
|
|
if (app.isPackaged) {
|
|
const binaryName = binaryNameByPlatform[process.platform]!;
|
|
const binaryPath = path.join(
|
|
process.resourcesPath,
|
|
"hydra-python-rpc",
|
|
binaryName
|
|
);
|
|
|
|
if (!fs.existsSync(binaryPath)) {
|
|
dialog.showErrorBox(
|
|
"Fatal",
|
|
"Hydra Python Instance binary not found. Please check if it has been removed by Windows Defender."
|
|
);
|
|
|
|
app.quit();
|
|
return;
|
|
}
|
|
|
|
const childProcess = cp.spawn(binaryPath, commonArgs, {
|
|
windowsHide: true,
|
|
stdio: ["inherit", "inherit"],
|
|
});
|
|
|
|
this.logStderr(childProcess.stderr);
|
|
this.pythonProcess = childProcess;
|
|
} else {
|
|
const scriptPath = path.join(
|
|
__dirname,
|
|
"..",
|
|
"..",
|
|
"python_rpc",
|
|
"main.py"
|
|
);
|
|
|
|
const childProcess = cp.spawn("python", [scriptPath, ...commonArgs], {
|
|
stdio: ["inherit", "inherit"],
|
|
});
|
|
|
|
this.logStderr(childProcess.stderr);
|
|
this.pythonProcess = childProcess;
|
|
}
|
|
|
|
this.rpc.defaults.headers.common["x-hydra-rpc-password"] = rpcPassword;
|
|
|
|
try {
|
|
await this.waitForHealthCheck();
|
|
pythonRpcLogger.log(`Python RPC started successfully on port ${port}`);
|
|
} catch (err) {
|
|
pythonRpcLogger.log(`Failed to start Python RPC: ${err}`);
|
|
dialog.showErrorBox(
|
|
"RPC Error",
|
|
`Failed to start download service.\n\nThe service did not respond in time. Please try restarting Hydra.`
|
|
);
|
|
this.kill();
|
|
throw err;
|
|
}
|
|
}
|
|
|
|
public static kill() {
|
|
if (this.pythonProcess) {
|
|
pythonRpcLogger.log("Killing python process");
|
|
this.pythonProcess.kill();
|
|
this.pythonProcess = null;
|
|
}
|
|
}
|
|
}
|