mirror of
https://github.com/hydralauncher/hydra.git
synced 2026-01-27 04:41:03 +00:00
Compare commits
13 Commits
feat/LBX-3
...
fix/LBX-45
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
073d3f25e3 | ||
|
|
066185e6ee | ||
|
|
8ce4b59294 | ||
|
|
90b62e4e8d | ||
|
|
8a447f683a | ||
|
|
5ddfd88ef7 | ||
|
|
569ad1c862 | ||
|
|
039df43123 | ||
|
|
50bafbb7f6 | ||
|
|
46154fa49a | ||
|
|
aae35b591d | ||
|
|
10ac6c9d9c | ||
|
|
9ca6a114b1 |
@@ -64,6 +64,7 @@
|
|||||||
"embla-carousel-react": "^8.6.0",
|
"embla-carousel-react": "^8.6.0",
|
||||||
"file-type": "^20.5.0",
|
"file-type": "^20.5.0",
|
||||||
"framer-motion": "^12.15.0",
|
"framer-motion": "^12.15.0",
|
||||||
|
"get-port": "^7.1.0",
|
||||||
"hls.js": "^1.5.12",
|
"hls.js": "^1.5.12",
|
||||||
"i18next": "^23.11.2",
|
"i18next": "^23.11.2",
|
||||||
"i18next-browser-languagedetector": "^7.2.1",
|
"i18next-browser-languagedetector": "^7.2.1",
|
||||||
|
|||||||
@@ -382,6 +382,9 @@
|
|||||||
"audio": "Audio",
|
"audio": "Audio",
|
||||||
"filter_by_source": "Filter by source",
|
"filter_by_source": "Filter by source",
|
||||||
"no_repacks_found": "No sources found for this game",
|
"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",
|
"delete_review": "Delete review",
|
||||||
"remove_review": "Remove Review",
|
"remove_review": "Remove Review",
|
||||||
"delete_review_modal_title": "Are you sure you want to delete your review?",
|
"delete_review_modal_title": "Are you sure you want to delete your review?",
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import http from "node:http";
|
import http from "node:http";
|
||||||
|
import getPort, { portNumbers } from "get-port";
|
||||||
|
|
||||||
import cp from "node:child_process";
|
import cp from "node:child_process";
|
||||||
import fs from "node:fs";
|
import fs from "node:fs";
|
||||||
@@ -27,11 +28,17 @@ const binaryNameByPlatform: Partial<Record<NodeJS.Platform, string>> = {
|
|||||||
win32: "hydra-python-rpc.exe",
|
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 {
|
export class PythonRPC {
|
||||||
public static readonly BITTORRENT_PORT = "5881";
|
public static readonly BITTORRENT_PORT = "5881";
|
||||||
public static readonly RPC_PORT = "8084";
|
|
||||||
public static readonly rpc = axios.create({
|
public static readonly rpc = axios.create({
|
||||||
baseURL: `http://localhost:${this.RPC_PORT}`,
|
baseURL: `http://localhost:${DEFAULT_RPC_PORT}`,
|
||||||
httpAgent: new http.Agent({
|
httpAgent: new http.Agent({
|
||||||
family: 4, // Force IPv4
|
family: 4, // Force IPv4
|
||||||
}),
|
}),
|
||||||
@@ -62,15 +69,46 @@ export class PythonRPC {
|
|||||||
return newPassword;
|
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(
|
public static async spawn(
|
||||||
initialDownload?: GamePayload,
|
initialDownload?: GamePayload,
|
||||||
initialSeeding?: GamePayload[]
|
initialSeeding?: GamePayload[]
|
||||||
) {
|
) {
|
||||||
const rpcPassword = await this.getRPCPassword();
|
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 = [
|
const commonArgs = [
|
||||||
this.BITTORRENT_PORT,
|
this.BITTORRENT_PORT,
|
||||||
this.RPC_PORT,
|
String(port),
|
||||||
rpcPassword,
|
rpcPassword,
|
||||||
initialDownload ? JSON.stringify(initialDownload) : "",
|
initialDownload ? JSON.stringify(initialDownload) : "",
|
||||||
initialSeeding ? JSON.stringify(initialSeeding) : "",
|
initialSeeding ? JSON.stringify(initialSeeding) : "",
|
||||||
@@ -91,6 +129,7 @@ export class PythonRPC {
|
|||||||
);
|
);
|
||||||
|
|
||||||
app.quit();
|
app.quit();
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const childProcess = cp.spawn(binaryPath, commonArgs, {
|
const childProcess = cp.spawn(binaryPath, commonArgs, {
|
||||||
@@ -99,7 +138,6 @@ export class PythonRPC {
|
|||||||
});
|
});
|
||||||
|
|
||||||
this.logStderr(childProcess.stderr);
|
this.logStderr(childProcess.stderr);
|
||||||
|
|
||||||
this.pythonProcess = childProcess;
|
this.pythonProcess = childProcess;
|
||||||
} else {
|
} else {
|
||||||
const scriptPath = path.join(
|
const scriptPath = path.join(
|
||||||
@@ -115,11 +153,23 @@ export class PythonRPC {
|
|||||||
});
|
});
|
||||||
|
|
||||||
this.logStderr(childProcess.stderr);
|
this.logStderr(childProcess.stderr);
|
||||||
|
|
||||||
this.pythonProcess = childProcess;
|
this.pythonProcess = childProcess;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.rpc.defaults.headers.common["x-hydra-rpc-password"] = rpcPassword;
|
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() {
|
public static kill() {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import { BellIcon } from "@primer/octicons-react";
|
import { BellIcon } from "@primer/octicons-react";
|
||||||
import { useAppSelector, useUserDetails } from "@renderer/hooks";
|
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 { useTranslation } from "react-i18next";
|
||||||
import SteamLogo from "@renderer/assets/steam-logo.svg?react";
|
import SteamLogo from "@renderer/assets/steam-logo.svg?react";
|
||||||
import { Avatar } from "../avatar/avatar";
|
import { Avatar } from "../avatar/avatar";
|
||||||
@@ -20,51 +20,60 @@ export function SidebarProfile() {
|
|||||||
const { gameRunning } = useAppSelector((state) => state.gameRunning);
|
const { gameRunning } = useAppSelector((state) => state.gameRunning);
|
||||||
|
|
||||||
const [notificationCount, setNotificationCount] = useState(0);
|
const [notificationCount, setNotificationCount] = useState(0);
|
||||||
|
const apiNotificationCountRef = useRef(0);
|
||||||
|
const hasFetchedInitialCount = useRef(false);
|
||||||
|
|
||||||
const fetchNotificationCount = useCallback(async () => {
|
const fetchLocalNotificationCount = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
// Always fetch local notification count
|
|
||||||
const localCount = await window.electron.getLocalNotificationsCount();
|
const localCount = await window.electron.getLocalNotificationsCount();
|
||||||
|
setNotificationCount(localCount + apiNotificationCountRef.current);
|
||||||
// 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);
|
|
||||||
} catch (error) {
|
} 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(() => {
|
useEffect(() => {
|
||||||
fetchNotificationCount();
|
fetchLocalNotificationCount();
|
||||||
|
}, [fetchLocalNotificationCount]);
|
||||||
|
|
||||||
const interval = setInterval(fetchNotificationCount, 60000);
|
// Fetch API count when user logs in (only if not already fetched)
|
||||||
return () => clearInterval(interval);
|
useEffect(() => {
|
||||||
}, [fetchNotificationCount]);
|
if (userDetails && !hasFetchedInitialCount.current) {
|
||||||
|
hasFetchedInitialCount.current = true;
|
||||||
|
fetchApiNotificationCount();
|
||||||
|
} else if (!userDetails) {
|
||||||
|
hasFetchedInitialCount.current = false;
|
||||||
|
apiNotificationCountRef.current = 0;
|
||||||
|
fetchLocalNotificationCount();
|
||||||
|
}
|
||||||
|
}, [userDetails, fetchApiNotificationCount, fetchLocalNotificationCount]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const unsubscribe = window.electron.onLocalNotificationCreated(() => {
|
const unsubscribe = window.electron.onLocalNotificationCreated(() => {
|
||||||
fetchNotificationCount();
|
fetchLocalNotificationCount();
|
||||||
});
|
});
|
||||||
|
|
||||||
return () => unsubscribe();
|
return () => unsubscribe();
|
||||||
}, [fetchNotificationCount]);
|
}, [fetchLocalNotificationCount]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleNotificationsChange = () => {
|
const handleNotificationsChange = () => {
|
||||||
fetchNotificationCount();
|
fetchLocalNotificationCount();
|
||||||
};
|
};
|
||||||
|
|
||||||
window.addEventListener("notificationsChanged", handleNotificationsChange);
|
window.addEventListener("notificationsChanged", handleNotificationsChange);
|
||||||
@@ -74,15 +83,18 @@ export function SidebarProfile() {
|
|||||||
handleNotificationsChange
|
handleNotificationsChange
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
}, [fetchNotificationCount]);
|
}, [fetchLocalNotificationCount]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const unsubscribe = window.electron.onSyncNotificationCount(() => {
|
const unsubscribe = window.electron.onSyncNotificationCount(
|
||||||
fetchNotificationCount();
|
(notification) => {
|
||||||
});
|
apiNotificationCountRef.current = notification.notificationCount;
|
||||||
|
fetchLocalNotificationCount();
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
return () => unsubscribe();
|
return () => unsubscribe();
|
||||||
}, [fetchNotificationCount]);
|
}, [fetchLocalNotificationCount]);
|
||||||
|
|
||||||
const handleProfileClick = () => {
|
const handleProfileClick = () => {
|
||||||
if (userDetails === null) {
|
if (userDetails === null) {
|
||||||
|
|||||||
@@ -40,6 +40,34 @@
|
|||||||
gap: calc(globals.$spacing-unit * 1);
|
gap: calc(globals.$spacing-unit * 1);
|
||||||
color: globals.$body-color;
|
color: globals.$body-color;
|
||||||
padding: calc(globals.$spacing-unit * 2);
|
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 {
|
&__repack-title {
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
ChevronDownIcon,
|
ChevronDownIcon,
|
||||||
ChevronUpIcon,
|
ChevronUpIcon,
|
||||||
} from "@primer/octicons-react";
|
} from "@primer/octicons-react";
|
||||||
|
import { Tooltip } from "react-tooltip";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
Badge,
|
Badge,
|
||||||
@@ -185,6 +186,20 @@ export function RepacksModal({
|
|||||||
);
|
);
|
||||||
}, [repacks, hashesInDebrid]);
|
}, [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(() => {
|
useEffect(() => {
|
||||||
const term = filterTerm.trim().toLowerCase();
|
const term = filterTerm.trim().toLowerCase();
|
||||||
|
|
||||||
@@ -363,6 +378,8 @@ export function RepacksModal({
|
|||||||
filteredRepacks.map((repack) => {
|
filteredRepacks.map((repack) => {
|
||||||
const isLastDownloadedOption =
|
const isLastDownloadedOption =
|
||||||
checkIfLastDownloadedOption(repack);
|
checkIfLastDownloadedOption(repack);
|
||||||
|
const availabilityStatus = getRepackAvailabilityStatus(repack);
|
||||||
|
const tooltipId = `availability-orb-${repack.id}`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Button
|
<Button
|
||||||
@@ -371,6 +388,13 @@ export function RepacksModal({
|
|||||||
onClick={() => handleRepackClick(repack)}
|
onClick={() => handleRepackClick(repack)}
|
||||||
className="repacks-modal__repack-button"
|
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">
|
<p className="repacks-modal__repack-title">
|
||||||
{repack.title}
|
{repack.title}
|
||||||
{userPreferences?.enableNewDownloadOptionsBadges !==
|
{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"
|
resolved "https://registry.yarnpkg.com/get-nonce/-/get-nonce-1.0.1.tgz#fdf3f0278073820d2ce9426c18f07481b1e0cdf3"
|
||||||
integrity sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==
|
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:
|
get-proto@^1.0.0, get-proto@^1.0.1:
|
||||||
version "1.0.1"
|
version "1.0.1"
|
||||||
resolved "https://registry.yarnpkg.com/get-proto/-/get-proto-1.0.1.tgz#150b3f2743869ef3e851ec0c49d15b1d14d00ee1"
|
resolved "https://registry.yarnpkg.com/get-proto/-/get-proto-1.0.1.tgz#150b3f2743869ef3e851ec0c49d15b1d14d00ee1"
|
||||||
|
|||||||
Reference in New Issue
Block a user