mirror of
https://github.com/hydralauncher/hydra.git
synced 2026-01-23 10:51:02 +00:00
Merge branch 'feat/LBX-457' of https://github.com/hydralauncher/hydra into feat/LBX-457
This commit is contained in:
@@ -64,6 +64,7 @@
|
||||
"embla-carousel-react": "^8.6.0",
|
||||
"file-type": "^20.5.0",
|
||||
"framer-motion": "^12.15.0",
|
||||
"get-port": "^7.1.0",
|
||||
"hls.js": "^1.5.12",
|
||||
"i18next": "^23.11.2",
|
||||
"i18next-browser-languagedetector": "^7.2.1",
|
||||
|
||||
@@ -382,6 +382,9 @@
|
||||
"audio": "Audio",
|
||||
"filter_by_source": "Filter by source",
|
||||
"no_repacks_found": "No sources found for this game",
|
||||
"source_online": "Source is online",
|
||||
"source_partial": "Some links are offline",
|
||||
"source_offline": "Source is offline",
|
||||
"delete_review": "Delete review",
|
||||
"remove_review": "Remove Review",
|
||||
"delete_review_modal_title": "Are you sure you want to delete your review?",
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
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";
|
||||
@@ -27,11 +28,17 @@ const binaryNameByPlatform: Partial<Record<NodeJS.Platform, string>> = {
|
||||
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_PORT = "8084";
|
||||
|
||||
public static readonly rpc = axios.create({
|
||||
baseURL: `http://localhost:${this.RPC_PORT}`,
|
||||
baseURL: `http://localhost:${DEFAULT_RPC_PORT}`,
|
||||
httpAgent: new http.Agent({
|
||||
family: 4, // Force IPv4
|
||||
}),
|
||||
@@ -62,15 +69,46 @@ export class PythonRPC {
|
||||
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,
|
||||
this.RPC_PORT,
|
||||
String(port),
|
||||
rpcPassword,
|
||||
initialDownload ? JSON.stringify(initialDownload) : "",
|
||||
initialSeeding ? JSON.stringify(initialSeeding) : "",
|
||||
@@ -91,6 +129,7 @@ export class PythonRPC {
|
||||
);
|
||||
|
||||
app.quit();
|
||||
return;
|
||||
}
|
||||
|
||||
const childProcess = cp.spawn(binaryPath, commonArgs, {
|
||||
@@ -99,7 +138,6 @@ export class PythonRPC {
|
||||
});
|
||||
|
||||
this.logStderr(childProcess.stderr);
|
||||
|
||||
this.pythonProcess = childProcess;
|
||||
} else {
|
||||
const scriptPath = path.join(
|
||||
@@ -115,11 +153,23 @@ export class PythonRPC {
|
||||
});
|
||||
|
||||
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() {
|
||||
|
||||
@@ -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, useState, useRef } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import SteamLogo from "@renderer/assets/steam-logo.svg?react";
|
||||
import { Avatar } from "../avatar/avatar";
|
||||
@@ -20,51 +20,60 @@ export function SidebarProfile() {
|
||||
const { gameRunning } = useAppSelector((state) => state.gameRunning);
|
||||
|
||||
const [notificationCount, setNotificationCount] = useState(0);
|
||||
const apiNotificationCountRef = useRef(0);
|
||||
const hasFetchedInitialCount = useRef(false);
|
||||
|
||||
const fetchNotificationCount = useCallback(async () => {
|
||||
const fetchLocalNotificationCount = 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) {
|
||||
try {
|
||||
const response =
|
||||
await window.electron.hydraApi.get<NotificationCountResponse>(
|
||||
"/profile/notifications/count",
|
||||
{ needsAuth: true }
|
||||
);
|
||||
apiCount = response.count;
|
||||
} catch {
|
||||
// Ignore API errors
|
||||
}
|
||||
}
|
||||
|
||||
setNotificationCount(localCount + apiCount);
|
||||
setNotificationCount(localCount + apiNotificationCountRef.current);
|
||||
} catch (error) {
|
||||
logger.error("Failed to fetch notification count", error);
|
||||
logger.error("Failed to fetch local notification count", error);
|
||||
}
|
||||
}, [userDetails]);
|
||||
}, []);
|
||||
|
||||
const fetchApiNotificationCount = useCallback(async () => {
|
||||
try {
|
||||
const response =
|
||||
await window.electron.hydraApi.get<NotificationCountResponse>(
|
||||
"/profile/notifications/count",
|
||||
{ needsAuth: true }
|
||||
);
|
||||
apiNotificationCountRef.current = response.count;
|
||||
} catch {
|
||||
// Ignore API errors
|
||||
}
|
||||
fetchLocalNotificationCount();
|
||||
}, [fetchLocalNotificationCount]);
|
||||
|
||||
// Initial fetch on mount (only once)
|
||||
useEffect(() => {
|
||||
fetchNotificationCount();
|
||||
fetchLocalNotificationCount();
|
||||
}, [fetchLocalNotificationCount]);
|
||||
|
||||
const interval = setInterval(fetchNotificationCount, 60000);
|
||||
return () => clearInterval(interval);
|
||||
}, [fetchNotificationCount]);
|
||||
// Fetch API count when user logs in (only if not already fetched)
|
||||
useEffect(() => {
|
||||
if (userDetails && !hasFetchedInitialCount.current) {
|
||||
hasFetchedInitialCount.current = true;
|
||||
fetchApiNotificationCount();
|
||||
} else if (!userDetails) {
|
||||
hasFetchedInitialCount.current = false;
|
||||
apiNotificationCountRef.current = 0;
|
||||
fetchLocalNotificationCount();
|
||||
}
|
||||
}, [userDetails, fetchApiNotificationCount, fetchLocalNotificationCount]);
|
||||
|
||||
useEffect(() => {
|
||||
const unsubscribe = window.electron.onLocalNotificationCreated(() => {
|
||||
fetchNotificationCount();
|
||||
fetchLocalNotificationCount();
|
||||
});
|
||||
|
||||
return () => unsubscribe();
|
||||
}, [fetchNotificationCount]);
|
||||
}, [fetchLocalNotificationCount]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleNotificationsChange = () => {
|
||||
fetchNotificationCount();
|
||||
fetchLocalNotificationCount();
|
||||
};
|
||||
|
||||
window.addEventListener("notificationsChanged", handleNotificationsChange);
|
||||
@@ -74,15 +83,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) {
|
||||
|
||||
@@ -40,6 +40,34 @@
|
||||
gap: calc(globals.$spacing-unit * 1);
|
||||
color: globals.$body-color;
|
||||
padding: calc(globals.$spacing-unit * 2);
|
||||
padding-right: calc(globals.$spacing-unit * 4);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
&__availability-orb {
|
||||
position: absolute;
|
||||
top: calc(globals.$spacing-unit * 1.5);
|
||||
right: calc(globals.$spacing-unit * 1.5);
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
|
||||
&--online {
|
||||
background-color: #22c55e;
|
||||
box-shadow: 0 0 6px rgba(34, 197, 94, 0.5);
|
||||
}
|
||||
|
||||
&--partial {
|
||||
background-color: #eab308;
|
||||
box-shadow: 0 0 6px rgba(234, 179, 8, 0.5);
|
||||
}
|
||||
|
||||
&--offline {
|
||||
background-color: #ef4444;
|
||||
opacity: 0.7;
|
||||
box-shadow: 0 0 6px rgba(239, 68, 68, 0.4);
|
||||
}
|
||||
}
|
||||
|
||||
&__repack-title {
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
ChevronDownIcon,
|
||||
ChevronUpIcon,
|
||||
} from "@primer/octicons-react";
|
||||
import { Tooltip } from "react-tooltip";
|
||||
|
||||
import {
|
||||
Badge,
|
||||
@@ -185,6 +186,20 @@ export function RepacksModal({
|
||||
);
|
||||
}, [repacks, hashesInDebrid]);
|
||||
|
||||
const getRepackAvailabilityStatus = (
|
||||
repack: GameRepack
|
||||
): "online" | "partial" | "offline" => {
|
||||
const unavailableSet = new Set(repack.unavailableUris ?? []);
|
||||
const availableCount = repack.uris.filter(
|
||||
(uri) => !unavailableSet.has(uri)
|
||||
).length;
|
||||
const unavailableCount = repack.uris.length - availableCount;
|
||||
|
||||
if (unavailableCount === 0) return "online";
|
||||
if (availableCount === 0) return "offline";
|
||||
return "partial";
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const term = filterTerm.trim().toLowerCase();
|
||||
|
||||
@@ -363,6 +378,8 @@ export function RepacksModal({
|
||||
filteredRepacks.map((repack) => {
|
||||
const isLastDownloadedOption =
|
||||
checkIfLastDownloadedOption(repack);
|
||||
const availabilityStatus = getRepackAvailabilityStatus(repack);
|
||||
const tooltipId = `availability-orb-${repack.id}`;
|
||||
|
||||
return (
|
||||
<Button
|
||||
@@ -371,6 +388,13 @@ export function RepacksModal({
|
||||
onClick={() => handleRepackClick(repack)}
|
||||
className="repacks-modal__repack-button"
|
||||
>
|
||||
<span
|
||||
className={`repacks-modal__availability-orb repacks-modal__availability-orb--${availabilityStatus}`}
|
||||
data-tooltip-id={tooltipId}
|
||||
data-tooltip-content={t(`source_${availabilityStatus}`)}
|
||||
/>
|
||||
<Tooltip id={tooltipId} />
|
||||
|
||||
<p className="repacks-modal__repack-title">
|
||||
{repack.title}
|
||||
{userPreferences?.enableNewDownloadOptionsBadges !==
|
||||
|
||||
@@ -5587,6 +5587,11 @@ get-nonce@^1.0.0:
|
||||
resolved "https://registry.yarnpkg.com/get-nonce/-/get-nonce-1.0.1.tgz#fdf3f0278073820d2ce9426c18f07481b1e0cdf3"
|
||||
integrity sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==
|
||||
|
||||
get-port@^7.1.0:
|
||||
version "7.1.0"
|
||||
resolved "https://registry.yarnpkg.com/get-port/-/get-port-7.1.0.tgz#d5a500ebfc7aa705294ec2b83cc38c5d0e364fec"
|
||||
integrity sha512-QB9NKEeDg3xxVwCCwJQ9+xycaz6pBB6iQ76wiWMl1927n0Kir6alPiP+yuiICLLU4jpMe08dXfpebuQppFA2zw==
|
||||
|
||||
get-proto@^1.0.0, get-proto@^1.0.1:
|
||||
version "1.0.1"
|
||||
resolved "https://registry.yarnpkg.com/get-proto/-/get-proto-1.0.1.tgz#150b3f2743869ef3e851ec0c49d15b1d14d00ee1"
|
||||
|
||||
Reference in New Issue
Block a user