refactor: replace Python RPC and aria2 with native addon

This commit is contained in:
Chubby Granny Chaser
2026-03-20 11:43:16 +00:00
parent ba574b51f5
commit c8e368fa8f
39 changed files with 5804 additions and 1572 deletions
+1
View File
@@ -4,4 +4,5 @@ out
.gitignore
migration.stub
hydra-python-rpc/
hydra-native/
src/main/generated/
+3 -17
View File
@@ -28,26 +28,12 @@ jobs:
with:
node-version: 22.21.0
- name: Install Rust
uses: dtolnay/rust-toolchain@stable
- name: Install dependencies
run: yarn --frozen-lockfile
- name: Install Python
uses: actions/setup-python@v5
with:
python-version: 3.9
- name: Install dependencies
run: pip install -r requirements.txt
- name: Build with cx_Freeze
run: python python_rpc/setup.py build
- name: Copy OpenSSL DLLs
if: matrix.os == 'windows-2022'
run: |
cp hydra-python-rpc/lib/libcrypto-1_1.dll hydra-python-rpc/lib/libcrypto-1_1-x64.dll
cp hydra-python-rpc/lib/libssl-1_1.dll hydra-python-rpc/lib/libssl-1_1-x64.dll
- name: Build Linux
if: matrix.os == 'ubuntu-latest'
run: |
+1 -1
View File
@@ -20,7 +20,7 @@ jobs:
node-version: 22.21.0
- name: Install dependencies
run: yarn --frozen-lockfile
run: yarn --frozen-lockfile --ignore-scripts
- name: Validate current commit (last commit) with commitlint
run: npx commitlint --last --verbose
+3 -17
View File
@@ -26,26 +26,12 @@ jobs:
with:
node-version: 22.21.0
- name: Install Rust
uses: dtolnay/rust-toolchain@stable
- name: Install dependencies
run: yarn --frozen-lockfile
- name: Install Python
uses: actions/setup-python@v5
with:
python-version: 3.9
- name: Install dependencies
run: pip install -r requirements.txt
- name: Build with cx_Freeze
run: python python_rpc/setup.py build
- name: Copy OpenSSL DLLs
if: matrix.os == 'windows-2022'
run: |
cp hydra-python-rpc/lib/libcrypto-1_1.dll hydra-python-rpc/lib/libcrypto-1_1-x64.dll
cp hydra-python-rpc/lib/libssl-1_1.dll hydra-python-rpc/lib/libssl-1_1-x64.dll
- name: Build Linux
if: matrix.os == 'ubuntu-latest'
run: |
+2
View File
@@ -10,6 +10,8 @@ out
ludusavi/**
!ludusavi/config.yaml
hydra-python-rpc/
/hydra-native/
native/hydra-native/target/
.python-version
# Sentry Config File
+8 -1
View File
@@ -5,7 +5,7 @@
<h1 align="center">Hydra Launcher</h1>
<p align="center">
<strong>Hydra Launcher is an open-source gaming platform created to be the single tool that you need in order to manage your gaming library. Hydra is written in Node.js (Electron, React, Typescript) and Python.</strong>
<strong>Hydra Launcher is an open-source gaming platform created to be the single tool that you need in order to manage your gaming library. Hydra is written in Node.js (Electron, React, Typescript) and Rust.</strong>
</p>
[![build](https://img.shields.io/github/actions/workflow/status/hydralauncher/hydra/build.yml)](https://github.com/hydralauncher/hydra/actions)
@@ -29,6 +29,13 @@
Please, refer to our Documentation pages: [docs.hydralauncher.gg](https://docs.hydralauncher.gg/getting-started)
### Local development requirements
- Node.js + Yarn
- Rust toolchain (for `hydra-native`)
After installing dependencies, `postinstall` now builds the Rust native addon automatically (`hydra-native/hydra-native.node`).
## Contributors
<a href="https://github.com/hydralauncher/hydra/graphs/contributors">
BIN
View File
Binary file not shown.
Binary file not shown.
+1 -3
View File
@@ -4,7 +4,7 @@ directories:
buildResources: build
extraResources:
- ludusavi
- hydra-python-rpc
- hydra-native
- seeds
- from: node_modules/create-desktop-shortcuts/src/windows.vbs
- from: resources/achievement.wav
@@ -22,7 +22,6 @@ asarUnpack:
win:
executableName: Hydra
extraResources:
- from: binaries/aria2c.exe
- from: binaries/7z.exe
- from: binaries/7z.dll
target:
@@ -53,7 +52,6 @@ dmg:
linux:
extraResources:
- from: binaries/7zzs
- from: binaries/aria2c
- from: binaries/umu/umu-run
target:
- AppImage
+3670
View File
File diff suppressed because it is too large Load Diff
+27
View File
@@ -0,0 +1,27 @@
[package]
name = "hydra-native"
version = "0.1.0"
edition = "2021"
[lib]
crate-type = ["cdylib"]
[dependencies]
anyhow = "1.0.100"
image = { version = "0.25.8", default-features = false, features = ["gif", "jpeg", "png", "webp"] }
librqbit = "8.1.1"
mime_guess = "2.0.5"
napi = { version = "3.5.2", default-features = false, features = ["napi8"] }
napi-derive = "3.3.2"
once_cell = "1.21.3"
data-encoding = "2.9.0"
regex = "1.12.2"
reqwest = { version = "0.12.24", default-features = false, features = ["rustls-tls"] }
sysinfo = "0.37.2"
tokio = { version = "1.48.0", features = ["rt-multi-thread", "time"] }
url = "2.5.7"
urlencoding = "2.1.3"
uuid = { version = "1.11.0", features = ["v4"] }
[build-dependencies]
napi-build = "2.3.1"
+3
View File
@@ -0,0 +1,3 @@
fn main() {
napi_build::setup();
}
+226
View File
@@ -0,0 +1,226 @@
use std::fs::File;
use std::io::BufReader;
use std::path::{Path, PathBuf};
use std::{cmp::Ordering, collections::HashMap};
mod torrent;
use image::codecs::gif::GifDecoder;
use image::codecs::png::PngDecoder;
use image::codecs::webp::WebPDecoder;
use image::{AnimationDecoder, ImageFormat, ImageReader};
use napi::bindgen_prelude::Error;
use napi_derive::napi;
use sysinfo::{ProcessesToUpdate, System};
use uuid::Uuid;
#[napi(object)]
pub struct ProcessedImageData {
pub image_path: String,
pub mime_type: String,
}
#[napi(object)]
pub struct NativeProcessPayload {
pub exe: Option<String>,
pub pid: u32,
pub name: String,
pub environ: Option<HashMap<String, String>>,
pub cwd: Option<String>,
}
#[napi]
pub fn process_profile_image(
image_path: String,
target_extension: Option<String>,
) -> napi::Result<ProcessedImageData> {
let input_path = PathBuf::from(image_path);
if !input_path.exists() {
return Err(Error::from_reason("Image file not found"));
}
let format = detect_image_format(&input_path)?;
let animated = is_animated_image(&input_path, format)?;
if !animated {
return Ok(ProcessedImageData {
image_path: input_path.to_string_lossy().to_string(),
mime_type: mime_type_from_format_or_path(format, &input_path),
});
}
let extension = target_extension
.map(|value| value.to_ascii_lowercase())
.unwrap_or_else(|| "webp".to_string());
let output_format = output_format_from_extension(&extension)?;
let output_path = build_temp_output_path(&extension);
let image = ImageReader::open(&input_path)
.map_err(|err| Error::from_reason(err.to_string()))?
.with_guessed_format()
.map_err(|err| Error::from_reason(err.to_string()))?
.decode()
.map_err(|err| Error::from_reason(err.to_string()))?;
image
.save_with_format(&output_path, output_format)
.map_err(|err| Error::from_reason(err.to_string()))?;
Ok(ProcessedImageData {
image_path: output_path.to_string_lossy().to_string(),
mime_type: mime_type_from_format_or_path(Some(output_format), &output_path),
})
}
#[napi]
pub fn list_processes() -> Vec<NativeProcessPayload> {
let mut system = System::new_all();
system.refresh_processes(ProcessesToUpdate::All, true);
let mut processes: Vec<NativeProcessPayload> = system
.processes()
.values()
.map(|process| {
let include_linux_extras = !cfg!(target_os = "windows");
NativeProcessPayload {
exe: process
.exe()
.map(|value| value.to_string_lossy().to_string()),
pid: process.pid().as_u32(),
name: process.name().to_string_lossy().to_string(),
cwd: if include_linux_extras {
process
.cwd()
.map(|value| value.to_string_lossy().to_string())
} else {
None
},
environ: if include_linux_extras {
let env_map: HashMap<String, String> = process
.environ()
.iter()
.filter_map(|entry| {
let entry_value = entry.to_string_lossy();
entry_value.split_once('=').and_then(|(key, value)| {
if key.is_empty() {
None
} else {
Some((key.to_string(), value.to_string()))
}
})
})
.collect();
if env_map.is_empty() {
None
} else {
Some(env_map)
}
} else {
None
},
}
})
.collect();
processes.sort_by(|left, right| {
let by_pid = left.pid.cmp(&right.pid);
if by_pid == Ordering::Equal {
left.name.cmp(&right.name)
} else {
by_pid
}
});
processes
}
fn detect_image_format(path: &Path) -> napi::Result<Option<ImageFormat>> {
let reader = ImageReader::open(path).map_err(|err| Error::from_reason(err.to_string()))?;
let guessed = reader
.with_guessed_format()
.map_err(|err| Error::from_reason(err.to_string()))?;
Ok(guessed.format())
}
fn is_animated_image(path: &Path, format: Option<ImageFormat>) -> napi::Result<bool> {
match format {
Some(ImageFormat::Gif) => is_gif_animated(path),
Some(ImageFormat::WebP) => is_webp_animated(path),
Some(ImageFormat::Png) => is_apng(path),
_ => Ok(false),
}
}
fn is_gif_animated(path: &Path) -> napi::Result<bool> {
let file = File::open(path).map_err(|err| Error::from_reason(err.to_string()))?;
let decoder =
GifDecoder::new(BufReader::new(file)).map_err(|err| Error::from_reason(err.to_string()))?;
let mut frames = decoder.into_frames();
let _ = frames.next().transpose();
Ok(matches!(frames.next().transpose(), Ok(Some(_))))
}
fn is_webp_animated(path: &Path) -> napi::Result<bool> {
let file = File::open(path).map_err(|err| Error::from_reason(err.to_string()))?;
let decoder = WebPDecoder::new(BufReader::new(file))
.map_err(|err| Error::from_reason(err.to_string()))?;
Ok(decoder.has_animation())
}
fn is_apng(path: &Path) -> napi::Result<bool> {
let file = File::open(path).map_err(|err| Error::from_reason(err.to_string()))?;
let decoder =
PngDecoder::new(BufReader::new(file)).map_err(|err| Error::from_reason(err.to_string()))?;
decoder
.is_apng()
.map_err(|err| Error::from_reason(err.to_string()))
}
fn output_format_from_extension(extension: &str) -> napi::Result<ImageFormat> {
match extension {
"png" => Ok(ImageFormat::Png),
"jpg" | "jpeg" => Ok(ImageFormat::Jpeg),
"webp" => Ok(ImageFormat::WebP),
_ => Err(Error::from_reason("Unsupported target extension")),
}
}
fn build_temp_output_path(extension: &str) -> PathBuf {
let mut output_path = std::env::temp_dir();
output_path.push(format!("{}.{}", Uuid::new_v4(), extension));
output_path
}
fn mime_type_from_format_or_path(format: Option<ImageFormat>, path: &Path) -> String {
if let Some(value) = mime_type_from_image_format(format) {
return value.to_string();
}
mime_guess::from_path(path)
.first_or_octet_stream()
.essence_str()
.to_string()
}
fn mime_type_from_image_format(format: Option<ImageFormat>) -> Option<&'static str> {
match format {
Some(ImageFormat::Png) => Some("image/png"),
Some(ImageFormat::Jpeg) => Some("image/jpeg"),
Some(ImageFormat::Gif) => Some("image/gif"),
Some(ImageFormat::WebP) => Some("image/webp"),
Some(ImageFormat::Bmp) => Some("image/bmp"),
Some(ImageFormat::Ico) => Some("image/x-icon"),
Some(ImageFormat::Tiff) => Some("image/tiff"),
Some(ImageFormat::Avif) => Some("image/avif"),
_ => None,
}
}
File diff suppressed because it is too large Load Diff
+5 -4
View File
@@ -20,14 +20,15 @@
"typecheck:node": "tsc --noEmit -p tsconfig.node.json --composite false",
"typecheck:web": "tsc --noEmit -p tsconfig.web.json --composite false",
"typecheck": "npm run typecheck:node && npm run typecheck:web",
"build:native": "node ./scripts/build-native-addon.cjs",
"start": "electron-vite preview",
"dev": "electron-vite dev",
"build": "npm run typecheck && electron-vite build",
"postinstall": "electron-builder install-app-deps && node ./scripts/postinstall.cjs",
"postinstall": "npm run build:native && electron-builder install-app-deps && node ./scripts/postinstall.cjs",
"build:unpack": "npm run build && electron-builder --dir",
"build:win": "electron-vite build && electron-builder --win",
"build:mac": "electron-vite build && electron-builder --mac",
"build:linux": "electron-vite build && electron-builder --linux",
"build:win": "npm run build:native && electron-vite build && electron-builder --win",
"build:mac": "npm run build:native && electron-vite build && electron-builder --mac",
"build:linux": "npm run build:native && electron-vite build && electron-builder --linux",
"prepare": "husky",
"protoc": "npx protoc --ts_out src/main/generated --proto_path proto proto/*.proto"
},
-68
View File
@@ -1,68 +0,0 @@
import aria2p
from aria2p.client import ClientException as DownloadNotFound
class HttpDownloader:
def __init__(self):
self.download = None
self.max_download_speed = None
self.aria2 = aria2p.API(
aria2p.Client(
host="http://localhost",
port=6800,
secret=""
)
)
def set_download_limit(self, max_download_speed: int = None):
self.max_download_speed = max_download_speed if max_download_speed and max_download_speed > 0 else None
speed_limit = str(self.max_download_speed) if self.max_download_speed else "0"
try:
self.aria2.set_global_options({"max-overall-download-limit": speed_limit})
except Exception:
pass
def start_download(self, url: str, save_path: str, header, out: str = None):
if self.download:
self.aria2.resume([self.download])
else:
options = {"dir": save_path}
if self.max_download_speed:
options["max-download-limit"] = str(self.max_download_speed)
if header:
options["header"] = header
if out:
options["out"] = out
downloads = self.aria2.add(url, options=options)
self.download = downloads[0]
def pause_download(self):
if self.download:
self.aria2.pause([self.download])
def cancel_download(self):
if self.download:
self.aria2.remove([self.download])
self.download = None
def get_download_status(self):
if self.download == None:
return None
try:
download = self.aria2.get_download(self.download.gid)
except DownloadNotFound:
self.download = None
return None
response = {
'folderName': download.name,
'fileSize': download.total_length,
'progress': download.completed_length / download.total_length if download.total_length else 0,
'downloadSpeed': download.download_speed,
'numPeers': 0,
'numSeeds': 0,
'status': download.status,
'bytesDownloaded': download.completed_length,
}
return response
-541
View File
@@ -1,541 +0,0 @@
import hmac
import json
import logging
import re
import sys
import tempfile
import threading
import time
import urllib.parse
import libtorrent as lt
import psutil
from flask import Flask, jsonify, request
from http_downloader import HttpDownloader
from profile_image_processor import ProfileImageProcessor
from torrent_downloader import TorrentDownloader
app = Flask(__name__)
logging.basicConfig(
level=logging.INFO,
format="[%(asctime)s] %(levelname)s %(name)s: %(message)s",
)
logger = logging.getLogger("hydra.rpc")
# Retrieve command line arguments
torrent_port = sys.argv[1]
http_port = sys.argv[2]
rpc_password = sys.argv[3]
start_download_payload = sys.argv[4]
start_seeding_payload = sys.argv[5]
downloads = {}
downloads_lock = threading.RLock()
metadata_semaphore = threading.BoundedSemaphore(value=2)
# This can be streamed down from Node
downloading_game_id = -1
current_download_limit = None
torrent_session = lt.session(
{"listen_interfaces": "0.0.0.0:{port}".format(port=torrent_port)}
)
MAGNET_HASH_HEX_RE = re.compile(r"^[a-fA-F0-9]{40}$")
MAGNET_HASH_BASE32_RE = re.compile(r"^[a-zA-Z2-7]{32}$")
TORRENT_FILES_CACHE_TTL_SECONDS = 300
TORRENT_FILES_CACHE_MAX_ITEMS = 128
torrent_files_cache = {}
torrent_files_cache_lock = threading.RLock()
def load_json_payload(raw_payload: str):
if not raw_payload:
return None
return json.loads(urllib.parse.unquote(raw_payload))
def parse_file_indices(file_indices):
if file_indices is None:
return None
if not isinstance(file_indices, list):
raise ValueError("invalid_file_indices")
parsed = []
for index in file_indices:
if isinstance(index, bool) or not isinstance(index, int):
raise ValueError("invalid_file_indices")
parsed.append(index)
return parsed
def validate_magnet_uri(magnet: str):
if not isinstance(magnet, str):
raise ValueError("invalid_magnet")
magnet = magnet.strip()
if not magnet.startswith("magnet:"):
raise ValueError("invalid_magnet")
if len(magnet) > 8192:
raise ValueError("invalid_magnet")
parsed = urllib.parse.urlparse(magnet)
if parsed.scheme != "magnet":
raise ValueError("invalid_magnet")
query = urllib.parse.parse_qs(parsed.query)
xt_values = query.get("xt") or []
info_hash = None
for xt in xt_values:
if not xt.startswith("urn:btih:"):
continue
hash_candidate = xt[len("urn:btih:") :].strip()
if MAGNET_HASH_HEX_RE.match(hash_candidate) or MAGNET_HASH_BASE32_RE.match(
hash_candidate
):
info_hash = hash_candidate.lower()
break
if info_hash is None:
raise ValueError("invalid_magnet")
return magnet, info_hash
def get_cached_torrent_files(info_hash: str):
with torrent_files_cache_lock:
item = torrent_files_cache.get(info_hash)
if not item:
return None
if time.time() - item["timestamp"] > TORRENT_FILES_CACHE_TTL_SECONDS:
torrent_files_cache.pop(info_hash, None)
return None
return item["value"]
def set_cached_torrent_files(info_hash: str, value):
with torrent_files_cache_lock:
if len(torrent_files_cache) >= TORRENT_FILES_CACHE_MAX_ITEMS:
oldest_key = min(
torrent_files_cache,
key=lambda cache_key: torrent_files_cache[cache_key]["timestamp"],
)
torrent_files_cache.pop(oldest_key, None)
torrent_files_cache[info_hash] = {
"timestamp": time.time(),
"value": value,
}
def map_downloader_error(error: Exception):
code = str(error)
if isinstance(error, TimeoutError) or code == "metadata_timeout":
return jsonify({"error": "metadata_timeout"}), 408
if code in {
"invalid_magnet",
"invalid_file_indices",
"empty_selection",
"invalid_url",
"invalid_save_path",
}:
return jsonify({"error": code}), 400
if code == "metadata_incomplete":
return jsonify({"error": "metadata_incomplete"}), 422
if code == "too_many_files":
return jsonify({"error": "too_many_files"}), 413
logger.error("Unhandled RPC error: %s", error, exc_info=True)
return jsonify({"error": "internal_error"}), 500
def normalize_download_limit(value):
try:
parsed = int(value)
except (TypeError, ValueError):
return None
return parsed if parsed > 0 else None
def apply_download_limit(downloader):
if not downloader:
return
set_download_limit = getattr(downloader, "set_download_limit", None)
if callable(set_download_limit):
set_download_limit(current_download_limit)
def validate_rpc_password():
"""Middleware to validate RPC password."""
header_password = request.headers.get("x-hydra-rpc-password")
if not isinstance(header_password, str) or not hmac.compare_digest(
header_password, rpc_password
):
return jsonify({"error": "Unauthorized"}), 401
def start_torrent_download(game_id, url, save_path, file_indices=None, flags=None):
with downloads_lock:
existing_downloader = downloads.get(game_id)
if existing_downloader and isinstance(existing_downloader, TorrentDownloader):
apply_download_limit(existing_downloader)
existing_downloader.start_download(url, save_path, file_indices=file_indices)
return
torrent_downloader = TorrentDownloader(
torrent_session,
flags or lt.torrent_flags.auto_managed,
session_lock=downloads_lock,
)
apply_download_limit(torrent_downloader)
with downloads_lock:
downloads[game_id] = torrent_downloader
try:
torrent_downloader.start_download(url, save_path, file_indices=file_indices)
except Exception:
with downloads_lock:
downloads.pop(game_id, None)
raise
def start_http_download(game_id, url, save_path, header=None, out=None):
with downloads_lock:
existing_downloader = downloads.get(game_id)
if existing_downloader and isinstance(existing_downloader, HttpDownloader):
apply_download_limit(existing_downloader)
existing_downloader.start_download(url, save_path, header, out)
return
http_downloader = HttpDownloader()
apply_download_limit(http_downloader)
with downloads_lock:
downloads[game_id] = http_downloader
try:
http_downloader.start_download(url, save_path, header, out)
except Exception:
with downloads_lock:
downloads.pop(game_id, None)
raise
def bootstrap_downloads():
global downloading_game_id
initial_download = load_json_payload(start_download_payload)
if initial_download:
downloading_game_id = initial_download["game_id"]
try:
if initial_download["url"].startswith("magnet"):
file_indices = parse_file_indices(initial_download.get("file_indices"))
start_torrent_download(
initial_download["game_id"],
initial_download["url"],
initial_download["save_path"],
file_indices=file_indices,
)
else:
start_http_download(
initial_download["game_id"],
initial_download["url"],
initial_download["save_path"],
initial_download.get("header"),
initial_download.get("out"),
)
except Exception as error:
logger.error("Error starting initial download: %s", error, exc_info=True)
initial_seeding = load_json_payload(start_seeding_payload)
if initial_seeding:
for seed in initial_seeding:
try:
start_torrent_download(
seed["game_id"],
seed["url"],
seed["save_path"],
flags=lt.torrent_flags.upload_mode,
)
except Exception as error:
logger.error("Error starting initial seeding: %s", error, exc_info=True)
@app.route("/status", methods=["GET"])
def status():
auth_error = validate_rpc_password()
if auth_error:
return auth_error
with downloads_lock:
downloader = downloads.get(downloading_game_id)
if not downloader:
return jsonify(None)
status_payload = downloader.get_download_status()
if not status_payload:
return jsonify(None)
return jsonify(status_payload), 200
@app.route("/seed-status", methods=["GET"])
def seed_status():
auth_error = validate_rpc_password()
if auth_error:
return auth_error
with downloads_lock:
download_items = list(downloads.items())
seed_payload = []
for game_id, downloader in download_items:
if not downloader:
continue
response = downloader.get_download_status()
if not response:
continue
if response.get("status") == 5: # Torrent seeding check
seed_payload.append(
{
"gameId": game_id,
**response,
}
)
return jsonify(seed_payload), 200
@app.route("/healthcheck", methods=["GET"])
def healthcheck():
return "ok", 200
@app.route("/torrent-files", methods=["POST"])
def torrent_files():
auth_error = validate_rpc_password()
if auth_error:
return auth_error
data = request.get_json(silent=True) or {}
try:
magnet, info_hash = validate_magnet_uri(data.get("magnet"))
except Exception as error:
return map_downloader_error(error)
cached_payload = get_cached_torrent_files(info_hash)
if cached_payload is not None:
return jsonify(cached_payload), 200
timeout_ms = data.get("timeout_ms", 30000)
try:
timeout_ms = int(timeout_ms)
except (TypeError, ValueError):
timeout_ms = 30000
timeout_ms = max(5000, min(timeout_ms, 120000))
timeout_seconds = timeout_ms / 1000
if not metadata_semaphore.acquire(timeout=5):
return jsonify({"error": "metadata_busy"}), 429
temp_downloader = TorrentDownloader(
torrent_session,
lt.torrent_flags.upload_mode,
session_lock=downloads_lock,
)
started_at = time.time()
try:
temp_downloader.start_download(magnet, tempfile.gettempdir())
files_payload = temp_downloader.get_torrent_files(timeout_seconds=timeout_seconds)
response = {
"infoHash": info_hash,
**files_payload,
}
set_cached_torrent_files(info_hash, response)
elapsed_ms = int((time.time() - started_at) * 1000)
logger.info("Resolved torrent metadata hash=%s in %sms", info_hash, elapsed_ms)
return jsonify(response), 200
except Exception as error:
return map_downloader_error(error)
finally:
temp_downloader.cancel_download()
metadata_semaphore.release()
@app.route("/process-list", methods=["GET"])
def process_list():
auth_error = validate_rpc_password()
if auth_error:
return auth_error
iter_list = ["exe", "pid", "name"]
if sys.platform != "win32":
iter_list.append("cwd")
iter_list.append("environ")
process_list_payload = [proc.info for proc in psutil.process_iter(iter_list)]
return jsonify(process_list_payload), 200
@app.route("/profile-image", methods=["POST"])
def profile_image():
auth_error = validate_rpc_password()
if auth_error:
return auth_error
data = request.get_json()
image_path = data.get("image_path")
# use webp as default value for target_extension
target_extension = data.get("target_extension") or "webp"
try:
processed_image_path, mime_type = ProfileImageProcessor.process_image(
image_path, target_extension
)
return jsonify({"imagePath": processed_image_path, "mimeType": mime_type}), 200
except Exception as error:
return jsonify({"error": str(error)}), 400
@app.route("/action", methods=["POST"])
def action():
global downloading_game_id
global current_download_limit
auth_error = validate_rpc_password()
if auth_error:
return auth_error
data = request.get_json(silent=True) or {}
action_name = data.get("action")
game_id = data.get("game_id")
if not action_name:
return jsonify({"error": "invalid_action"}), 400
requires_game_id = {"start", "pause", "cancel", "resume_seeding", "pause_seeding"}
if action_name in requires_game_id and not game_id:
return jsonify({"error": "invalid_game_id"}), 400
try:
if action_name == "start":
url = data.get("url")
if not isinstance(url, str):
raise ValueError("invalid_url")
save_path = data.get("save_path")
if not isinstance(save_path, str):
raise ValueError("invalid_save_path")
if url.startswith("magnet"):
file_indices = parse_file_indices(data.get("file_indices"))
start_torrent_download(
game_id,
url,
save_path,
file_indices=file_indices,
)
else:
start_http_download(
game_id,
url,
save_path,
data.get("header"),
data.get("out"),
)
downloading_game_id = game_id
elif action_name == "pause":
with downloads_lock:
downloader = downloads.get(game_id)
if downloader:
downloader.pause_download()
if downloading_game_id == game_id:
downloading_game_id = -1
elif action_name == "cancel":
with downloads_lock:
downloader = downloads.get(game_id)
if downloader:
downloader.cancel_download()
with downloads_lock:
downloads.pop(game_id, None)
if downloading_game_id == game_id:
downloading_game_id = -1
elif action_name == "resume_seeding":
start_torrent_download(
game_id,
data["url"],
data["save_path"],
flags=lt.torrent_flags.upload_mode,
)
elif action_name == "pause_seeding":
with downloads_lock:
downloader = downloads.get(game_id)
if downloader:
downloader.cancel_download()
with downloads_lock:
downloads.pop(game_id, None)
elif action_name == "set_download_limit":
current_download_limit = normalize_download_limit(
data.get("max_download_speed_bytes_per_second")
)
with downloads_lock:
active_downloaders = list(downloads.values())
for downloader in active_downloaders:
apply_download_limit(downloader)
else:
return jsonify({"error": "invalid_action"}), 400
except Exception as error:
return map_downloader_error(error)
return "", 200
bootstrap_downloads()
if __name__ == "__main__":
app.run(host="127.0.0.1", port=int(http_port), threaded=True)
-30
View File
@@ -1,30 +0,0 @@
from PIL import Image
import os, uuid, tempfile
class ProfileImageProcessor:
@staticmethod
def get_parsed_image_data(image_path, target_extension):
Image.MAX_IMAGE_PIXELS = 933120000
image = Image.open(image_path)
try:
image.seek(1)
except EOFError:
mime_type = image.get_format_mimetype()
return image_path, mime_type
else:
new_uuid = str(uuid.uuid4())
new_image_path = os.path.join(tempfile.gettempdir(), new_uuid) + "." + target_extension
image.save(new_image_path)
new_image = Image.open(new_image_path)
mime_type = new_image.get_format_mimetype()
return new_image_path, mime_type
@staticmethod
def process_image(image_path, target_extension):
return ProfileImageProcessor.get_parsed_image_data(image_path, target_extension)
-20
View File
@@ -1,20 +0,0 @@
from cx_Freeze import setup, Executable
# Dependencies are automatically detected, but it might need fine tuning.
build_exe_options = {
"packages": ["libtorrent"],
"build_exe": "hydra-python-rpc",
"include_msvcr": True
}
setup(
name="hydra-python-rpc",
version="0.1",
description="Hydra",
options={"build_exe": build_exe_options},
executables=[Executable(
"python_rpc/main.py",
target_name="hydra-python-rpc",
icon="build/icon.ico"
)]
)
-391
View File
@@ -1,391 +0,0 @@
import logging
import threading
import time
from typing import List, Optional, Set
import libtorrent as lt
class TorrentDownloader:
def __init__(
self,
torrent_session,
flags=lt.torrent_flags.auto_managed,
session_lock: Optional[threading.RLock] = None,
):
self.torrent_handle = None
self.session = torrent_session
self.flags = flags
self.session_lock = session_lock or threading.RLock()
self.selected_file_indices = None
self.selected_size_bytes = None
self.logger = logging.getLogger("hydra.torrent")
self.trackers = [
"udp://tracker.opentrackr.org:1337/announce",
"http://tracker.opentrackr.org:1337/announce",
"udp://open.tracker.cl:1337/announce",
"udp://open.demonii.com:1337/announce",
"udp://open.stealth.si:80/announce",
"udp://tracker.torrent.eu.org:451/announce",
"udp://exodus.desync.com:6969/announce",
"udp://tracker.theoks.net:6969/announce",
"udp://tracker-udp.gbitt.info:80/announce",
"udp://explodie.org:6969/announce",
"https://tracker.tamersunion.org:443/announce",
"udp://tracker2.dler.org:80/announce",
"udp://tracker1.myporn.club:9337/announce",
"udp://tracker.tiny-vps.com:6969/announce",
"udp://tracker.dler.org:6969/announce",
"udp://tracker.bittor.pw:1337/announce",
"udp://tracker.0x7c0.com:6969/announce",
"udp://retracker01-msk-virt.corbina.net:80/announce",
"udp://opentracker.io:6969/announce",
"udp://open.free-tracker.ga:6969/announce",
"udp://new-line.net:6969/announce",
"udp://moonburrow.club:6969/announce",
"udp://leet-tracker.moe:1337/announce",
"udp://bt2.archive.org:6969/announce",
"udp://bt1.archive.org:6969/announce",
"http://tracker2.dler.org:80/announce",
"http://tracker1.bt.moack.co.kr:80/announce",
"http://tracker.dler.org:6969/announce",
"http://tr.kxmp.cf:80/announce",
"udp://u.peer-exchange.download:6969/announce",
"udp://ttk2.nbaonlineservice.com:6969/announce",
"udp://tracker.tryhackx.org:6969/announce",
"udp://tracker.srv00.com:6969/announce",
"udp://tracker.skynetcloud.site:6969/announce",
"udp://tracker.jamesthebard.net:6969/announce",
"udp://tracker.fnix.net:6969/announce",
"udp://tracker.filemail.com:6969/announce",
"udp://tracker.farted.net:6969/announce",
"udp://tracker.edkj.club:6969/announce",
"udp://tracker.dump.cl:6969/announce",
"udp://tracker.deadorbit.nl:6969/announce",
"udp://tracker.darkness.services:6969/announce",
"udp://tracker.ccp.ovh:6969/announce",
"udp://tamas3.ynh.fr:6969/announce",
"udp://ryjer.com:6969/announce",
"udp://run.publictracker.xyz:6969/announce",
"udp://public.tracker.vraphim.com:6969/announce",
"udp://p4p.arenabg.com:1337/announce",
"udp://p2p.publictracker.xyz:6969/announce",
"udp://open.u-p.pw:6969/announce",
"udp://open.publictracker.xyz:6969/announce",
"udp://open.dstud.io:6969/announce",
"udp://open.demonoid.ch:6969/announce",
"udp://odd-hd.fr:6969/announce",
"udp://martin-gebhardt.eu:25/announce",
"udp://jutone.com:6969/announce",
"udp://isk.richardsw.club:6969/announce",
"udp://evan.im:6969/announce",
"udp://epider.me:6969/announce",
"udp://d40969.acod.regrucolo.ru:6969/announce",
"udp://bt.rer.lol:6969/announce",
"udp://amigacity.xyz:6969/announce",
"udp://1c.premierzal.ru:6969/announce",
"https://trackers.run:443/announce",
"https://tracker.yemekyedim.com:443/announce",
"https://tracker.renfei.net:443/announce",
"https://tracker.pmman.tech:443/announce",
"https://tracker.lilithraws.org:443/announce",
"https://tracker.imgoingto.icu:443/announce",
"https://tracker.cloudit.top:443/announce",
"https://tracker-zhuqiy.dgj055.icu:443/announce",
"http://tracker.renfei.net:8080/announce",
"http://tracker.mywaifu.best:6969/announce",
"http://tracker.ipv6tracker.org:80/announce",
"http://tracker.files.fm:6969/announce",
"http://tracker.edkj.club:6969/announce",
"http://tracker.bt4g.com:2095/announce",
"http://tracker-zhuqiy.dgj055.icu:80/announce",
"http://t1.aag.moe:17715/announce",
"http://t.overflow.biz:6969/announce",
"http://bittorrent-tracker.e-n-c-r-y-p-t.net:1337/announce",
"udp://torrents.artixlinux.org:6969/announce",
"udp://mail.artixlinux.org:6969/announce",
"udp://ipv4.rer.lol:2710/announce",
"udp://concen.org:6969/announce",
"udp://bt.rer.lol:2710/announce",
"udp://aegir.sexy:6969/announce",
"https://www.peckservers.com:9443/announce",
"https://tracker.ipfsscan.io:443/announce",
"https://tracker.gcrenwp.top:443/announce",
"http://www.peckservers.com:9000/announce",
"http://tracker1.itzmx.com:8080/announce",
"http://ch3oh.ru:6969/announce",
"http://bvarf.tracker.sh:2086/announce",
]
def set_download_limit(self, max_download_speed: int = None):
download_limit = (
max_download_speed if max_download_speed and max_download_speed > 0 else 0
)
try:
self.session.set_download_rate_limit(download_limit)
except Exception:
pass
def _wait_for_metadata(self, timeout_seconds: float = 30.0, poll_interval: float = 0.25):
if not self.torrent_handle or not self.torrent_handle.is_valid():
return False
deadline = time.monotonic() + max(timeout_seconds, 1.0)
while time.monotonic() < deadline:
try:
status = self.torrent_handle.status()
except RuntimeError:
return False
if status.has_metadata:
return True
time.sleep(max(poll_interval, 0.05))
return False
def wait_for_metadata(self, timeout_seconds: float = 30.0):
return self._wait_for_metadata(timeout_seconds=timeout_seconds)
def _sanitize_file_indices(self, file_indices: List[int], files_storage):
if file_indices is None:
return None
if not isinstance(file_indices, list):
raise ValueError("invalid_file_indices")
max_index = files_storage.num_files() - 1
sanitized: Set[int] = set()
for index in file_indices:
if isinstance(index, bool) or not isinstance(index, int):
raise ValueError("invalid_file_indices")
if index < 0 or index > max_index:
raise ValueError("invalid_file_indices")
sanitized.add(index)
if not sanitized:
raise ValueError("empty_selection")
return sorted(sanitized)
def _set_selected_file_priorities(self, selected_indices: List[int], files_storage):
priorities = [0] * files_storage.num_files()
for index in selected_indices:
priorities[index] = 1
self.torrent_handle.prioritize_files(priorities)
deadline = time.monotonic() + 3.0
while time.monotonic() < deadline:
try:
current_priorities = [int(priority) for priority in self.torrent_handle.get_file_priorities()]
except RuntimeError:
break
if current_priorities == priorities:
return
time.sleep(0.1)
self.logger.warning("File priority synchronization timeout")
def start_download(
self,
magnet: str,
save_path: str,
file_indices: Optional[List[int]] = None,
wait_timeout_seconds: float = 30.0,
):
selective_download = file_indices is not None
with self.session_lock:
if self.torrent_handle and self.torrent_handle.is_valid():
if not selective_download:
self.torrent_handle.set_flags(lt.torrent_flags.auto_managed)
self.torrent_handle.resume()
return
self.torrent_handle.pause()
self.session.remove_torrent(self.torrent_handle)
self.torrent_handle = None
initial_flags = self.flags | lt.torrent_flags.paused
if selective_download:
initial_flags |= lt.torrent_flags.default_dont_download
initial_flags |= lt.torrent_flags.auto_managed
else:
initial_flags |= lt.torrent_flags.auto_managed
params = {
"url": magnet,
"save_path": save_path,
"trackers": self.trackers,
"flags": initial_flags,
}
if self.torrent_handle is None or not self.torrent_handle.is_valid():
self.torrent_handle = self.session.add_torrent(params)
self.selected_file_indices = None
self.selected_size_bytes = None
if selective_download:
try:
self.torrent_handle.set_flags(lt.torrent_flags.auto_managed)
self.torrent_handle.resume()
if not self._wait_for_metadata(timeout_seconds=wait_timeout_seconds):
raise TimeoutError("metadata_timeout")
try:
info = self.torrent_handle.get_torrent_info()
files_storage = info.files()
except RuntimeError as error:
raise RuntimeError("metadata_incomplete") from error
self.torrent_handle.pause()
self.torrent_handle.unset_flags(lt.torrent_flags.auto_managed)
sanitized_indices = self._sanitize_file_indices(file_indices, files_storage)
self._set_selected_file_priorities(sanitized_indices, files_storage)
self.selected_file_indices = sanitized_indices
self.selected_size_bytes = sum(files_storage.file_size(index) for index in sanitized_indices)
except Exception:
self.cancel_download()
raise
self.torrent_handle.set_flags(lt.torrent_flags.auto_managed)
self.torrent_handle.resume()
def get_torrent_files(self, timeout_seconds: float = 30.0, max_files: int = 100000):
if not self._wait_for_metadata(timeout_seconds=timeout_seconds):
raise TimeoutError("metadata_timeout")
try:
info = self.torrent_handle.get_torrent_info()
except RuntimeError as error:
raise RuntimeError("metadata_incomplete") from error
files_storage = info.files()
file_count = files_storage.num_files()
if file_count > max_files:
raise OverflowError("too_many_files")
files = []
for index in range(file_count):
files.append(
{
"index": index,
"path": files_storage.file_path(index),
"length": files_storage.file_size(index),
}
)
return {
"name": info.name(),
"totalSize": info.total_size(),
"files": files,
}
def pause_download(self):
if self.torrent_handle:
self.torrent_handle.pause()
self.torrent_handle.unset_flags(lt.torrent_flags.auto_managed)
def cancel_download(self):
with self.session_lock:
if self.torrent_handle:
if self.torrent_handle.is_valid():
self.torrent_handle.pause()
self.session.remove_torrent(self.torrent_handle, lt.session.delete_partfile)
self.torrent_handle = None
self.selected_file_indices = None
self.selected_size_bytes = None
def abort_session(self):
self.cancel_download()
self.session.abort()
self.torrent_handle = None
self.selected_file_indices = None
self.selected_size_bytes = None
def _get_handle_status(self):
if self.torrent_handle is None:
return None
if not self.torrent_handle.is_valid():
return None
try:
return self.torrent_handle.status()
except RuntimeError:
return None
def _get_torrent_info_if_available(self, status):
if not status.has_metadata:
return None
try:
return self.torrent_handle.get_torrent_info()
except RuntimeError:
return None
def _get_file_size(self, status, info):
total_wanted = getattr(status, "total_wanted", 0)
if total_wanted > 0:
return total_wanted
if self.selected_size_bytes is not None:
return self.selected_size_bytes
if info:
return info.total_size()
return 0
def _get_bytes_downloaded(self, status, file_size):
total_wanted_done = getattr(status, "total_wanted_done", -1)
if total_wanted_done >= 0:
return total_wanted_done
if file_size > 0:
return int(status.progress * file_size)
return status.all_time_download
def _get_progress(self, status, file_size, bytes_downloaded):
if file_size <= 0:
return status.progress
return min(max(bytes_downloaded / file_size, 0), 1)
def get_download_status(self):
status = self._get_handle_status()
if status is None:
return None
info = self._get_torrent_info_if_available(status)
file_size = self._get_file_size(status, info)
bytes_downloaded = self._get_bytes_downloaded(status, file_size)
progress = self._get_progress(status, file_size, bytes_downloaded)
response = {
'folderName': info.name() if info else "",
'fileSize': file_size,
'progress': progress,
'downloadSpeed': status.download_rate,
'uploadSpeed': status.upload_rate,
'numPeers': status.num_peers,
'numSeeds': status.num_seeds,
'status': status.state,
'bytesDownloaded': bytes_downloaded,
}
return response
-8
View File
@@ -1,8 +0,0 @@
libtorrent
cx_Freeze == 7.2.3
cx_Logging; sys_platform == 'win32'
pywin32; sys_platform == 'win32'
psutil
Pillow
flask
aria2p
+117
View File
@@ -0,0 +1,117 @@
const fs = require("node:fs");
const path = require("node:path");
const util = require("node:util");
const childProcess = require("node:child_process");
const execFile = util.promisify(childProcess.execFile);
const projectRoot = process.cwd();
const manifestPath = path.join(
projectRoot,
"native",
"hydra-native",
"Cargo.toml"
);
const cargoTargetDir = path.join(
projectRoot,
"native",
"hydra-native",
"target"
);
const outputDir = path.join(projectRoot, "hydra-native");
const outputNodePath = path.join(outputDir, "hydra-native.node");
const sourceLibraryNameByPlatform = {
linux: "libhydra_native.so",
darwin: "libhydra_native.dylib",
win32: "hydra_native.dll",
};
const run = async (command, args, options = {}) => {
await execFile(command, args, {
cwd: projectRoot,
maxBuffer: 1024 * 1024 * 10,
...options,
});
};
const ensureDepsResolvableOnLinux = async () => {
if (process.platform !== "linux") return;
const { stdout } = await execFile("ldd", [outputNodePath], {
cwd: projectRoot,
maxBuffer: 1024 * 1024 * 10,
});
if (stdout.includes("not found")) {
throw new Error(
`Unresolved dynamic dependencies found for ${outputNodePath}\n${stdout}`
);
}
};
const copySidecarLibrariesOnWindows = async (sourceDirectory) => {
if (process.platform !== "win32") return;
const candidateDlls = [
"libgcc_s_seh-1.dll",
"libstdc++-6.dll",
"libwinpthread-1.dll",
"vcruntime140.dll",
"vcruntime140_1.dll",
"msvcp140.dll",
];
for (const dll of candidateDlls) {
const sourcePath = path.join(sourceDirectory, dll);
if (!fs.existsSync(sourcePath)) continue;
const targetPath = path.join(outputDir, dll);
if (!fs.existsSync(targetPath)) {
fs.copyFileSync(sourcePath, targetPath);
}
}
};
const build = async () => {
const sourceLibraryName = sourceLibraryNameByPlatform[process.platform];
if (!sourceLibraryName) {
throw new Error(
`Unsupported platform for native build: ${process.platform}`
);
}
console.log("Building hydra-native Rust addon...");
await run("cargo", [
"build",
"--release",
"--manifest-path",
manifestPath,
"--target-dir",
cargoTargetDir,
]);
const sourceLibraryPath = path.join(
cargoTargetDir,
"release",
sourceLibraryName
);
if (!fs.existsSync(sourceLibraryPath)) {
throw new Error(`Native build output not found at ${sourceLibraryPath}`);
}
fs.mkdirSync(outputDir, { recursive: true });
fs.copyFileSync(sourceLibraryPath, outputNodePath);
await copySidecarLibrariesOnWindows(path.dirname(sourceLibraryPath));
await ensureDepsResolvableOnLinux();
console.log(`Hydra native addon ready at ${outputNodePath}`);
};
build().catch((error) => {
console.error(error);
process.exit(1);
});
+2 -5
View File
@@ -2,11 +2,10 @@ import { registerEvent } from "../register-event";
import { logger, Wine } from "@main/services";
import sudo from "sudo-prompt";
import { app } from "electron";
import { PythonRPC } from "@main/services/python-rpc";
import { ProcessPayload } from "@main/services/download/types";
import { gamesSublevel, levelKeys } from "@main/level";
import { GameShop } from "@types";
import path from "node:path";
import { NativeAddon } from "@main/services/native-addon";
const getKillCommand = (pid: number) => {
if (process.platform == "win32") {
@@ -21,9 +20,7 @@ const closeGame = async (
shop: GameShop,
objectId: string
) => {
const processes =
(await PythonRPC.rpc.get<ProcessPayload[] | null>("/process-list")).data ||
[];
const processes = NativeAddon.listProcesses();
const game = await gamesSublevel.get(levelKeys.game(shop, objectId));
@@ -1,5 +1,5 @@
import { registerEvent } from "../register-event";
import { PythonRPC } from "@main/services/python-rpc";
import { NativeAddon } from "@main/services/native-addon";
const processProfileImageEvent = async (
_event: Electron.IpcMainInvokeEvent,
@@ -9,12 +9,7 @@ const processProfileImageEvent = async (
};
export const processProfileImage = async (path: string, extension?: string) => {
return PythonRPC.rpc
.post<{
imagePath: string;
mimeType: string;
}>("/profile-image", { image_path: path, target_extension: extension })
.then((response) => response.data);
return NativeAddon.processProfileImage(path, extension);
};
registerEvent("processProfileImage", processProfileImageEvent);
@@ -1,15 +1,11 @@
import { registerEvent } from "../register-event";
import { PythonRPC } from "@main/services/python-rpc";
import { NativeAddon } from "@main/services/native-addon";
import type { TorrentFilesResponse } from "@types";
import { AxiosError } from "axios";
import { DownloadError } from "@shared";
const mapTorrentFilesError = (error: unknown) => {
if (error instanceof AxiosError) {
const rpcError = (error.response?.data as { error?: string } | undefined)
?.error;
switch (rpcError) {
if (error instanceof Error) {
switch (error.message) {
case "invalid_magnet":
return DownloadError.InvalidMagnet;
case "metadata_timeout":
@@ -18,17 +14,11 @@ const mapTorrentFilesError = (error: unknown) => {
return DownloadError.TorrentMetadataIncomplete;
case "too_many_files":
return DownloadError.TorrentTooManyFiles;
case "metadata_busy":
return DownloadError.TorrentMetadataTimeout;
default:
return DownloadError.TorrentFilesUnavailable;
}
}
if (error instanceof Error) {
return error.message;
}
return DownloadError.TorrentFilesUnavailable;
};
@@ -41,21 +31,11 @@ const getTorrentFiles = async (
}
try {
await PythonRPC.ensureReady();
const response = await PythonRPC.rpc.post<TorrentFilesResponse>(
"/torrent-files",
{
magnet,
},
{
timeout: 45000,
}
);
const response = NativeAddon.getTorrentFiles(magnet, 45_000);
return {
ok: true,
data: response.data,
data: response as TorrentFilesResponse,
};
} catch (error) {
return {
@@ -114,6 +114,34 @@ const handleHostSpecificError = (
return null;
};
const mapTorrentErrorCode = (code: string): DownloadErrorResult | null => {
if (code === "invalid_magnet") {
return { ok: false, error: DownloadError.InvalidMagnet };
}
if (code === "metadata_timeout") {
return { ok: false, error: DownloadError.TorrentMetadataTimeout };
}
if (code === "metadata_incomplete") {
return { ok: false, error: DownloadError.TorrentMetadataIncomplete };
}
if (code === "empty_selection") {
return { ok: false, error: DownloadError.TorrentNoFilesSelected };
}
if (code === "invalid_file_indices") {
return { ok: false, error: DownloadError.TorrentInvalidFileSelection };
}
if (code === "too_many_files") {
return { ok: false, error: DownloadError.TorrentTooManyFiles };
}
return null;
};
export const handleDownloadError = (
err: unknown,
downloader: Downloader
@@ -124,6 +152,11 @@ export const handleDownloadError = (
}
if (err instanceof Error) {
if (downloader === Downloader.Torrent) {
const mapped = mapTorrentErrorCode(err.message);
if (mapped) return mapped;
}
const hostResult = handleHostSpecificError(err.message, downloader);
if (hostResult) return hostResult;
+2 -5
View File
@@ -9,10 +9,9 @@ import {
Umu,
PowerSaveBlockerManager,
Wine,
NativeAddon,
} from "@main/services";
import { CommonRedistManager } from "@main/services/common-redist-manager";
import { ProcessPayload } from "@main/services/download/types";
import { PythonRPC } from "@main/services/python-rpc";
import { parseExecutablePath } from "../events/helpers/parse-executable-path";
import { isGamemodeAvailable } from "./is-gamemode-available";
import { isMangohudAvailable } from "./is-mangohud-available";
@@ -147,9 +146,7 @@ const cleanupStaleCompatibilityProcesses = async (
const defaultPrefixPath = Wine.getDefaultPrefixPathForGame(objectId);
if (defaultPrefixPath !== winePrefixPath) return;
const processes =
(await PythonRPC.rpc.get<ProcessPayload[] | null>("/process-list")).data ||
[];
const processes = NativeAddon.listProcesses();
const stalePids = processes
.filter((runningProcess) => {
-5
View File
@@ -9,11 +9,9 @@ import {
clearGamesPlaytime,
WindowManager,
Lock,
Aria2,
PowerSaveBlockerManager,
} from "@main/services";
import resources from "@locales";
import { PythonRPC } from "./services/python-rpc";
import { db, gamesSublevel, levelKeys } from "./level";
import { GameShop, UserPreferences } from "@types";
import { launchGame } from "./helpers";
@@ -281,9 +279,6 @@ app.on("before-quit", async (e) => {
if (!canAppBeClosed) {
e.preventDefault();
PowerSaveBlockerManager.reset();
/* Disconnects libtorrent */
PythonRPC.kill();
Aria2.kill();
await clearGamesPlaytime();
canAppBeClosed = true;
app.quit();
-3
View File
@@ -10,7 +10,6 @@ import {
RealDebridClient,
PremiumizeClient,
AllDebridClient,
Aria2,
DownloadManager,
HydraApi,
uploadGamesBatch,
@@ -36,8 +35,6 @@ export const loadState = async () => {
await import("./events");
Aria2.spawn();
if (userPreferences?.realDebridApiToken) {
RealDebridClient.authorize(userPreferences.realDebridApiToken);
}
-35
View File
@@ -1,35 +0,0 @@
import path from "node:path";
import cp from "node:child_process";
import { app } from "electron";
import { logger } from "./logger";
export class Aria2 {
private static process: cp.ChildProcess | null = null;
public static spawn() {
const binaryPath =
process.platform === "darwin"
? "aria2c"
: app.isPackaged
? path.join(process.resourcesPath, "aria2c")
: path.join(__dirname, "..", "..", "binaries", "aria2c");
this.process = cp.spawn(
binaryPath,
[
"--enable-rpc",
"--rpc-listen-all",
"--file-allocation=none",
"--allow-overwrite=true",
],
{ stdio: "inherit", windowsHide: true }
);
}
public static kill() {
if (this.process) {
logger.log("Killing aria2 process");
this.process.kill();
}
}
}
+49 -73
View File
@@ -10,12 +10,8 @@ import {
VikingFileApi,
RootzApi,
} from "../hosters";
import { PythonRPC } from "../python-rpc";
import {
LibtorrentPayload,
LibtorrentStatus,
PauseDownloadPayload,
} from "./types";
import { NativeAddon } from "../native-addon";
import { LibtorrentStatus } from "./types";
import { calculateETA, getDirSize } from "./helpers";
import { RealDebridClient } from "./real-debrid";
import path from "node:path";
@@ -167,7 +163,7 @@ export class DownloadManager {
logger.log(`[DownloadManager] Using filename: ${sanitizedFilename}`);
} else {
logger.log(
`[DownloadManager] No filename extracted, aria2 will use default`
`[DownloadManager] No filename extracted, downloader will use default`
);
}
@@ -182,12 +178,7 @@ export class DownloadManager {
}
private static async shouldUseJsDownloader(): Promise<boolean> {
const userPreferences = await db.get<string, UserPreferences | null>(
levelKeys.userPreferences,
{ valueEncoding: "json" }
);
// Default to true - native HTTP downloader is enabled by default (opt-out)
return userPreferences?.useNativeHttpDownloader ?? true;
return true;
}
private static isHttpDownloader(downloader: Downloader): boolean {
@@ -226,40 +217,32 @@ export class DownloadManager {
this.maxDownloadSpeedBytesPerSecond = normalizedLimit;
this.jsDownloader?.setMaxDownloadSpeedBytesPerSecond(normalizedLimit);
await PythonRPC.rpc
.post("/action", {
action: "set_download_limit",
max_download_speed_bytes_per_second: normalizedLimit,
})
.catch((error) => {
logger.error(
"[DownloadManager] Failed to update RPC download speed limit:",
error
);
});
try {
NativeAddon.setTorrentDownloadLimit(normalizedLimit);
} catch (error) {
logger.error(
"[DownloadManager] Failed to update torrent speed limit:",
error
);
}
}
public static async startRPC(
download?: Download,
downloadsToSeed?: Download[]
) {
await PythonRPC.spawn(
download?.status === "active"
? await this.getDownloadPayload(download).catch((err) => {
logger.error("Error getting download payload", err);
return undefined;
})
: undefined,
downloadsToSeed?.map((download) => ({
action: "seed",
game_id: levelKeys.game(download.shop, download.objectId),
url: download.uri,
save_path: download.downloadPath,
}))
);
if (downloadsToSeed?.length) {
for (const seedDownload of downloadsToSeed) {
await this.resumeSeeding(seedDownload).catch((error) => {
logger.error("[DownloadManager] Failed to resume seeding", error);
});
}
}
if (download) {
this.downloadingGameId = levelKeys.game(download.shop, download.objectId);
if (download?.status === "active") {
await this.startDownload(download).catch((error) => {
logger.error("[DownloadManager] Failed to resume download", error);
});
}
await this.applyDownloadSpeedLimit();
@@ -398,10 +381,8 @@ export class DownloadManager {
}
private static async getDownloadStatusFromRpc(): Promise<DownloadProgress | null> {
const response = await PythonRPC.rpc.get<LibtorrentPayload | null>(
"/status"
);
if (response.data === null || !this.downloadingGameId) return null;
const response = NativeAddon.getTorrentStatus();
if (response === null || !this.downloadingGameId) return null;
const downloadId = this.downloadingGameId;
try {
@@ -414,7 +395,7 @@ export class DownloadManager {
fileSize,
folderName,
status,
} = response.data;
} = response;
const isDownloadingMetadata =
status === LibtorrentStatus.DownloadingMetadata;
@@ -685,9 +666,7 @@ export class DownloadManager {
}
public static async getSeedStatus() {
const seedStatus = await PythonRPC.rpc
.get<LibtorrentPayload[] | []>("/seed-status")
.then((res) => res.data);
const seedStatus = NativeAddon.getTorrentSeedStatus();
if (!seedStatus.length) return;
@@ -723,13 +702,12 @@ export class DownloadManager {
if (this.usingJsDownloader && this.jsDownloader) {
logger.log("[DownloadManager] Pausing JS download");
this.jsDownloader.pauseDownload();
} else {
await PythonRPC.rpc
.post("/action", {
action: "pause",
game_id: downloadKey,
} as PauseDownloadPayload)
.catch(() => {});
} else if (downloadKey) {
try {
NativeAddon.pauseTorrentDownload(downloadKey);
} catch {
// ignore pause failures
}
}
if (downloadKey === this.downloadingGameId) {
@@ -753,9 +731,11 @@ export class DownloadManager {
this.usingJsDownloader = false;
this.allDebridBatch = null;
} else if (!this.isPreparingDownload) {
await PythonRPC.rpc
.post("/action", { action: "cancel", game_id: downloadKey })
.catch((err) => logger.error("Failed to cancel game download", err));
try {
NativeAddon.cancelTorrentDownload(downloadKey!);
} catch (err) {
logger.error("Failed to cancel game download", err);
}
}
WindowManager.mainWindow?.setProgressBar(-1);
@@ -768,19 +748,15 @@ export class DownloadManager {
}
static async resumeSeeding(download: Download) {
await PythonRPC.rpc.post("/action", {
action: "resume_seeding",
game_id: levelKeys.game(download.shop, download.objectId),
NativeAddon.resumeTorrentSeeding({
gameId: levelKeys.game(download.shop, download.objectId),
url: download.uri,
save_path: download.downloadPath,
savePath: download.downloadPath,
});
}
static async pauseSeeding(downloadKey: string) {
await PythonRPC.rpc.post("/action", {
action: "pause_seeding",
game_id: downloadKey,
});
NativeAddon.pauseTorrentSeeding(downloadKey);
}
private static async getJsDownloadOptions(download: Download): Promise<{
@@ -1442,18 +1418,18 @@ export class DownloadManager {
throw err;
}
} else {
logger.log("[DownloadManager] Using Python RPC downloader");
const payload = await this.getDownloadPayload(download);
logger.log("[DownloadManager] Using native torrent downloader");
const isSelectiveTorrentStart =
download.downloader === Downloader.Torrent &&
Array.isArray(download.fileIndices) &&
download.fileIndices.length > 0;
if (payload?.url) {
this.logResolvedUrl(payload.url);
}
await PythonRPC.rpc.post("/action", payload, {
timeout: isSelectiveTorrentStart ? 60_000 : 10_000,
NativeAddon.startTorrentDownload({
gameId: downloadId,
url: download.uri,
savePath: download.downloadPath,
fileIndices: download.fileIndices,
timeoutMs: isSelectiveTorrentStart ? 60_000 : 10_000,
});
this.downloadingGameId = downloadId;
this.usingJsDownloader = false;
+1 -1
View File
@@ -12,7 +12,6 @@ export * from "./7zip";
export * from "./game-files-manager";
export * from "./game-executables";
export * from "./common-redist-manager";
export * from "./aria2";
export * from "./ws";
export * from "./system-path";
export * from "./library-sync";
@@ -24,3 +23,4 @@ export * from "./user";
export * from "./download-sources-checker";
export * from "./notifications/local-notifications";
export * from "./power-save-blocker";
export * from "./native-addon";
+178
View File
@@ -0,0 +1,178 @@
import fs from "node:fs";
import path from "node:path";
import { createRequire } from "node:module";
import { app } from "electron";
import type { ProcessPayload, LibtorrentPayload } from "./download/types";
import type { TorrentFilesResponse } from "@types";
import { logger } from "./logger";
type NativeProcessProfileImageResponse = {
imagePath?: string;
image_path?: string;
mimeType?: string;
mime_type?: string;
};
type HydraNativeModule = {
processProfileImage: (
imagePath: string,
targetExtension?: string
) => NativeProcessProfileImageResponse;
listProcesses: () => ProcessPayload[];
torrentGetStatus: () => LibtorrentPayload | null;
torrentGetSeedStatus: () => Array<LibtorrentPayload & { gameId: string }>;
torrentGetFiles: (magnet: string, timeoutMs?: number) => TorrentFilesResponse;
torrentStart: (payload: {
gameId: string;
url: string;
savePath: string;
fileIndices?: number[];
timeoutMs?: number;
}) => void;
torrentPause: (gameId: string) => void;
torrentCancel: (gameId: string) => void;
torrentResumeSeeding: (payload: {
gameId: string;
url: string;
savePath: string;
}) => void;
torrentPauseSeeding: (gameId: string) => void;
torrentSetDownloadLimit: (
maxDownloadSpeedBytesPerSecond?: number | null
) => void;
};
export class NativeAddon {
private static nativeModule: HydraNativeModule | null = null;
private static resolveAddonPath() {
if (app.isPackaged) {
return path.join(
process.resourcesPath,
"hydra-native",
"hydra-native.node"
);
}
return path.join(app.getAppPath(), "hydra-native", "hydra-native.node");
}
private static load() {
if (this.nativeModule) return this.nativeModule;
const addonPath = this.resolveAddonPath();
if (!fs.existsSync(addonPath)) {
throw new Error(`Hydra native addon not found at ${addonPath}`);
}
const require = createRequire(import.meta.url);
const nativeModule = require(addonPath) as HydraNativeModule;
this.nativeModule = nativeModule;
return nativeModule;
}
public static processProfileImage(
imagePath: string,
targetExtension = "webp"
) {
try {
const response = this.load().processProfileImage(
imagePath,
targetExtension
);
const normalizedImagePath = response.imagePath ?? response.image_path;
const normalizedMimeType = response.mimeType ?? response.mime_type;
if (!normalizedImagePath || !normalizedMimeType) {
throw new Error("Hydra native addon returned an invalid payload");
}
return {
imagePath: normalizedImagePath,
mimeType: normalizedMimeType,
};
} catch (error) {
logger.error("Failed to process profile image via native addon", error);
throw error;
}
}
public static listProcesses(): ProcessPayload[] {
try {
const response = this.load().listProcesses();
if (!Array.isArray(response)) {
throw new Error("Hydra native addon returned an invalid process list");
}
return response.filter((process): process is ProcessPayload => {
return (
typeof process?.pid === "number" &&
typeof process?.name === "string" &&
process.name.length > 0
);
});
} catch (error) {
logger.error("Failed to list processes via native addon", error);
return [];
}
}
public static getTorrentStatus(): LibtorrentPayload | null {
return this.load().torrentGetStatus();
}
public static getTorrentSeedStatus(): Array<
LibtorrentPayload & { gameId: string }
> {
return this.load().torrentGetSeedStatus();
}
public static getTorrentFiles(
magnet: string,
timeoutMs?: number
): TorrentFilesResponse {
return this.load().torrentGetFiles(magnet, timeoutMs);
}
public static startTorrentDownload(payload: {
gameId: string;
url: string;
savePath: string;
fileIndices?: number[];
timeoutMs?: number;
}) {
this.load().torrentStart(payload);
}
public static pauseTorrentDownload(gameId: string) {
this.load().torrentPause(gameId);
}
public static cancelTorrentDownload(gameId: string) {
this.load().torrentCancel(gameId);
}
public static resumeTorrentSeeding(payload: {
gameId: string;
url: string;
savePath: string;
}) {
this.load().torrentResumeSeeding(payload);
}
public static pauseTorrentSeeding(gameId: string) {
this.load().torrentPauseSeeding(gameId);
}
public static setTorrentDownloadLimit(
maxDownloadSpeedBytesPerSecond?: number | null
) {
this.load().torrentSetDownloadLimit(maxDownloadSpeedBytesPerSecond);
}
}
+3 -6
View File
@@ -1,17 +1,16 @@
import { WindowManager } from "./window-manager";
import { createGame, trackGamePlaytime } from "./library-sync";
import type { Game, GameRunning, UserPreferences } from "@types";
import { PythonRPC } from "./python-rpc";
import axios from "axios";
import { ProcessPayload } from "./download/types";
import { db, gamesSublevel, levelKeys } from "@main/level";
import { CloudSync } from "./cloud-sync";
import { logger } from "./logger";
import { PowerSaveBlockerManager } from "./power-save-blocker";
import path from "path";
import path from "node:path";
import { AchievementWatcherManager } from "./achievements/achievement-watcher-manager";
import { MAIN_LOOP_INTERVAL } from "@main/constants";
import { Wine } from "./wine";
import { NativeAddon } from "./native-addon";
export const gamesPlaytime = new Map<
string,
@@ -123,9 +122,7 @@ const findGamePathByProcess = async (
};
const getSystemProcessMap = async () => {
const processes =
(await PythonRPC.rpc.get<ProcessPayload[] | null>("/process-list")).data ||
[];
const processes = NativeAddon.listProcesses();
const processMap = new Map<string, Set<string>>();
const winePrefixMap = new Map<string, string>();
-223
View File
@@ -1,223 +0,0 @@
import axios from "axios";
import http from "node:http";
import cp from "node:child_process";
import crypto from "node:crypto";
import fs from "node:fs";
import net from "node:net";
import path from "node:path";
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;
file_indices?: 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 = 8087;
export class PythonRPC {
public static readonly BITTORRENT_PORT = "5881";
public static readonly rpc = axios.create({
baseURL: `http://localhost:${DEFAULT_RPC_PORT}`,
timeout: 10000,
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 isPortAvailable(port: number) {
return new Promise<boolean>((resolve) => {
const server = net.createServer();
server.unref();
server.on("error", () => {
server.close(() => resolve(false));
});
server.listen(port, "127.0.0.1", () => {
server.close(() => resolve(true));
});
});
}
private static async findAvailablePort() {
const scannedPorts = new Set<number>();
const enqueuePort = (port: number) => {
if (port < RPC_PORT_RANGE_START || port > RPC_PORT_RANGE_END) return;
if (!scannedPorts.has(port)) scannedPorts.add(port);
};
enqueuePort(DEFAULT_RPC_PORT);
for (let port = RPC_PORT_RANGE_START; port <= RPC_PORT_RANGE_END; port++) {
enqueuePort(port);
}
for (const port of scannedPorts) {
if (await this.isPortAvailable(port)) {
return port;
}
}
throw new Error("No available RPC port found");
}
private static updateRpcPort(port: number) {
this.rpc.defaults.baseURL = `http://localhost:${port}`;
}
public static async ensureReady(
retries = 20,
delayMs = 250,
timeoutMs = 2000
): Promise<void> {
let lastError: unknown = null;
for (let attempt = 0; attempt < retries; attempt++) {
try {
await this.rpc.get("/healthcheck", { timeout: timeoutMs });
return;
} catch (error) {
lastError = error;
if (attempt < retries - 1) {
await new Promise((resolve) => setTimeout(resolve, delayMs));
}
}
}
throw lastError instanceof Error
? lastError
: new Error("Python RPC healthcheck failed");
}
public static async spawn(
initialDownload?: GamePayload,
initialSeeding?: GamePayload[]
) {
const rpcPassword = await this.getRPCPassword();
let port: number;
try {
port = await this.findAvailablePort();
} catch (err) {
const message =
err instanceof Error && err.message
? err.message
: "Unknown error while selecting RPC port";
dialog.showErrorBox(
"RPC Error",
`Failed to select an available port for the download service.\n\n${message}`
);
throw err;
}
this.updateRpcPort(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;
await this.ensureReady();
}
public static kill() {
if (this.pythonProcess) {
pythonRpcLogger.log("Killing python process");
this.pythonProcess.kill();
this.pythonProcess = null;
}
}
}
+71 -9
View File
@@ -1,5 +1,12 @@
import { useTranslation } from "react-i18next";
import { useEffect, useId, useMemo, useRef, useState } from "react";
import {
useDeferredValue,
useEffect,
useId,
useMemo,
useRef,
useState,
} from "react";
import { useLocation, useNavigate, useSearchParams } from "react-router-dom";
import {
ArrowLeftIcon,
@@ -24,6 +31,7 @@ import cn from "classnames";
import { SearchDropdown } from "@renderer/components";
import { buildGameDetailsPath } from "@renderer/helpers";
import type { GameShop } from "@types";
import { debounce } from "lodash-es";
const pathTitle: Record<string, string> = {
"/": "home",
@@ -61,8 +69,27 @@ export function Header() {
? librarySearchValue
: catalogueSearchValue;
const [localSearchValue, setLocalSearchValue] = useState(searchValue);
const deferredSearchValue = useDeferredValue(localSearchValue);
const dispatch = useAppDispatch();
const debouncedLibrarySearch = useMemo(
() =>
debounce((value: string) => {
dispatch(setLibrarySearchQuery(value));
}, 180),
[dispatch]
);
const debouncedCatalogueSearch = useMemo(
() =>
debounce((value: string) => {
dispatch(setFilters({ title: value }));
}, 250),
[dispatch]
);
const [isFocused, setIsFocused] = useState(false);
const [isDropdownVisible, setIsDropdownVisible] = useState(false);
const [activeIndex, setActiveIndex] = useState(-1);
@@ -83,7 +110,7 @@ export function Header() {
useSearchHistory();
const { suggestions, isLoading: isLoadingSuggestions } = useSearchSuggestions(
searchValue,
deferredSearchValue,
isOnLibraryPage,
isDropdownVisible && isFocused && !isOnCataloguePage
);
@@ -107,6 +134,17 @@ export function Header() {
const totalItems = historyItems.length + suggestions.length;
useEffect(() => {
setLocalSearchValue(searchValue);
}, [searchValue, isOnLibraryPage, isOnCataloguePage]);
useEffect(() => {
return () => {
debouncedLibrarySearch.cancel();
debouncedCatalogueSearch.cancel();
};
}, [debouncedCatalogueSearch, debouncedLibrarySearch]);
const updateDropdownPosition = () => {
if (searchContainerRef.current) {
const rect = searchContainerRef.current.getBoundingClientRect();
@@ -149,6 +187,9 @@ export function Header() {
};
const handleSearch = (value: string) => {
debouncedLibrarySearch.cancel();
debouncedCatalogueSearch.cancel();
if (isOnLibraryPage) {
dispatch(setLibrarySearchQuery(value.slice(0, 255)));
} else {
@@ -157,7 +198,23 @@ export function Header() {
setActiveIndex(-1);
};
const handleInputChange = (value: string) => {
const normalizedValue = value.slice(0, 255);
setLocalSearchValue(normalizedValue);
setActiveIndex(-1);
if (isOnLibraryPage) {
debouncedCatalogueSearch.cancel();
debouncedLibrarySearch(normalizedValue);
} else {
debouncedLibrarySearch.cancel();
debouncedCatalogueSearch(normalizedValue);
}
};
const executeSearch = (query: string) => {
setLocalSearchValue(query.slice(0, 255));
const context = isOnLibraryPage ? "library" : "catalogue";
if (query.trim()) {
addToHistory(query, context);
@@ -187,6 +244,11 @@ export function Header() {
};
const handleClearSearch = () => {
debouncedLibrarySearch.cancel();
debouncedCatalogueSearch.cancel();
setLocalSearchValue("");
if (isOnLibraryPage) {
dispatch(setLibrarySearchQuery(""));
} else {
@@ -219,8 +281,8 @@ export function Header() {
const suggestionIndex = activeIndex - historyItems.length;
handleSelectSuggestion(suggestions[suggestionIndex]);
}
} else if (searchValue.trim()) {
executeSearch(searchValue);
} else if (localSearchValue.trim()) {
executeSearch(localSearchValue);
}
} else if (event.key === "ArrowDown") {
event.preventDefault();
@@ -347,15 +409,15 @@ export function Header() {
type="text"
name="search"
placeholder={isOnLibraryPage ? t("search_library") : t("search")}
value={searchValue}
value={localSearchValue}
className="header__search-input"
onChange={(event) => handleSearch(event.target.value)}
onChange={(event) => handleInputChange(event.target.value)}
onFocus={handleFocus}
onBlur={handleBlur}
onKeyDown={handleKeyDown}
/>
{searchValue && (
{localSearchValue && (
<button
type="button"
onMouseDown={handleClearSearchMouseDown}
@@ -378,7 +440,7 @@ export function Header() {
<SearchDropdown
visible={
isDropdownVisible &&
(searchValue.trim().length > 0 ||
(localSearchValue.trim().length > 0 ||
historyItems.length > 0 ||
suggestions.length > 0 ||
isLoadingSuggestions)
@@ -393,7 +455,7 @@ export function Header() {
onClearHistory={handleClearHistory}
onClose={handleCloseDropdown}
activeIndex={activeIndex}
currentQuery={searchValue}
currentQuery={deferredSearchValue}
searchContainerRef={searchContainerRef}
/>
@@ -1,4 +1,4 @@
import { useState, useEffect, useCallback, useRef } from "react";
import { useState, useEffect, useCallback, useMemo, useRef } from "react";
import { useAppSelector } from "./redux";
import { debounce } from "lodash-es";
import { logger } from "@renderer/logger";
@@ -22,31 +22,49 @@ export function useSearchSuggestions(
const library = useAppSelector((state) => state.library.value);
const abortControllerRef = useRef<AbortController | null>(null);
const cacheRef = useRef<Map<string, SearchSuggestion[]>>(new Map());
const librarySearchIndex = useMemo(
() =>
library.map((game) => ({
titleLower: game.title.toLowerCase(),
game,
})),
[library]
);
const getLibrarySuggestions = useCallback(
(searchQuery: string, limit: number = 3): SearchSuggestion[] => {
if (!searchQuery.trim()) return [];
const normalizedQuery = searchQuery.trim().toLowerCase();
if (normalizedQuery.length < 2) return [];
const queryLower = searchQuery.toLowerCase();
const matches: SearchSuggestion[] = [];
for (const game of library) {
for (const { game, titleLower } of librarySearchIndex) {
if (matches.length >= limit) break;
const titleLower = game.title.toLowerCase();
if (titleLower.includes(normalizedQuery)) {
matches.push({
title: game.title,
objectId: game.objectId,
shop: game.shop,
iconUrl: game.iconUrl,
source: "library",
});
continue;
}
let queryIndex = 0;
for (
let i = 0;
i < titleLower.length && queryIndex < queryLower.length;
i++
let index = 0;
index < titleLower.length && queryIndex < normalizedQuery.length;
index++
) {
if (titleLower[i] === queryLower[queryIndex]) {
if (titleLower[index] === normalizedQuery[queryIndex]) {
queryIndex++;
}
}
if (queryIndex === queryLower.length) {
if (queryIndex === normalizedQuery.length) {
matches.push({
title: game.title,
objectId: game.objectId,
@@ -59,7 +77,7 @@ export function useSearchSuggestions(
return matches;
},
[library]
[librarySearchIndex]
);
const fetchCatalogueSuggestions = useCallback(
+57 -23
View File
@@ -5,7 +5,15 @@ import type {
} from "@types";
import { useAppDispatch, useAppSelector, useFormat } from "@renderer/hooks";
import { lazy, Suspense, useEffect, useMemo, useRef, useState } from "react";
import {
lazy,
Suspense,
useDeferredValue,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import "./catalogue.scss";
@@ -82,7 +90,8 @@ const areSameValues = (first: string[], second: string[]) =>
first.every((item) => second.includes(item));
export default function Catalogue() {
const abortControllerRef = useRef<AbortController | null>(null);
const requestSequenceRef = useRef(0);
const hasResultsRef = useRef(false);
const cataloguePageRef = useRef<HTMLDivElement>(null);
const { steamDevelopers, steamPublishers, downloadSources } = useCatalogue();
@@ -90,8 +99,17 @@ export default function Catalogue() {
const { steamGenres, steamUserTags, filters, page } = useAppSelector(
(state) => state.catalogueSearch
);
const deferredTitleFilter = useDeferredValue(filters.title);
const effectiveFilters = useMemo(() => {
return {
...filters,
title: deferredTitleFilter,
};
}, [filters, deferredTitleFilter]);
const [isLoading, setIsLoading] = useState(true);
const [isFetching, setIsFetching] = useState(false);
const [results, setResults] = useState<CatalogueSearchResult[]>([]);
@@ -110,11 +128,9 @@ export default function Catalogue() {
filters: CatalogueSearchPayload,
downloadSources: DownloadSource[],
pageSize: number,
offset: number
offset: number,
requestId: number
) => {
const abortController = new AbortController();
abortControllerRef.current = abortController;
const requestData = {
...filters,
take: pageSize,
@@ -124,19 +140,25 @@ export default function Catalogue() {
),
};
const response = await window.electron.hydraApi.post<{
edges: CatalogueSearchResult[];
count: number;
}>("/catalogue/search", {
data: requestData,
needsAuth: false,
});
try {
const response = await window.electron.hydraApi.post<{
edges: CatalogueSearchResult[];
count: number;
}>("/catalogue/search", {
data: requestData,
needsAuth: false,
});
if (abortController.signal.aborted) return;
if (requestId !== requestSequenceRef.current) return;
setResults(response.edges);
setItemsCount(response.count);
setIsLoading(false);
setResults(response.edges);
setItemsCount(response.count);
setIsLoading(false);
} finally {
if (requestId === requestSequenceRef.current) {
setIsFetching(false);
}
}
},
500
)
@@ -146,21 +168,29 @@ export default function Catalogue() {
s.replaceAll("&amp;", "&").replaceAll("&lt;", "<").replaceAll("&gt;", ">");
useEffect(() => {
setResults([]);
setIsLoading(true);
abortControllerRef.current?.abort();
hasResultsRef.current = results.length > 0;
}, [results.length]);
useEffect(() => {
const requestId = ++requestSequenceRef.current;
setIsFetching(true);
if (!hasResultsRef.current) {
setIsLoading(true);
}
debouncedSearch(
filters,
effectiveFilters,
downloadSources,
PAGE_SIZE,
(page - 1) * PAGE_SIZE
(page - 1) * PAGE_SIZE,
requestId
);
return () => {
debouncedSearch.cancel();
};
}, [filters, downloadSources, page, debouncedSearch]);
}, [effectiveFilters, downloadSources, page, debouncedSearch]);
const language = i18n.language.split("-")[0];
@@ -411,6 +441,10 @@ export default function Catalogue() {
results.map((game) => <GameItem key={game.id} game={game} />)
)}
{isFetching && !isLoading && (
<span className="catalogue__result-count">{t("loading")}</span>
)}
<div className="catalogue__pagination-container">
<span className="catalogue__result-count">
{t("result_count", {
+47 -40
View File
@@ -1,4 +1,10 @@
import { useEffect, useMemo, useState, useCallback } from "react";
import {
useDeferredValue,
useEffect,
useMemo,
useState,
useCallback,
} from "react";
import { AnimatePresence, motion } from "framer-motion";
import {
useLibrary,
@@ -95,6 +101,7 @@ export default function Library() {
const [isDeletingCollection, setIsDeletingCollection] = useState(false);
const searchQuery = useAppSelector((state) => state.library.searchQuery);
const deferredSearchQuery = useDeferredValue(searchQuery);
const dispatch = useAppDispatch();
const { t } = useTranslation(["library", "sidebar"]);
@@ -371,42 +378,8 @@ export default function Library() {
hasLoadedCollections,
]);
const filteredLibrary = useMemo(() => {
let filtered = library;
if (selectedCollectionId) {
if (selectedCollectionId === FAVORITES_COLLECTION_ID) {
filtered = filtered.filter((game) => game.favorite);
} else {
filtered = filtered.filter((game) =>
getGameCollectionIds(game).includes(selectedCollectionId)
);
}
}
if (!searchQuery.trim()) return filtered;
const queryLower = searchQuery.toLowerCase();
return filtered.filter((game) => {
const titleLower = game.title.toLowerCase();
let queryIndex = 0;
for (
let i = 0;
i < titleLower.length && queryIndex < queryLower.length;
i++
) {
if (titleLower[i] === queryLower[queryIndex]) {
queryIndex++;
}
}
return queryIndex === queryLower.length;
});
}, [library, searchQuery, selectedCollectionId]);
const sortedLibrary = useMemo(() => {
return [...filteredLibrary].sort((a, b) => {
return [...library].sort((a, b) => {
switch (sortBy) {
case "recently_played": {
const aHasPlayed = a.lastTimePlayed !== null;
@@ -459,7 +432,41 @@ export default function Library() {
sensitivity: "base",
});
});
}, [filteredLibrary, sortBy]);
}, [library, sortBy]);
const filteredLibrary = useMemo(() => {
let filtered = sortedLibrary;
if (selectedCollectionId) {
if (selectedCollectionId === FAVORITES_COLLECTION_ID) {
filtered = filtered.filter((game) => game.favorite);
} else {
filtered = filtered.filter((game) =>
getGameCollectionIds(game).includes(selectedCollectionId)
);
}
}
if (!deferredSearchQuery.trim()) return filtered;
const queryLower = deferredSearchQuery.toLowerCase();
return filtered.filter((game) => {
const titleLower = game.title.toLowerCase();
let queryIndex = 0;
for (
let i = 0;
i < titleLower.length && queryIndex < queryLower.length;
i++
) {
if (titleLower[i] === queryLower[queryIndex]) {
queryIndex++;
}
}
return queryIndex === queryLower.length;
});
}, [sortedLibrary, deferredSearchQuery, selectedCollectionId]);
const favoritesCount = useMemo(() => {
return library.filter((game) => game.favorite).length;
@@ -477,7 +484,7 @@ export default function Library() {
}, [collections, favoritesCount, t]);
const hasGames = library.length > 0;
const hasNoFilteredGames = sortedLibrary.length === 0;
const hasNoFilteredGames = filteredLibrary.length === 0;
const isFavoritesCollectionSelected =
selectedCollectionId === FAVORITES_COLLECTION_ID;
const shouldShowFavoritesEmptyState =
@@ -593,7 +600,7 @@ export default function Library() {
exit={{ opacity: 0, x: 10 }}
transition={{ duration: 0.2 }}
>
{sortedLibrary.map((game) => (
{filteredLibrary.map((game) => (
<LibraryGameCardLarge
key={`${game.shop}-${game.objectId}`}
game={game}
@@ -612,7 +619,7 @@ export default function Library() {
exit={{ opacity: 0, x: 10 }}
transition={{ duration: 0.2 }}
>
{sortedLibrary.map((game) => (
{filteredLibrary.map((game) => (
<li
key={`${game.shop}-${game.objectId}`}
style={{ listStyle: "none" }}