Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 38 additions & 0 deletions python_rpc/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -247,6 +247,40 @@ 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 apply_network_interface(interface):
if interface:
listen_interfaces = "{iface}:{port}".format(
iface=interface, port=torrent_port
)
outgoing_interfaces = interface
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
Expand Down Expand Up @@ -511,6 +545,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:
Expand Down
4 changes: 4 additions & 0 deletions src/locales/en/translation.json
Original file line number Diff line number Diff line change
Expand Up @@ -769,6 +769,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",
Expand Down
4 changes: 4 additions & 0 deletions src/locales/es/translation.json
Original file line number Diff line number Diff line change
Expand Up @@ -731,6 +731,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",
Expand Down
4 changes: 4 additions & 0 deletions src/locales/pt-BR/translation.json
Original file line number Diff line number Diff line change
Expand Up @@ -679,6 +679,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",
Expand Down
4 changes: 4 additions & 0 deletions src/locales/pt-PT/translation.json
Original file line number Diff line number Diff line change
Expand Up @@ -626,6 +626,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",
Expand Down
4 changes: 4 additions & 0 deletions src/locales/ru/translation.json
Original file line number Diff line number Diff line change
Expand Up @@ -770,6 +770,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": "Контент и игровой процесс",
Expand Down
33 changes: 33 additions & 0 deletions src/main/events/hardware/get-network-interfaces.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import os from "node:os";

import type { NetworkInterface } from "@types";
import { registerEvent } from "../register-event";

const getNetworkInterfaces = async (): Promise<NetworkInterface[]> => {
const interfaces = os.networkInterfaces();

return Object.entries(interfaces).reduce<NetworkInterface[]>(
(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);
1 change: 1 addition & 0 deletions src/main/events/hardware/index.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
import "./check-folder-write-permission";
import "./get-disk-free-space";
import "./get-network-interfaces";
6 changes: 6 additions & 0 deletions src/main/events/user-preferences/update-user-preferences.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,12 @@ const updateUserPreferences = async (
preferences.maxDownloadSpeedBytesPerSecond ?? null
);
}

if (Object.hasOwn(preferences, "torrentNetworkInterface")) {
await DownloadManager.applyNetworkInterface(
preferences.torrentNetworkInterface ?? null
);
}
};

registerEvent("updateUserPreferences", updateUserPreferences);
30 changes: 30 additions & 0 deletions src/main/services/download/download-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -266,12 +266,42 @@ export class DownloadManager {
});
}

private static async getPersistedNetworkInterface() {
const userPreferences = await db.get<string, UserPreferences | null>(
levelKeys.userPreferences,
{ valueEncoding: "json" }
);

return userPreferences?.torrentNetworkInterface ?? null;
}

public static async applyNetworkInterface(
value?: string | null
): Promise<void> {
const networkInterface =
value ?? (await this.getPersistedNetworkInterface());

await PythonRPC.rpc
.call("action", {
action: "set_network_interface",
interface: 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) => {
Expand Down
1 change: 1 addition & 0 deletions src/preload/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -778,6 +778,7 @@ contextBridge.exposeInMainWorld("electron", {
ipcRenderer.invoke("getDiskFreeSpace", path),
checkFolderWritePermission: (path: string) =>
ipcRenderer.invoke("checkFolderWritePermission", path),
getNetworkInterfaces: () => ipcRenderer.invoke("getNetworkInterfaces"),

/* Cloud save */
uploadSaveGame: (
Expand Down
2 changes: 2 additions & 0 deletions src/renderer/src/declaration.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ import type {
AchievementNotificationInfo,
Game,
DiskUsage,
NetworkInterface,
DownloadSource,
LocalNotification,
ProtonVersion,
Expand Down Expand Up @@ -605,6 +606,7 @@ declare global {
/* Hardware */
getDiskFreeSpace: (path: string) => Promise<DiskUsage>;
checkFolderWritePermission: (path: string) => Promise<boolean>;
getNetworkInterfaces: () => Promise<NetworkInterface[]>;

/* Cloud save */
uploadSaveGame: (
Expand Down
62 changes: 60 additions & 2 deletions src/renderer/src/pages/settings/settings-context-downloads.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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;

Expand All @@ -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<typeof form>) => {
setForm((prev) => ({ ...prev, ...values }));
updateUserPreferences(values);
Expand Down Expand Up @@ -159,6 +203,20 @@ export function SettingsContextDownloads() {
placeholder={t("max_download_speed_unlimited")}
/>

<div className="settings-general__network-interface">
<SelectField
label={t("network_interface")}
value={form.torrentNetworkInterface}
onChange={(event) =>
handleChange({ torrentNetworkInterface: event.target.value })
}
options={networkInterfaceOptions}
/>
<small className="settings-general__network-interface-hint">
{t("network_interface_hint")}
</small>
</div>

<CheckboxField
label={t("seed_after_download_complete")}
checked={form.seedAfterDownloadComplete}
Expand Down
14 changes: 13 additions & 1 deletion src/renderer/src/pages/settings/settings-general.scss
Original file line number Diff line number Diff line change
Expand Up @@ -19,12 +19,24 @@
}

&__disabled-hint {
font-size: 13px;
font-size: globals.$hint-font-size;
color: globals.$muted-color;
margin-top: calc(globals.$spacing-unit * -1);
font-style: italic;
}

&__network-interface {
display: flex;
flex-direction: column;
gap: globals.$spacing-unit;
margin-block: calc(globals.$spacing-unit * 2);
}

&__network-interface-hint {
font-size: globals.$hint-font-size;
color: globals.$muted-color;
}
Comment thread
Moyasee marked this conversation as resolved.

&__volume-control {
display: flex;
flex-direction: column;
Expand Down
1 change: 1 addition & 0 deletions src/renderer/src/scss/globals.scss
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ $backdrop-z-index: 4;
$modal-z-index: 5;

$body-font-size: 14px;
$hint-font-size: 13px;
$small-font-size: 12px;

$app-container: app-container;
6 changes: 6 additions & 0 deletions src/types/level.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -182,13 +182,19 @@ export interface UserPreferences {
bigPictureDiagnosticsEnabled?: boolean;
bigPictureDiagnosticsPosition?: BigPictureDiagnosticsPosition;
maxDownloadSpeedBytesPerSecond?: number | null;
torrentNetworkInterface?: string | null;
defaultProtonPath?: string | null;
autoRunMangohud?: boolean;
autoRunGamemode?: boolean;
hideClassicsBookmark?: boolean;
classicsUseHeroLayout?: boolean;
}

export interface NetworkInterface {
name: string;
addresses: string[];
}

export interface ScreenState {
x?: number;
y?: number;
Expand Down
Loading