mirror of
https://github.com/hydralauncher/hydra.git
synced 2026-01-21 05:09:39 -03:00
Compare commits
1 Commits
feat/LBX-4
...
fix/LBX-45
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
50bafbb7f6 |
@@ -1,36 +1,10 @@
|
||||
from flask import Flask, request, jsonify
|
||||
import sys, json, urllib.parse, psutil, socket
|
||||
import sys, json, urllib.parse, psutil
|
||||
from torrent_downloader import TorrentDownloader
|
||||
from http_downloader import HttpDownloader
|
||||
from profile_image_processor import ProfileImageProcessor
|
||||
import libtorrent as lt
|
||||
|
||||
RPC_PORT_MIN = 8080
|
||||
RPC_PORT_MAX = 9000
|
||||
|
||||
def find_available_port(preferred_port, start=RPC_PORT_MIN, end=RPC_PORT_MAX):
|
||||
"""Find an available port, trying the preferred port first."""
|
||||
# Try preferred port first
|
||||
try:
|
||||
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
||||
s.bind(('0.0.0.0', preferred_port))
|
||||
return preferred_port
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
# Try ports in range
|
||||
for port in range(start, end + 1):
|
||||
if port == preferred_port:
|
||||
continue
|
||||
try:
|
||||
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
||||
s.bind(('0.0.0.0', port))
|
||||
return port
|
||||
except OSError:
|
||||
continue
|
||||
|
||||
raise RuntimeError(f"No available ports in range {start}-{end}")
|
||||
|
||||
app = Flask(__name__)
|
||||
|
||||
# Retrieve command line arguments
|
||||
@@ -218,7 +192,4 @@ def action():
|
||||
return "", 200
|
||||
|
||||
if __name__ == "__main__":
|
||||
actual_port = find_available_port(int(http_port))
|
||||
# Print port for Node.js to capture - must be flushed immediately
|
||||
print(f"RPC_PORT:{actual_port}", flush=True)
|
||||
app.run(host="0.0.0.0", port=actual_port)
|
||||
app.run(host="0.0.0.0", port=int(http_port))
|
||||
|
||||
@@ -27,19 +27,11 @@ const binaryNameByPlatform: Partial<Record<NodeJS.Platform, string>> = {
|
||||
win32: "hydra-python-rpc.exe",
|
||||
};
|
||||
|
||||
const RPC_PORT_PREFIX = "RPC_PORT:";
|
||||
const PORT_DISCOVERY_TIMEOUT_MS = 30000;
|
||||
const HEALTH_CHECK_INTERVAL_MS = 100;
|
||||
const HEALTH_CHECK_TIMEOUT_MS = 10000;
|
||||
|
||||
export class PythonRPC {
|
||||
public static readonly BITTORRENT_PORT = "5881";
|
||||
public static readonly DEFAULT_RPC_PORT = "8084";
|
||||
|
||||
private static currentPort: string = this.DEFAULT_RPC_PORT;
|
||||
|
||||
public static readonly RPC_PORT = "8084";
|
||||
public static readonly rpc = axios.create({
|
||||
baseURL: `http://localhost:${this.DEFAULT_RPC_PORT}`,
|
||||
baseURL: `http://localhost:${this.RPC_PORT}`,
|
||||
httpAgent: new http.Agent({
|
||||
family: 4, // Force IPv4
|
||||
}),
|
||||
@@ -70,102 +62,6 @@ export class PythonRPC {
|
||||
return newPassword;
|
||||
}
|
||||
|
||||
private static updateBaseURL(port: string) {
|
||||
this.currentPort = port;
|
||||
this.rpc.defaults.baseURL = `http://localhost:${port}`;
|
||||
pythonRpcLogger.log(`RPC baseURL updated to port ${port}`);
|
||||
}
|
||||
|
||||
private static parsePortFromStdout(data: string): string | null {
|
||||
const lines = data.split("\n");
|
||||
for (const line of lines) {
|
||||
if (line.startsWith(RPC_PORT_PREFIX)) {
|
||||
return line.slice(RPC_PORT_PREFIX.length).trim();
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
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");
|
||||
}
|
||||
|
||||
private static waitForPort(
|
||||
childProcess: cp.ChildProcess
|
||||
): Promise<string | null> {
|
||||
return new Promise((resolve, reject) => {
|
||||
let resolved = false;
|
||||
let stdoutBuffer = "";
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
if (!resolved) {
|
||||
resolved = true;
|
||||
reject(
|
||||
new Error(
|
||||
`Port discovery timed out after ${PORT_DISCOVERY_TIMEOUT_MS}ms`
|
||||
)
|
||||
);
|
||||
}
|
||||
}, PORT_DISCOVERY_TIMEOUT_MS);
|
||||
|
||||
const cleanup = () => {
|
||||
clearTimeout(timeout);
|
||||
};
|
||||
|
||||
if (childProcess.stdout) {
|
||||
childProcess.stdout.setEncoding("utf-8");
|
||||
childProcess.stdout.on("data", (data: string) => {
|
||||
stdoutBuffer += data;
|
||||
pythonRpcLogger.log(data);
|
||||
|
||||
const port = this.parsePortFromStdout(stdoutBuffer);
|
||||
if (port && !resolved) {
|
||||
resolved = true;
|
||||
cleanup();
|
||||
resolve(port);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
childProcess.on("error", (err) => {
|
||||
if (!resolved) {
|
||||
resolved = true;
|
||||
cleanup();
|
||||
reject(err);
|
||||
}
|
||||
});
|
||||
|
||||
childProcess.on("exit", (code) => {
|
||||
if (!resolved) {
|
||||
resolved = true;
|
||||
cleanup();
|
||||
if (code === 0) {
|
||||
resolve(null);
|
||||
} else {
|
||||
reject(new Error(`Python RPC process exited with code ${code}`));
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
public static async spawn(
|
||||
initialDownload?: GamePayload,
|
||||
initialSeeding?: GamePayload[]
|
||||
@@ -174,14 +70,12 @@ export class PythonRPC {
|
||||
|
||||
const commonArgs = [
|
||||
this.BITTORRENT_PORT,
|
||||
this.DEFAULT_RPC_PORT,
|
||||
this.RPC_PORT,
|
||||
rpcPassword,
|
||||
initialDownload ? JSON.stringify(initialDownload) : "",
|
||||
initialSeeding ? JSON.stringify(initialSeeding) : "",
|
||||
];
|
||||
|
||||
let childProcess: cp.ChildProcess;
|
||||
|
||||
if (app.isPackaged) {
|
||||
const binaryName = binaryNameByPlatform[process.platform]!;
|
||||
const binaryPath = path.join(
|
||||
@@ -197,13 +91,16 @@ export class PythonRPC {
|
||||
);
|
||||
|
||||
app.quit();
|
||||
return;
|
||||
}
|
||||
|
||||
childProcess = cp.spawn(binaryPath, commonArgs, {
|
||||
const childProcess = cp.spawn(binaryPath, commonArgs, {
|
||||
windowsHide: true,
|
||||
stdio: ["inherit", "pipe", "pipe"],
|
||||
stdio: ["inherit", "inherit"],
|
||||
});
|
||||
|
||||
this.logStderr(childProcess.stderr);
|
||||
|
||||
this.pythonProcess = childProcess;
|
||||
} else {
|
||||
const scriptPath = path.join(
|
||||
__dirname,
|
||||
@@ -213,44 +110,16 @@ export class PythonRPC {
|
||||
"main.py"
|
||||
);
|
||||
|
||||
childProcess = cp.spawn("python", [scriptPath, ...commonArgs], {
|
||||
stdio: ["inherit", "pipe", "pipe"],
|
||||
const childProcess = cp.spawn("python", [scriptPath, ...commonArgs], {
|
||||
stdio: ["inherit", "inherit"],
|
||||
});
|
||||
|
||||
this.logStderr(childProcess.stderr);
|
||||
|
||||
this.pythonProcess = childProcess;
|
||||
}
|
||||
|
||||
this.logStderr(childProcess.stderr);
|
||||
this.pythonProcess = childProcess;
|
||||
|
||||
try {
|
||||
const port = await this.waitForPort(childProcess);
|
||||
|
||||
if (port) {
|
||||
this.updateBaseURL(port);
|
||||
} else {
|
||||
pythonRpcLogger.log(
|
||||
`No port received, using default port ${this.DEFAULT_RPC_PORT}`
|
||||
);
|
||||
this.updateBaseURL(this.DEFAULT_RPC_PORT);
|
||||
}
|
||||
|
||||
this.rpc.defaults.headers.common["x-hydra-rpc-password"] = rpcPassword;
|
||||
|
||||
await this.waitForHealthCheck();
|
||||
|
||||
pythonRpcLogger.log(
|
||||
`Python RPC started successfully on port ${this.currentPort}`
|
||||
);
|
||||
} catch (err) {
|
||||
pythonRpcLogger.log(`Failed to start Python RPC: ${err}`);
|
||||
|
||||
dialog.showErrorBox(
|
||||
"RPC Error",
|
||||
`Failed to start download service. ${err instanceof Error ? err.message : String(err)}\n\nPlease ensure no other application is using ports 8080-9000 and try restarting Hydra.`
|
||||
);
|
||||
|
||||
this.kill();
|
||||
throw err;
|
||||
}
|
||||
this.rpc.defaults.headers.common["x-hydra-rpc-password"] = rpcPassword;
|
||||
}
|
||||
|
||||
public static kill() {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { BellIcon } from "@primer/octicons-react";
|
||||
import { useAppSelector, useUserDetails } from "@renderer/hooks";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import SteamLogo from "@renderer/assets/steam-logo.svg?react";
|
||||
import { Avatar } from "../avatar/avatar";
|
||||
@@ -10,6 +10,8 @@ import { logger } from "@renderer/logger";
|
||||
import type { NotificationCountResponse } from "@types";
|
||||
import "./sidebar-profile.scss";
|
||||
|
||||
const NOTIFICATION_POLL_INTERVAL_MS = 5 * 60 * 1000;
|
||||
|
||||
export function SidebarProfile() {
|
||||
const navigate = useNavigate();
|
||||
|
||||
@@ -20,51 +22,78 @@ export function SidebarProfile() {
|
||||
const { gameRunning } = useAppSelector((state) => state.gameRunning);
|
||||
|
||||
const [notificationCount, setNotificationCount] = useState(0);
|
||||
const apiNotificationCountRef = useRef(0);
|
||||
const userDetailsRef = useRef(userDetails);
|
||||
|
||||
const fetchNotificationCount = useCallback(async () => {
|
||||
// Keep userDetailsRef in sync
|
||||
useEffect(() => {
|
||||
userDetailsRef.current = userDetails;
|
||||
}, [userDetails]);
|
||||
|
||||
const fetchLocalNotificationCount = useCallback(async () => {
|
||||
try {
|
||||
const localCount = await window.electron.getLocalNotificationsCount();
|
||||
setNotificationCount(localCount + apiNotificationCountRef.current);
|
||||
} catch (error) {
|
||||
logger.error("Failed to fetch local notification count", error);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const fetchFullNotificationCount = useCallback(async () => {
|
||||
try {
|
||||
// Always fetch local notification count
|
||||
const localCount = await window.electron.getLocalNotificationsCount();
|
||||
|
||||
// Fetch API notification count only if logged in
|
||||
let apiCount = 0;
|
||||
if (userDetails) {
|
||||
if (userDetailsRef.current) {
|
||||
try {
|
||||
const response =
|
||||
await window.electron.hydraApi.get<NotificationCountResponse>(
|
||||
"/profile/notifications/count",
|
||||
{ needsAuth: true }
|
||||
);
|
||||
apiCount = response.count;
|
||||
apiNotificationCountRef.current = response.count;
|
||||
} catch {
|
||||
// Ignore API errors
|
||||
}
|
||||
} else {
|
||||
apiNotificationCountRef.current = 0;
|
||||
}
|
||||
|
||||
setNotificationCount(localCount + apiCount);
|
||||
setNotificationCount(localCount + apiNotificationCountRef.current);
|
||||
} catch (error) {
|
||||
logger.error("Failed to fetch notification count", error);
|
||||
}
|
||||
}, [userDetails]);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchNotificationCount();
|
||||
fetchFullNotificationCount();
|
||||
|
||||
const interval = setInterval(fetchNotificationCount, 60000);
|
||||
const interval = setInterval(
|
||||
fetchFullNotificationCount,
|
||||
NOTIFICATION_POLL_INTERVAL_MS
|
||||
);
|
||||
return () => clearInterval(interval);
|
||||
}, [fetchNotificationCount]);
|
||||
}, [fetchFullNotificationCount]);
|
||||
|
||||
useEffect(() => {
|
||||
if (userDetails) {
|
||||
fetchFullNotificationCount();
|
||||
} else {
|
||||
apiNotificationCountRef.current = 0;
|
||||
fetchLocalNotificationCount();
|
||||
}
|
||||
}, [userDetails, fetchFullNotificationCount, fetchLocalNotificationCount]);
|
||||
|
||||
useEffect(() => {
|
||||
const unsubscribe = window.electron.onLocalNotificationCreated(() => {
|
||||
fetchNotificationCount();
|
||||
fetchLocalNotificationCount();
|
||||
});
|
||||
|
||||
return () => unsubscribe();
|
||||
}, [fetchNotificationCount]);
|
||||
}, [fetchLocalNotificationCount]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleNotificationsChange = () => {
|
||||
fetchNotificationCount();
|
||||
fetchLocalNotificationCount();
|
||||
};
|
||||
|
||||
window.addEventListener("notificationsChanged", handleNotificationsChange);
|
||||
@@ -74,15 +103,18 @@ export function SidebarProfile() {
|
||||
handleNotificationsChange
|
||||
);
|
||||
};
|
||||
}, [fetchNotificationCount]);
|
||||
}, [fetchLocalNotificationCount]);
|
||||
|
||||
useEffect(() => {
|
||||
const unsubscribe = window.electron.onSyncNotificationCount(() => {
|
||||
fetchNotificationCount();
|
||||
});
|
||||
const unsubscribe = window.electron.onSyncNotificationCount(
|
||||
(notification) => {
|
||||
apiNotificationCountRef.current = notification.notificationCount;
|
||||
fetchLocalNotificationCount();
|
||||
}
|
||||
);
|
||||
|
||||
return () => unsubscribe();
|
||||
}, [fetchNotificationCount]);
|
||||
}, [fetchLocalNotificationCount]);
|
||||
|
||||
const handleProfileClick = () => {
|
||||
if (userDetails === null) {
|
||||
|
||||
Reference in New Issue
Block a user