diff --git a/nym-vpn-app/CHANGELOG.md b/nym-vpn-app/CHANGELOG.md index 24f37dbe3e..a9d9aea31c 100644 --- a/nym-vpn-app/CHANGELOG.md +++ b/nym-vpn-app/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Add custom DNS settings + ## [1.20.3] - 2025-12-09 ## [1.20.0] - 2025-12-02 diff --git a/nym-vpn-app/package-lock.json b/nym-vpn-app/package-lock.json index a683494720..fc9edcae74 100644 --- a/nym-vpn-app/package-lock.json +++ b/nym-vpn-app/package-lock.json @@ -10,6 +10,9 @@ "license": "GPL-3.0-only", "dependencies": { "@base-ui-components/react": "^1.0.0-beta.3", + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/sortable": "^10.0.0", + "@dnd-kit/utilities": "^3.2.2", "@headlessui/react": "^2.1.2", "@lottiefiles/dotlottie-react": "^0.17.0", "@radix-ui/react-accordion": "^1.2.3", @@ -383,6 +386,60 @@ } } }, + "node_modules/@dnd-kit/accessibility": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz", + "integrity": "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/core": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz", + "integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "@dnd-kit/accessibility": "^3.1.1", + "@dnd-kit/utilities": "^3.2.2", + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/sortable": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-10.0.0.tgz", + "integrity": "sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==", + "license": "MIT", + "dependencies": { + "@dnd-kit/utilities": "^3.2.2", + "tslib": "^2.0.0" + }, + "peerDependencies": { + "@dnd-kit/core": "^6.3.0", + "react": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/utilities": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz", + "integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.25.12", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", diff --git a/nym-vpn-app/package.json b/nym-vpn-app/package.json index 9d49b565bb..e00312db8d 100644 --- a/nym-vpn-app/package.json +++ b/nym-vpn-app/package.json @@ -31,6 +31,9 @@ }, "dependencies": { "@base-ui-components/react": "^1.0.0-beta.3", + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/sortable": "^10.0.0", + "@dnd-kit/utilities": "^3.2.2", "@headlessui/react": "^2.1.2", "@lottiefiles/dotlottie-react": "^0.17.0", "@radix-ui/react-accordion": "^1.2.3", diff --git a/nym-vpn-app/src-tauri/src/commands/tunnel.rs b/nym-vpn-app/src-tauri/src/commands/tunnel.rs index 7046fbfc3c..05e0fc7438 100644 --- a/nym-vpn-app/src-tauri/src/commands/tunnel.rs +++ b/nym-vpn-app/src-tauri/src/commands/tunnel.rs @@ -9,6 +9,7 @@ use crate::{ tunnel::{ConnectingState, TunnelState}, }, }; +use std::net::IpAddr; use tauri::{Manager, State}; use tracing::{debug, info, instrument, warn}; @@ -153,3 +154,30 @@ pub async fn set_allow_lan(vpnd: State<'_, VpndClient>, enabled: bool) -> Result vpnd.set_allow_lan(enabled).await?; Ok(()) } + +#[instrument(skip(vpnd))] +#[tauri::command] +pub async fn get_default_dns(vpnd: State<'_, VpndClient>) -> Result, BackendError> { + let dns = vpnd.get_default_dns().await?; + Ok(dns) +} + +#[instrument(skip(vpnd))] +#[tauri::command] +pub async fn set_custom_dns_enabled( + vpnd: State<'_, VpndClient>, + enabled: bool, +) -> Result<(), BackendError> { + vpnd.set_custom_dns_enabled(enabled).await?; + Ok(()) +} + +#[instrument(skip(vpnd))] +#[tauri::command] +pub async fn set_custom_dns( + vpnd: State<'_, VpndClient>, + dns: Vec, +) -> Result<(), BackendError> { + vpnd.set_custom_dns(dns).await?; + Ok(()) +} diff --git a/nym-vpn-app/src-tauri/src/main.rs b/nym-vpn-app/src-tauri/src/main.rs index 8022f4437f..31a240d520 100644 --- a/nym-vpn-app/src-tauri/src/main.rs +++ b/nym-vpn-app/src-tauri/src/main.rs @@ -263,6 +263,9 @@ async fn main() -> Result<()> { tunnel::disconnect, tunnel::set_node, tunnel::set_quic, + tunnel::get_default_dns, + tunnel::set_custom_dns, + tunnel::set_custom_dns_enabled, tunnel::set_no_ipv6, tunnel::set_allow_lan, cmd_db::db_set, diff --git a/nym-vpn-app/src-tauri/src/vpnd/client.rs b/nym-vpn-app/src-tauri/src/vpnd/client.rs index 78075a583a..fc4a3914c9 100644 --- a/nym-vpn-app/src-tauri/src/vpnd/client.rs +++ b/nym-vpn-app/src-tauri/src/vpnd/client.rs @@ -21,6 +21,7 @@ use nym_vpn_proto::rpc_client::RpcClient; use once_cell::sync::Lazy; use std::{ env::consts::{ARCH, OS}, + net::IpAddr, path::PathBuf, sync::Mutex, }; @@ -772,6 +773,46 @@ impl VpndClient { Ok(()) } + #[instrument(skip_all)] + pub async fn get_default_dns(&self) -> Result, VpndError> { + let mut vpnd = self.vpnd().await?; + + let dns = vpnd.get_default_dns().await.map_err(|e| { + error!("failed to get default DNS: {}", e); + VpndError::RpcClient(e) + })?; + + Ok(dns) + } + + #[instrument(skip_all)] + pub async fn set_custom_dns_enabled(&self, enabled: bool) -> Result<(), VpndError> { + let mut vpnd = self.vpnd().await?; + + vpnd.set_enable_custom_dns(enabled) + .await + .map_err(VpndError::RpcClient) + .inspect_err(|e| { + error!("failed to set custom DNS enabled: {}", e); + })?; + + Ok(()) + } + + #[instrument(skip_all)] + pub async fn set_custom_dns(&self, dns: Vec) -> Result<(), VpndError> { + let mut vpnd = self.vpnd().await?; + + vpnd.set_custom_dns(dns) + .await + .map_err(VpndError::RpcClient) + .inspect_err(|e| { + error!("failed to set custom DNS: {}", e); + })?; + + Ok(()) + } + pub fn reset_log_flag() { let mut logged = VPND_DOWN_LOGGED.lock().unwrap(); *logged = false; diff --git a/nym-vpn-app/src-tauri/src/vpnd/config.rs b/nym-vpn-app/src-tauri/src/vpnd/config.rs index 0e02e5c28a..8d02212f2b 100644 --- a/nym-vpn-app/src-tauri/src/vpnd/config.rs +++ b/nym-vpn-app/src-tauri/src/vpnd/config.rs @@ -13,6 +13,7 @@ pub struct VpndConfig { pub entry_node: Node, pub exit_node: Node, pub custom_dns: Option>, + pub enable_custom_dns: bool, pub allow_lan: bool, pub disable_ipv6: bool, pub vpn_mode: VpnMode, @@ -38,6 +39,7 @@ impl VpndConfig { entry_node: config.entry_point.into(), exit_node: config.exit_point.try_into()?, custom_dns: Some(config.custom_dns), + enable_custom_dns: config.enable_custom_dns, allow_lan: config.allow_lan, disable_ipv6: config.disable_ipv6, vpn_mode, diff --git a/nym-vpn-app/src/App.tsx b/nym-vpn-app/src/App.tsx index f7ab1724f6..840c731c00 100644 --- a/nym-vpn-app/src/App.tsx +++ b/nym-vpn-app/src/App.tsx @@ -12,6 +12,7 @@ import { MainStateProvider, NodeListStateProvider, Socks5Provider, + TopBarProvider, } from './contexts'; import { useLang } from './hooks'; import { LngTag } from './i18n'; @@ -74,9 +75,11 @@ function App({ init }: { init: InitState }) { - }> - - + + }> + + + diff --git a/nym-vpn-app/src/components/confirmation-dialog/ConfirmationDialog.tsx b/nym-vpn-app/src/components/confirmation-dialog/ConfirmationDialog.tsx new file mode 100644 index 0000000000..4e7a5d5a21 --- /dev/null +++ b/nym-vpn-app/src/components/confirmation-dialog/ConfirmationDialog.tsx @@ -0,0 +1,95 @@ +import { useCallback, useEffect, useState } from 'react'; +import { useNavigate } from 'react-router'; +import { DialogTitle } from '@headlessui/react'; +import { useTranslation } from 'react-i18next'; +import { Button, Dialog, MsIcon } from '../../ui'; +import { useTopBar } from '../../contexts'; + +type ConfirmationDialogProps = { + hasUnsavedChanges: boolean; + onConfirm: () => Promise; + onCancel: () => void; +}; + +export function ConfirmationDialog({ + hasUnsavedChanges, + onConfirm, + onCancel, +}: ConfirmationDialogProps) { + const { t } = useTranslation('settings'); + const { setCustomLeftNavHandler } = useTopBar(); + const navigate = useNavigate(); + + const [isConfirmationDialogOpen, setIsConfirmationDialogOpen] = + useState(false); + const [isApplying, setIsApplying] = useState(false); + + const handleBackNavigation = useCallback(() => { + if (hasUnsavedChanges) { + setIsConfirmationDialogOpen(true); + } else { + navigate(-1); + } + }, [hasUnsavedChanges, navigate, setIsConfirmationDialogOpen]); + + useEffect(() => { + setCustomLeftNavHandler(handleBackNavigation); + return () => { + setCustomLeftNavHandler(null); + }; + }, [handleBackNavigation, setCustomLeftNavHandler]); + + const handleConfirm = async () => { + setIsApplying(true); + try { + await onConfirm(); + } finally { + setIsApplying(false); + } + }; + + const handleCancel = () => { + setIsConfirmationDialogOpen(false); + onCancel(); + }; + + return ( + setIsConfirmationDialogOpen(false)} + > +
+ + + + {t('confirmation-dialog.title')} + +
+

+ {t('confirmation-dialog.description')} +

+
+ + +
+
+ ); +} diff --git a/nym-vpn-app/src/components/confirmation-dialog/index.ts b/nym-vpn-app/src/components/confirmation-dialog/index.ts new file mode 100644 index 0000000000..47269cb00b --- /dev/null +++ b/nym-vpn-app/src/components/confirmation-dialog/index.ts @@ -0,0 +1 @@ +export { ConfirmationDialog } from './ConfirmationDialog'; diff --git a/nym-vpn-app/src/components/index.ts b/nym-vpn-app/src/components/index.ts new file mode 100644 index 0000000000..e8b635838f --- /dev/null +++ b/nym-vpn-app/src/components/index.ts @@ -0,0 +1 @@ +export * from './confirmation-dialog'; diff --git a/nym-vpn-app/src/constants.ts b/nym-vpn-app/src/constants.ts index 0c0ca8d94a..cc3ffdf654 100644 --- a/nym-vpn-app/src/constants.ts +++ b/nym-vpn-app/src/constants.ts @@ -56,3 +56,6 @@ export const ResidentialIpServersUrl = 'https://support.nym.com/hc/en-us/articles/35279486714641-Why-can-t-I-access-streaming-services-while-using-NymVPN'; export const QuicSupportArticleUrl = 'https://support.nym.com/hc/en-us/articles/39648047741457-QUIC-transport-mode'; +export const LocationAccuracyLink = + 'https://support.nym.com/hc/en-us/articles/26448676449297-How-is-server-location-determined-by-NymVPN'; +export const CustomDnsHelpUrl = 'https://nym.com/features/custom-dns'; diff --git a/nym-vpn-app/src/contexts/index.ts b/nym-vpn-app/src/contexts/index.ts index 0f4f233c06..08018bf76a 100644 --- a/nym-vpn-app/src/contexts/index.ts +++ b/nym-vpn-app/src/contexts/index.ts @@ -5,3 +5,4 @@ export * from './node-list'; export * from './node-list-state'; export * from './gateways'; export * from './socks5'; +export * from './topbar'; diff --git a/nym-vpn-app/src/contexts/main/provider.tsx b/nym-vpn-app/src/contexts/main/provider.tsx index fdb285c1ab..6c551bf737 100644 --- a/nym-vpn-app/src/contexts/main/provider.tsx +++ b/nym-vpn-app/src/contexts/main/provider.tsx @@ -28,6 +28,8 @@ function MainStateProvider({ children, init }: Props) { quic: init.quic, ipv6Support: !init.noIpv6, allowLan: init.allowLan, + customDnsEnabled: init.customDnsEnabled, + customDns: init.customDns, }); const { push } = useInAppNotify(); diff --git a/nym-vpn-app/src/contexts/main/reducer.ts b/nym-vpn-app/src/contexts/main/reducer.ts index 6b435e86fe..2401b5da7a 100644 --- a/nym-vpn-app/src/contexts/main/reducer.ts +++ b/nym-vpn-app/src/contexts/main/reducer.ts @@ -76,7 +76,10 @@ export type StateAction = | { type: 'set-backend-flags'; flags: FeatureFlags } | { type: 'set-quic'; enabled: boolean } | { type: 'set-domain-fronting'; enabled: boolean } - | { type: 'set-streaming-optimized-label-seen'; seen: boolean }; + | { type: 'set-streaming-optimized-label-seen'; seen: boolean } + | { type: 'set-custom-dns-enabled'; enabled: boolean } + | { type: 'set-custom-dns'; dns: string[] } + | { type: 'set-default-dns'; dns: string[] }; export const initialState: AppState = { initialized: false, @@ -115,6 +118,9 @@ export const initialState: AppState = { zknymCredential: false, }, streamingOptimizedLabelSeen: false, + customDnsEnabled: false, + customDns: [], + defaultDns: [], }; export function reducer(state: AppState, action: StateAction): AppState { @@ -380,7 +386,21 @@ export function reducer(state: AppState, action: StateAction): AppState { ...state, domainFronting: action.enabled, }; - + case 'set-custom-dns-enabled': + return { + ...state, + customDnsEnabled: action.enabled, + }; + case 'set-custom-dns': + return { + ...state, + customDns: action.dns, + }; + case 'set-default-dns': + return { + ...state, + defaultDns: action.dns, + }; case 'reset': return initialState; } diff --git a/nym-vpn-app/src/contexts/topbar/context.ts b/nym-vpn-app/src/contexts/topbar/context.ts new file mode 100644 index 0000000000..399cb534c1 --- /dev/null +++ b/nym-vpn-app/src/contexts/topbar/context.ts @@ -0,0 +1,23 @@ +import { createContext, useContext } from 'react'; + +export type TopBarContextType = { + // Custom handler that overrides the default left nav behavior + readonly customLeftNavHandler: (() => void) | null; + // Set a custom handler for the left navigation button + // Call with null to clear the custom handler + readonly setCustomLeftNavHandler: (handler: (() => void) | null) => void; +}; + +const init: TopBarContextType = { + customLeftNavHandler: null, + setCustomLeftNavHandler: () => { + /* SCARECROW */ + }, +}; + +export const TopBarContext = createContext(init); + +// Access the TopBar context +export const useTopBar = () => { + return useContext(TopBarContext); +}; diff --git a/nym-vpn-app/src/contexts/topbar/index.ts b/nym-vpn-app/src/contexts/topbar/index.ts new file mode 100644 index 0000000000..2263e49d95 --- /dev/null +++ b/nym-vpn-app/src/contexts/topbar/index.ts @@ -0,0 +1,3 @@ +export { default as TopBarProvider } from './provider'; +export { useTopBar } from './context'; +export * from './provider'; diff --git a/nym-vpn-app/src/contexts/topbar/provider.tsx b/nym-vpn-app/src/contexts/topbar/provider.tsx new file mode 100644 index 0000000000..0ef4562991 --- /dev/null +++ b/nym-vpn-app/src/contexts/topbar/provider.tsx @@ -0,0 +1,33 @@ +import { useCallback, useMemo, useState } from 'react'; +import { TopBarContext } from './context'; + +export type TopBarProviderProps = { + children: React.ReactNode; +}; + +function TopBarProvider({ children }: TopBarProviderProps) { + const [customLeftNavHandler, setCustomLeftNavHandlerState] = useState< + (() => void) | null + >(null); + + const setCustomLeftNavHandler = useCallback( + (handler: (() => void) | null) => { + setCustomLeftNavHandlerState(() => handler); + }, + [], + ); + + const ctx = useMemo( + () => ({ + customLeftNavHandler, + setCustomLeftNavHandler, + }), + [customLeftNavHandler, setCustomLeftNavHandler], + ); + + return ( + {children} + ); +} + +export default TopBarProvider; diff --git a/nym-vpn-app/src/hooks/useCustomDns.ts b/nym-vpn-app/src/hooks/useCustomDns.ts new file mode 100644 index 0000000000..12dcb0c8a5 --- /dev/null +++ b/nym-vpn-app/src/hooks/useCustomDns.ts @@ -0,0 +1,50 @@ +import { invoke } from '@tauri-apps/api/core'; +import { useTranslation } from 'react-i18next'; +import { useMainDispatch, useMainState } from '../contexts/main/context'; +import { StateDispatch } from '../types'; +import { useInAppNotify } from '../contexts'; + +function useCustomDns() { + const { t } = useTranslation('settings'); + const { customDnsEnabled, customDns, defaultDns } = useMainState(); + const dispatch = useMainDispatch() as StateDispatch; + const { push } = useInAppNotify(); + + const toggle = async (state: boolean) => { + try { + await invoke('set_custom_dns_enabled', { enabled: state }); + dispatch({ type: 'set-custom-dns-enabled', enabled: state }); + } catch (e) { + console.error(e); + push({ + message: t('dns.error.failed'), + close: true, + type: 'error', + }); + } + }; + + const setCustomDns = async (dns: string[]) => { + try { + await invoke('set_custom_dns', { dns }); + dispatch({ type: 'set-custom-dns', dns }); + } catch (e) { + console.error(e); + push({ + message: t('dns.error.failed'), + close: true, + type: 'error', + }); + } + }; + + return { + enabled: customDnsEnabled, + toggle, + customDns, + defaultDns, + setCustomDns, + }; +} + +export default useCustomDns; diff --git a/nym-vpn-app/src/i18n/en/settings.json b/nym-vpn-app/src/i18n/en/settings.json index 0815385f38..093d676b41 100644 --- a/nym-vpn-app/src/i18n/en/settings.json +++ b/nym-vpn-app/src/i18n/en/settings.json @@ -32,6 +32,35 @@ "description": "Make the app better in your language." } }, + "dns": { + "title": "Customize DNS", + "topbar-title": "DNS customization", + "top-description": "By default, using Nym's recommended DNS servers. Configure custom servers below if needed.", + "view-default-dns": "View default DNS", + "hide-default-dns": "Hide default DNS", + "details": { + "title": "Use custom DNS servers", + "description": "Configure your own DNS for ad-blocking, content filtering, or preferred providers. Drag to reorder priority. ⚠️ May limit Nym connectivity.", + "list-header": "Custom DNS servers", + "add": "Add", + "input-label": "DNS address", + "input-placeholder": "IPv4 or IPv6 address", + "apply": "Save changes", + "link": "Learn more about DNS", + "applied": "Custom DNS saved." + }, + "error": { + "duplicate": "Duplicate DNS address", + "invalid": "Invalid DNS address format", + "failed": "Failed to apply DNS changes" + } + }, + "confirmation-dialog": { + "title": "Save changes?", + "description": "You have unsaved changes.", + "save": "Save changes", + "cancel": "Discard" + }, "legal": { "title": "Legal", "policy": "Privacy statement", diff --git a/nym-vpn-app/src/main.tsx b/nym-vpn-app/src/main.tsx index f962684344..9880aded37 100644 --- a/nym-vpn-app/src/main.tsx +++ b/nym-vpn-app/src/main.tsx @@ -95,6 +95,9 @@ dayjs.extend(duration); config?.disableIpv6 !== undefined ? config.disableIpv6 : defaultNoIpv6, allowLan: config?.allowLan !== undefined ? config.allowLan : defaultAllowLan, + customDnsEnabled: + config?.enableCustomDns !== undefined ? config.enableCustomDns : false, + customDns: !config?.customDns ? [] : config.customDns, }; console.log('initial state:', initState); diff --git a/nym-vpn-app/src/router.tsx b/nym-vpn-app/src/router.tsx index 3b78d80c17..4129fd9933 100644 --- a/nym-vpn-app/src/router.tsx +++ b/nym-vpn-app/src/router.tsx @@ -5,6 +5,7 @@ import { AntiCensorship, Appearance, AppearanceRouteIndex, + CustomDNS, DataAndPrivacy, Dev, Display, @@ -40,6 +41,7 @@ export const routes = { display: '/settings/appearance/display', lang: '/settings/appearance/lang', logs: '/settings/logs', + dns: '/settings/dns', antiCensorship: '/settings/anti-censorship', socks5: '/settings/socks5', dataPrivacy: '/settings/data-privacy', @@ -132,6 +134,11 @@ const router = createBrowserRouter([ Component: Logs, errorElement: , }, + { + path: routes.dns, + Component: CustomDNS, + errorElement: , + }, { path: routes.antiCensorship, Component: AntiCensorship, diff --git a/nym-vpn-app/src/screens/settings/Settings.tsx b/nym-vpn-app/src/screens/settings/Settings.tsx index 474d1fd0f9..9057890a63 100644 --- a/nym-vpn-app/src/screens/settings/Settings.tsx +++ b/nym-vpn-app/src/screens/settings/Settings.tsx @@ -82,6 +82,12 @@ function Settings() { onClick: handleAllowLan, trailing: , }, + { + title: t('dns.title'), + leadingIcon: 'dns', + onClick: () => navigate(routes.dns), + trailing: , + }, { title: t('anti-censorship.title', { ns: 'settings' }), leadingIcon: 'campaign', diff --git a/nym-vpn-app/src/screens/settings/custom-dns/CustomDNS.tsx b/nym-vpn-app/src/screens/settings/custom-dns/CustomDNS.tsx new file mode 100644 index 0000000000..969371b38d --- /dev/null +++ b/nym-vpn-app/src/screens/settings/custom-dns/CustomDNS.tsx @@ -0,0 +1,106 @@ +import { useMemo, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useNavigate } from 'react-router'; +import { CardSwitch, Link, PageAnim, SettingsMenuCardBig } from '../../../ui'; +import { CustomDnsHelpUrl } from '../../../constants'; +import useCustomDns from '../../../hooks/useCustomDns'; +import { useInAppNotify } from '../../../contexts'; +import { ConfirmationDialog } from '../../../components'; +import { CustomDnsServers } from './CustomDnsServers'; +import { DefaultDnsServers } from './DefaultDnsServers'; +import { DnsItem } from './DnsItemContent'; + +function CustomDNS() { + const { t } = useTranslation('settings'); + const navigate = useNavigate(); + const { + enabled: customDnsEnabled, + toggle: toggleCustomDns, + setCustomDns, + customDns, + } = useCustomDns(); + const { push } = useInAppNotify(); + + const [customDnsList, setCustomDnsList] = useState(() => + customDns.map((dns) => ({ id: dns, dns })), + ); + + const hasUnsavedChanges = useMemo(() => { + if (customDnsList.length !== customDns.length) return true; + + return !customDnsList.every((dns, index) => dns.dns === customDns[index]); + }, [customDnsList, customDns]); + + const applyChanges = async () => { + await setCustomDns(customDnsList.map((item) => item.dns)); + push({ + message: t('dns.details.applied'), + close: true, + type: 'info', + }); + }; + + const handleDnsSwitchChange = async () => { + await toggleCustomDns(!customDnsEnabled); + }; + + const handleListChange = (dnsList: DnsItem[]) => { + setCustomDnsList(dnsList); + }; + + const handleConfirmation = async () => { + await applyChanges(); + navigate(-1); + }; + + const handleCancel = () => { + navigate(-1); + }; + + return ( + +

+ {t('dns.top-description')} +

+ + + + } + > +
+

+ {t('dns.details.description')} +

+ + +
+
+ + + +
+ ); +} + +export default CustomDNS; diff --git a/nym-vpn-app/src/screens/settings/custom-dns/CustomDnsServers.tsx b/nym-vpn-app/src/screens/settings/custom-dns/CustomDnsServers.tsx new file mode 100644 index 0000000000..a2001f3135 --- /dev/null +++ b/nym-vpn-app/src/screens/settings/custom-dns/CustomDnsServers.tsx @@ -0,0 +1,152 @@ +import { useMemo, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import clsx from 'clsx'; +import { Button, TextInput } from '../../../ui'; +import DraggableList from '../../../ui/DraggableList'; +import { ipv4Regex, ipv6Regex } from '../../../utils'; +import { DnsItem, DnsItemContent } from './DnsItemContent'; + +const MAX_DNS_SERVERS = 5; + +export function CustomDnsServers({ + hasUnsavedChanges, + onApplyDns, + customDnsList, + onListChange, +}: { + hasUnsavedChanges: boolean; + onApplyDns: (dnsList: string[]) => Promise; + customDnsList: DnsItem[]; + onListChange: (dnsList: DnsItem[]) => void; +}) { + const { t } = useTranslation('settings'); + + const [inputValue, setInputValue] = useState(''); + const [errorMessage, setErrorMessage] = useState(''); + const [isApplyingDns, setIsApplyingDns] = useState(false); + + const isInputValueValid = useMemo( + () => + ipv4Regex.test(inputValue.trim()) || ipv6Regex.test(inputValue.trim()), + [inputValue], + ); + + const handleAddDns = () => { + const inputValueTrimmed = inputValue.trim(); + if (inputValueTrimmed === '') return; + + const containsDuplicate = customDnsList.some( + (item) => item.dns === inputValueTrimmed, + ); + + if (containsDuplicate) { + setErrorMessage(t('dns.error.duplicate')); + return; + } + + if (!isInputValueValid) { + setErrorMessage(t('dns.error.invalid')); + return; + } + + onListChange([ + ...customDnsList, + { id: inputValueTrimmed, dns: inputValueTrimmed }, + ]); + handleTextInputChange(''); + }; + + const handleTextInputChange = (value: string) => { + const inputValueTrimmed = value.trim(); + + setInputValue(inputValueTrimmed); + setErrorMessage(''); + }; + + const handleDeleteDns = (dns: string) => { + onListChange(customDnsList.filter((d) => d.id !== dns)); + }; + + const handleReorder = (items: DnsItem[]) => { + onListChange(items); + }; + + const handleApply = async () => { + setIsApplyingDns(true); + try { + await onApplyDns(customDnsList.map((item) => item.dns)); + } finally { + setIsApplyingDns(false); + } + }; + + return ( + <> + {customDnsList.length > 0 && ( +
+

+ {t('dns.details.list-header')} ({customDnsList.length}/ + {MAX_DNS_SERVERS}) +

+
+ ( + + )} + /> +
+
+ )} + {customDnsList.length < MAX_DNS_SERVERS && ( +
+
+ + {errorMessage && ( +

{errorMessage}

+ )} +
+
+ +
+
+ )} + + + + ); +} diff --git a/nym-vpn-app/src/screens/settings/custom-dns/DefaultDnsServers.tsx b/nym-vpn-app/src/screens/settings/custom-dns/DefaultDnsServers.tsx new file mode 100644 index 0000000000..6f5e532a0d --- /dev/null +++ b/nym-vpn-app/src/screens/settings/custom-dns/DefaultDnsServers.tsx @@ -0,0 +1,50 @@ +import { useState } from 'react'; +import { AnimatePresence, motion } from 'motion/react'; +import { useTranslation } from 'react-i18next'; +import useCustomDns from '../../../hooks/useCustomDns'; +import { ButtonText } from '../../../ui'; + +export function DefaultDnsServers() { + const { t } = useTranslation('settings'); + const { defaultDns } = useCustomDns(); + const [isDefaultDnsVisible, setIsDefaultDnsVisible] = useState(false); + + const buttonText = isDefaultDnsVisible + ? t('dns.hide-default-dns') + : t('dns.view-default-dns'); + + return ( +
+ setIsDefaultDnsVisible((v) => !v)} + > + {buttonText} + + + + {isDefaultDnsVisible && ( + +
    + {defaultDns.map((dns) => ( +
  • +

    + {dns} +

    +
  • + ))} +
+
+ )} +
+
+ ); +} diff --git a/nym-vpn-app/src/screens/settings/custom-dns/DnsItemContent.tsx b/nym-vpn-app/src/screens/settings/custom-dns/DnsItemContent.tsx new file mode 100644 index 0000000000..81a90d56c3 --- /dev/null +++ b/nym-vpn-app/src/screens/settings/custom-dns/DnsItemContent.tsx @@ -0,0 +1,36 @@ +import { type ReactNode } from 'react'; +import { ButtonIcon, type DraggableListItem } from '../../../ui'; + +export type DnsItem = DraggableListItem & { + dns: string; +}; + +export function DnsItemContent({ + item, + dragHandle, + onDelete, +}: { + item: DnsItem; + dragHandle: ReactNode; + onDelete: (dns: string) => void; +}) { + return ( +
+
+ {dragHandle} +

+ {item.dns} +

+
+ { + onDelete(item.id); + }} + noDefaultSize + className="shrink-0" + /> +
+ ); +} diff --git a/nym-vpn-app/src/screens/settings/custom-dns/index.ts b/nym-vpn-app/src/screens/settings/custom-dns/index.ts new file mode 100644 index 0000000000..412bda5702 --- /dev/null +++ b/nym-vpn-app/src/screens/settings/custom-dns/index.ts @@ -0,0 +1 @@ +export { default as CustomDNS } from './CustomDNS'; diff --git a/nym-vpn-app/src/screens/settings/index.ts b/nym-vpn-app/src/screens/settings/index.ts index 7eebb7e32e..e92a76d4fd 100644 --- a/nym-vpn-app/src/screens/settings/index.ts +++ b/nym-vpn-app/src/screens/settings/index.ts @@ -8,3 +8,4 @@ export * from './dev'; export * from './data-privacy'; export * from './anti-censorship'; export * from './socks5'; +export * from './custom-dns'; diff --git a/nym-vpn-app/src/state/init.ts b/nym-vpn-app/src/state/init.ts index ad9b3b9977..86f24bce9b 100644 --- a/nym-vpn-app/src/state/init.ts +++ b/nym-vpn-app/src/state/init.ts @@ -264,7 +264,18 @@ export async function initSecondBatch( }, }; - let requests: TauriReq[] = [getAutostart]; + const getDefaultDnsRq: TauriReq<() => Promise> = { + name: 'getDefaultDnsRq', + request: () => invoke('get_default_dns'), + onFulfilled: (dns) => { + dispatch({ + type: 'set-default-dns', + dns: dns || [], + }); + }, + }; + + let requests: TauriReq[] = [getAutostart, getDefaultDnsRq]; if (initState.vpnd !== 'down') { requests = [getAccountLinksRq, getNetworkCompatRq, ...requests]; } diff --git a/nym-vpn-app/src/types/app-state.ts b/nym-vpn-app/src/types/app-state.ts index 2da108857e..6c00d721c7 100644 --- a/nym-vpn-app/src/types/app-state.ts +++ b/nym-vpn-app/src/types/app-state.ts @@ -37,6 +37,8 @@ export type InitState = { quic: boolean; noIpv6: boolean; allowLan: boolean; + customDnsEnabled: boolean; + customDns: string[]; }; export type AppState = { @@ -90,4 +92,7 @@ export type AppState = { domainFronting: boolean; // whether the user has seen the streaming optimized label feature alert streamingOptimizedLabelSeen: boolean; + customDnsEnabled: boolean; + customDns: string[]; + defaultDns: string[]; }; diff --git a/nym-vpn-app/src/types/tauri.ts b/nym-vpn-app/src/types/tauri.ts index f9349f9ce1..bf4af8097b 100644 --- a/nym-vpn-app/src/types/tauri.ts +++ b/nym-vpn-app/src/types/tauri.ts @@ -354,6 +354,7 @@ export type VpndConfig = { entryNode: SelectedNode; exitNode: SelectedNode; customDns: Array | null; + enableCustomDns: boolean; allowLan: boolean; disableIpv6: boolean; vpnMode: VpnMode; diff --git a/nym-vpn-app/src/ui/DraggableList.tsx b/nym-vpn-app/src/ui/DraggableList.tsx new file mode 100644 index 0000000000..7f0336e57d --- /dev/null +++ b/nym-vpn-app/src/ui/DraggableList.tsx @@ -0,0 +1,133 @@ +import { ReactNode } from 'react'; +import { + DndContext, + DragEndEvent, + KeyboardSensor, + PointerSensor, + closestCenter, + useSensor, + useSensors, +} from '@dnd-kit/core'; +import { + SortableContext, + arrayMove, + sortableKeyboardCoordinates, + useSortable, + verticalListSortingStrategy, +} from '@dnd-kit/sortable'; +import clsx from 'clsx'; +import { CSS } from '@dnd-kit/utilities'; +import MsIcon from './MsIcon'; + +export type DraggableListItem = { + id: string; +}; + +type SortableItemProps = { + item: T; + renderItem: (item: T, dragHandle: ReactNode) => ReactNode; + dragHandleClassName?: string; +}; + +function SortableItem({ + item, + renderItem, + dragHandleClassName, +}: SortableItemProps) { + const { + attributes, + listeners, + setNodeRef, + transform, + transition, + isDragging, + } = useSortable({ id: item.id }); + + const style = { + transform: CSS.Transform.toString(transform), + transition, + }; + + const dragHandle = ( + + ); + + return ( +
+ {renderItem(item, dragHandle)} +
+ ); +} + +export type DraggableListProps = { + items: T[]; + onReorder: (items: T[]) => void; + renderItem: (item: T, dragHandle: ReactNode) => ReactNode; + dragHandleClassName?: string; +}; + +function DraggableList({ + items, + onReorder, + renderItem, + dragHandleClassName, +}: DraggableListProps) { + const sensors = useSensors( + useSensor(PointerSensor), + useSensor(KeyboardSensor, { + coordinateGetter: sortableKeyboardCoordinates, + }), + ); + + const handleDragEnd = (event: DragEndEvent) => { + const { active, over } = event; + + if (over && active.id !== over.id) { + const oldIndex = items.findIndex((item) => item.id === active.id); + const newIndex = items.findIndex((item) => item.id === over.id); + onReorder(arrayMove(items, oldIndex, newIndex)); + } + }; + + return ( + + item.id)} + strategy={verticalListSortingStrategy} + > +
+ {items.map((item) => ( + + ))} +
+
+
+ ); +} + +export default DraggableList; diff --git a/nym-vpn-app/src/ui/SettingsMenuCard.tsx b/nym-vpn-app/src/ui/SettingsMenuCard.tsx index 8718bc576a..d4c0207e0b 100644 --- a/nym-vpn-app/src/ui/SettingsMenuCard.tsx +++ b/nym-vpn-app/src/ui/SettingsMenuCard.tsx @@ -15,7 +15,7 @@ export type SettingsMenuCardProps = { className?: string; style?: CSSProperties; noHoverEffect?: boolean; - color?: 'normal' | 'red'; + color?: 'normal' | 'red' | 'gray'; }; function SettingsMenuCard({ @@ -47,6 +47,11 @@ function SettingsMenuCard({ color === 'red' && !noHoverEffect && 'hover:bg-aphrodisiac/20 dark:hover:bg-aphrodisiac/20', + // gray color + color === 'gray' && 'bg-white dark:bg-mine-shaft', + color === 'gray' && + !noHoverEffect && + 'hover:bg-white/60 dark:hover:bg-mine-shaft/85', 'flex flex-row justify-between items-center gap-4 select-none', 'px-5 rounded-lg min-h-16', description ? 'py-2' : 'py-4', diff --git a/nym-vpn-app/src/ui/TextInput.tsx b/nym-vpn-app/src/ui/TextInput.tsx index 93ca21bd03..cdea37e47e 100644 --- a/nym-vpn-app/src/ui/TextInput.tsx +++ b/nym-vpn-app/src/ui/TextInput.tsx @@ -21,6 +21,7 @@ export type TextInputProps = { leftIcon?: string; readonly?: boolean; clearable?: boolean; + color?: 'default' | 'gray'; }; function TextInput({ @@ -35,6 +36,7 @@ function TextInput({ autoFocus, className, clearable = false, + color = 'default', }: TextInputProps) { const handleChange = (e: React.ChangeEvent) => { onChange(e.target.value); @@ -44,6 +46,15 @@ function TextInput({ onChange(''); }; + const getColorClass = () => { + switch (color) { + case 'default': + return 'bg-faded-lavender dark:bg-ash'; + case 'gray': + return 'bg-white dark:bg-charcoal'; + } + }; + return ( {label} diff --git a/nym-vpn-app/src/ui/TopBar.tsx b/nym-vpn-app/src/ui/TopBar.tsx index 909da32fb9..078898a3aa 100644 --- a/nym-vpn-app/src/ui/TopBar.tsx +++ b/nym-vpn-app/src/ui/TopBar.tsx @@ -12,7 +12,7 @@ import clsx from 'clsx'; import { type } from '@tauri-apps/plugin-os'; import { motion } from 'motion/react'; import { NymVpnTextLogo } from '../assets'; -import { useDialog, useMainState } from '../contexts'; +import { useDialog, useMainState, useTopBar } from '../contexts'; import { routes } from '../router'; import { Routes } from '../types'; import ButtonIcon from './ButtonIcon'; @@ -37,6 +37,7 @@ export default function TopBar() { const { uiTheme } = useMainState(); const { show } = useDialog(); + const { customLeftNavHandler } = useTopBar(); const [currentNavLocation, setCurrentNavLocation] = useState({ title: '', @@ -121,6 +122,13 @@ export default function TopBar() { navigate(-1); }, }, + '/settings/dns': { + title: t('dns.topbar-title', { ns: 'settings' }), + leftIcon: 'arrow_back', + handleLeftNav: () => { + navigate(-1); + }, + }, '/settings/anti-censorship': { title: t('anti-censorship.title', { ns: 'settings' }), leftIcon: 'arrow_back', @@ -243,6 +251,10 @@ export default function TopBar() { setCurrentNavLocation(navBarData[location.pathname as Routes]); }, [location.pathname, navBarData]); + const defaultLeftNavHandler = () => { + navigate(-1); + }; + const renderTitle = (title?: string | ReactNode) => { if (typeof title === 'string') { return ( @@ -294,7 +306,11 @@ export default function TopBar() { >