diff --git a/python_rpc/main.py b/python_rpc/main.py index a7f0b2ab39..881da07b40 100644 --- a/python_rpc/main.py +++ b/python_rpc/main.py @@ -247,6 +247,46 @@ def apply_download_limit(downloader): if callable(set_download_limit): set_download_limit(current_download_limit) + +def normalize_network_interface(value): + if not isinstance(value, str): + return None + + trimmed = value.strip() + return trimmed or None + + +def build_listen_interface(token): + if ":" in token: + return "[{addr}]:{port}".format(addr=token, port=torrent_port) + + return "{addr}:{port}".format(addr=token, port=torrent_port) + + +def apply_network_interface(interface): + if interface: + tokens = [token.strip() for token in interface.split(",") if token.strip()] + listen_interfaces = ",".join(build_listen_interface(token) for token in tokens) + outgoing_interfaces = ",".join(tokens) + else: + listen_interfaces = "0.0.0.0:{port}".format(port=torrent_port) + outgoing_interfaces = "" + + try: + torrent_session.apply_settings( + { + "listen_interfaces": listen_interfaces, + "outgoing_interfaces": outgoing_interfaces, + } + ) + logger.info( + "Bound torrent client to network interface: %s", + interface or "default (all adapters)", + ) + except Exception: + logger.exception("Failed to bind torrent client to interface") + + def validate_rpc_password_value(password: Optional[str]): if rpc_password == "": return True @@ -511,6 +551,10 @@ def action(data: Optional[dict] = None): for downloader in active_downloaders: apply_download_limit(downloader) + elif action_name == "set_network_interface": + apply_network_interface( + normalize_network_interface(data.get("interface")) + ) else: raise RpcError("invalid_action") except RpcError: diff --git a/src/locales/en/translation.json b/src/locales/en/translation.json index cc1c2c4f44..6407f8b181 100755 --- a/src/locales/en/translation.json +++ b/src/locales/en/translation.json @@ -775,6 +775,10 @@ "app_basics": "App basics", "startup_behavior": "Startup behavior", "download_behavior": "Download behavior", + "network_interface": "Network adapter for torrents", + "network_interface_default": "Default (all adapters)", + "network_interface_hint": "Force torrents through a specific adapter, like a VPN. If it disconnects, downloads stop instead of leaking.", + "network_interface_unavailable": "unavailable", "library_notifications": "Library notifications", "achievement_notifications": "Achievement notifications", "content_gameplay": "Content & gameplay", diff --git a/src/locales/es/translation.json b/src/locales/es/translation.json index 730d1cc252..bef138dea9 100644 --- a/src/locales/es/translation.json +++ b/src/locales/es/translation.json @@ -746,6 +746,10 @@ "app_basics": "Básicos de la aplicación", "startup_behavior": "Comportamiento al iniciar", "download_behavior": "Comportamiento de descarga", + "network_interface": "Adaptador de red para torrents", + "network_interface_default": "Predeterminado (todos los adaptadores)", + "network_interface_hint": "Fuerza los torrents a través de un adaptador específico, como una VPN. Si se desconecta, las descargas se detienen en lugar de filtrarse.", + "network_interface_unavailable": "no disponible", "library_notifications": "Notificaciones de la librería", "achievement_notifications": "Notificaciones de logros", "content_preferences": "Preferencias de contenido", diff --git a/src/locales/pt-BR/translation.json b/src/locales/pt-BR/translation.json index 406f38031a..bcc65bb575 100755 --- a/src/locales/pt-BR/translation.json +++ b/src/locales/pt-BR/translation.json @@ -748,6 +748,10 @@ "app_basics": "Noções básicas do aplicativo", "startup_behavior": "Comportamento na inicialização", "download_behavior": "Comportamento de download", + "network_interface": "Adaptador de rede para torrents", + "network_interface_default": "Padrão (todos os adaptadores)", + "network_interface_hint": "Força os torrents por um adaptador específico, como uma VPN. Se ele cair, os downloads param em vez de vazar.", + "network_interface_unavailable": "indisponível", "library_notifications": "Notificações da biblioteca", "achievement_notifications": "Notificações de conquistas", "content_preferences": "Preferências de conteúdo", diff --git a/src/locales/pt-PT/translation.json b/src/locales/pt-PT/translation.json index ad5a0688a5..44ebd8b9b3 100644 --- a/src/locales/pt-PT/translation.json +++ b/src/locales/pt-PT/translation.json @@ -641,6 +641,10 @@ "app_basics": "Noções básicas da aplicação", "startup_behavior": "Comportamento no arranque", "download_behavior": "Comportamento de transferência", + "network_interface": "Adaptador de rede para torrents", + "network_interface_default": "Predefinido (todos os adaptadores)", + "network_interface_hint": "Força os torrents por um adaptador específico, como uma VPN. Se ficar indisponível, as transferências param em vez de vazar.", + "network_interface_unavailable": "indisponível", "library_notifications": "Notificações da biblioteca", "achievement_notifications": "Notificações de conquistas", "content_preferences": "Preferências de conteúdo", diff --git a/src/locales/ru/translation.json b/src/locales/ru/translation.json index 77f0375e60..af7167ffcc 100644 --- a/src/locales/ru/translation.json +++ b/src/locales/ru/translation.json @@ -776,6 +776,10 @@ "app_basics": "Основные настройки приложения", "startup_behavior": "Поведение при запуске", "download_behavior": "Поведение загрузок", + "network_interface": "Сетевой адаптер для торрентов", + "network_interface_default": "По умолчанию (все адаптеры)", + "network_interface_hint": "Направляет торренты через выбранный адаптер, например VPN. Если он отключится, загрузки остановятся, а не пойдут в обход.", + "network_interface_unavailable": "недоступен", "library_notifications": "Уведомления библиотеки", "achievement_notifications": "Уведомления о достижениях", "content_gameplay": "Контент и игровой процесс", diff --git a/src/main/events/hardware/get-network-interfaces.ts b/src/main/events/hardware/get-network-interfaces.ts new file mode 100644 index 0000000000..74e8b0c4ca --- /dev/null +++ b/src/main/events/hardware/get-network-interfaces.ts @@ -0,0 +1,33 @@ +import os from "node:os"; + +import type { NetworkInterface } from "@types"; +import { registerEvent } from "../register-event"; + +const getNetworkInterfaces = async (): Promise => { + const interfaces = os.networkInterfaces(); + + return Object.entries(interfaces).reduce( + (acc, [name, addresses]) => { + const external = (addresses ?? []).filter((address) => !address.internal); + + if (external.length === 0) { + return acc; + } + + const sorted = external.sort((a, b) => { + if (a.family === b.family) return 0; + return a.family === "IPv4" ? -1 : 1; + }); + + acc.push({ + name, + addresses: sorted.map((address) => address.address), + }); + + return acc; + }, + [] + ); +}; + +registerEvent("getNetworkInterfaces", getNetworkInterfaces); diff --git a/src/main/events/hardware/index.ts b/src/main/events/hardware/index.ts index 76823f51cf..d0753097eb 100644 --- a/src/main/events/hardware/index.ts +++ b/src/main/events/hardware/index.ts @@ -1,2 +1,3 @@ import "./check-folder-write-permission"; import "./get-disk-free-space"; +import "./get-network-interfaces"; diff --git a/src/main/events/user-preferences/update-user-preferences.ts b/src/main/events/user-preferences/update-user-preferences.ts index eb0834db52..852d736de0 100644 --- a/src/main/events/user-preferences/update-user-preferences.ts +++ b/src/main/events/user-preferences/update-user-preferences.ts @@ -94,6 +94,12 @@ const updateUserPreferences = async ( preferences.maxDownloadSpeedBytesPerSecond ?? null ); } + + if (Object.hasOwn(preferences, "torrentNetworkInterface")) { + await DownloadManager.applyNetworkInterface( + preferences.torrentNetworkInterface ?? null + ); + } }; registerEvent("updateUserPreferences", updateUserPreferences); diff --git a/src/main/services/download/download-manager.ts b/src/main/services/download/download-manager.ts index dd85cf7bf4..ae5caffae1 100644 --- a/src/main/services/download/download-manager.ts +++ b/src/main/services/download/download-manager.ts @@ -21,6 +21,7 @@ import { calculateETA, getDirSize } from "./helpers"; import { RealDebridClient } from "./real-debrid"; import path from "node:path"; import fs from "node:fs"; +import os from "node:os"; import { logger } from "../logger"; import { db, downloadsSublevel, gamesSublevel, levelKeys } from "@main/level"; import { TorBoxClient } from "./torbox"; @@ -303,12 +304,60 @@ export class DownloadManager { }); } + private static async getPersistedNetworkInterface() { + const userPreferences = await db.get( + levelKeys.userPreferences, + { valueEncoding: "json" } + ); + + return userPreferences?.torrentNetworkInterface ?? null; + } + + private static resolveNetworkInterfaceBinding(name: string | null): string { + if (!name) return ""; + + const addresses = os.networkInterfaces()[name] ?? []; + const usable = addresses.filter( + (address) => + !address.internal && + !( + address.family === "IPv6" && + address.address.toLowerCase().startsWith("fe80") + ) + ); + + if (usable.length === 0) return name; + + return usable.map((address) => address.address).join(","); + } + + public static async applyNetworkInterface( + value?: string | null + ): Promise { + const networkInterface = + value ?? (await this.getPersistedNetworkInterface()); + + await PythonRPC.rpc + .call("action", { + action: "set_network_interface", + interface: this.resolveNetworkInterfaceBinding(networkInterface), + }) + .catch((error) => { + logger.error( + "[DownloadManager] Failed to update RPC network interface:", + error + ); + }); + } + public static async startRPC( download?: Download, downloadsToSeed?: Download[] ) { await PythonRPC.spawn(); + await this.applyNetworkInterface(); + if (downloadsToSeed?.length) { for (const seedDownload of downloadsToSeed) { await this.resumeSeeding(seedDownload).catch((error) => { diff --git a/src/preload/index.ts b/src/preload/index.ts index cd9c81d571..a20c5a043b 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -784,6 +784,7 @@ contextBridge.exposeInMainWorld("electron", { ipcRenderer.invoke("getDiskFreeSpace", path), checkFolderWritePermission: (path: string) => ipcRenderer.invoke("checkFolderWritePermission", path), + getNetworkInterfaces: () => ipcRenderer.invoke("getNetworkInterfaces"), /* Cloud save */ uploadSaveGame: ( diff --git a/src/renderer/src/declaration.d.ts b/src/renderer/src/declaration.d.ts index 79f5f8e955..2418bfdde1 100644 --- a/src/renderer/src/declaration.d.ts +++ b/src/renderer/src/declaration.d.ts @@ -37,6 +37,7 @@ import type { AchievementNotificationInfo, Game, DiskUsage, + NetworkInterface, DownloadSource, LocalNotification, ProtonVersion, @@ -620,6 +621,7 @@ declare global { /* Hardware */ getDiskFreeSpace: (path: string) => Promise; checkFolderWritePermission: (path: string) => Promise; + getNetworkInterfaces: () => Promise; /* Cloud save */ uploadSaveGame: ( diff --git a/src/renderer/src/pages/settings/settings-context-downloads.tsx b/src/renderer/src/pages/settings/settings-context-downloads.tsx index a46757ba1e..4d6acceead 100644 --- a/src/renderer/src/pages/settings/settings-context-downloads.tsx +++ b/src/renderer/src/pages/settings/settings-context-downloads.tsx @@ -1,9 +1,10 @@ -import { useContext, useEffect, useState } from "react"; +import { useContext, useEffect, useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; -import { CheckboxField, TextField } from "@renderer/components"; +import { CheckboxField, SelectField, TextField } from "@renderer/components"; import { settingsContext } from "@renderer/context"; import { useAppSelector } from "@renderer/hooks"; +import type { NetworkInterface } from "@types"; import { SettingsDownloadSources } from "./settings-download-sources"; import "./settings-general.scss"; @@ -50,8 +51,20 @@ export function SettingsContextDownloads() { createStartMenuShortcut: true, maxDownloadSpeedMegabytes: "", deleteArchiveFilesAfterExtractionByDefault: false, + torrentNetworkInterface: "", }); + const [networkInterfaces, setNetworkInterfaces] = useState< + NetworkInterface[] + >([]); + + useEffect(() => { + globalThis.electron + .getNetworkInterfaces() + .then(setNetworkInterfaces) + .catch(() => setNetworkInterfaces([])); + }, []); + useEffect(() => { if (!userPreferences) return; @@ -72,9 +85,40 @@ export function SettingsContextDownloads() { : "", deleteArchiveFilesAfterExtractionByDefault: userPreferences.deleteArchiveFilesAfterExtractionByDefault ?? false, + torrentNetworkInterface: userPreferences.torrentNetworkInterface ?? "", }); }, [userPreferences]); + const networkInterfaceOptions = useMemo(() => { + const options = [ + { key: "default", value: "", label: t("network_interface_default") }, + ...networkInterfaces.map((networkInterface) => { + const ipv4 = networkInterface.addresses.find( + (address) => !address.includes(":") + ); + + return { + key: networkInterface.name, + value: networkInterface.name, + label: ipv4 + ? `${networkInterface.name} (${ipv4})` + : networkInterface.name, + }; + }), + ]; + + const selected = form.torrentNetworkInterface; + if (selected && !options.some((option) => option.value === selected)) { + options.push({ + key: selected, + value: selected, + label: `${selected} (${t("network_interface_unavailable")})`, + }); + } + + return options; + }, [networkInterfaces, form.torrentNetworkInterface, t]); + const handleChange = (values: Partial) => { setForm((prev) => ({ ...prev, ...values })); updateUserPreferences(values); @@ -159,6 +203,20 @@ export function SettingsContextDownloads() { placeholder={t("max_download_speed_unlimited")} /> +
+ + handleChange({ torrentNetworkInterface: event.target.value }) + } + options={networkInterfaceOptions} + /> + + {t("network_interface_hint")} + +
+