diff --git a/nym-connect/Cargo.lock b/nym-connect/Cargo.lock index 7b50a712f9..39072331f0 100644 --- a/nym-connect/Cargo.lock +++ b/nym-connect/Cargo.lock @@ -3385,6 +3385,7 @@ dependencies = [ "nym-socks5-client", "pretty_env_logger", "rand 0.7.3", + "reqwest", "serde", "serde_json", "tauri", diff --git a/nym-connect/src-tauri/Cargo.toml b/nym-connect/src-tauri/Cargo.toml index 92e555b322..e45da2ce06 100644 --- a/nym-connect/src-tauri/Cargo.toml +++ b/nym-connect/src-tauri/Cargo.toml @@ -35,6 +35,7 @@ url = "2.2" log = "0.4" pretty_env_logger = "0.4.0" fix-path-env = { git = "https://github.com/tauri-apps/fix-path-env-rs", branch = "release"} +reqwest = { version = "0.11", features = ["json"] } client-core = { path = "../../clients/client-core" } config = { path = "../../common/config" } diff --git a/nym-connect/src-tauri/src/error.rs b/nym-connect/src-tauri/src/error.rs index c56c1e488e..e14bd47264 100644 --- a/nym-connect/src-tauri/src/error.rs +++ b/nym-connect/src-tauri/src/error.rs @@ -14,6 +14,11 @@ pub enum BackendError { NoServiceProviderSet, #[error("No gateway provider set")] NoGatewaySet, + #[error("{source}")] + ReqwestError { + #[from] + source: reqwest::Error, + }, } impl Serialize for BackendError { diff --git a/nym-connect/src-tauri/src/main.rs b/nym-connect/src-tauri/src/main.rs index 84e862dc00..8f74d60429 100644 --- a/nym-connect/src-tauri/src/main.rs +++ b/nym-connect/src-tauri/src/main.rs @@ -42,6 +42,7 @@ fn main() { crate::operations::connection::connect::start_connecting, crate::operations::connection::disconnect::start_disconnecting, crate::operations::window::hide_window, + crate::operations::directory::get_services, ]) .menu(Menu::new().add_default_app_submenu_if_macos()) .system_tray(create_tray_menu()) diff --git a/nym-connect/src-tauri/src/models/mod.rs b/nym-connect/src-tauri/src/models/mod.rs index c7cda22fec..5ecdbf5c50 100644 --- a/nym-connect/src-tauri/src/models/mod.rs +++ b/nym-connect/src-tauri/src/models/mod.rs @@ -30,3 +30,23 @@ pub const APP_EVENT_CONNECTION_STATUS_CHANGED: &str = "app:connection-status-cha pub struct AppEventConnectionStatusChangedPayload { pub status: ConnectionStatusKind, } + +#[cfg_attr(test, derive(ts_rs::TS))] +#[derive(Clone, serde::Serialize, serde::Deserialize)] +pub struct DirectoryService { + pub id: String, + pub description: String, + pub items: Vec, +} + +#[cfg_attr(test, derive(ts_rs::TS))] +#[derive(Clone, serde::Serialize, serde::Deserialize)] +pub struct DirectoryServiceProvider { + pub id: String, + pub description: String, + /// Address of the network requester in the form "." + /// e.g. DpB3cHAchJiNBQi5FrZx2csXb1mrHkpYh9Wzf8Rjsuko.ANNWrvHqMYuertHGHUrZdBntQhpzfbWekB39qez9U2Vx@2BuMSfMW3zpeAjKXyKLhmY4QW1DXurrtSPEJ6CjX3SEh + pub address: String, + /// Address of the gateway, e.g. 2BuMSfMW3zpeAjKXyKLhmY4QW1DXurrtSPEJ6CjX3SEh + pub gateway: String, +} diff --git a/nym-connect/src-tauri/src/operations/directory/mod.rs b/nym-connect/src-tauri/src/operations/directory/mod.rs new file mode 100644 index 0000000000..1088c37f8b --- /dev/null +++ b/nym-connect/src-tauri/src/operations/directory/mod.rs @@ -0,0 +1,14 @@ +use crate::error::BackendError; +use crate::models::DirectoryService; + +static SERVICE_PROVIDER_WELLKNOWN_URL: &str = + "https://nymtech.net/.wellknown/connect/service-providers.json"; + +#[tauri::command] +pub async fn get_services() -> Result, BackendError> { + let res = reqwest::get(SERVICE_PROVIDER_WELLKNOWN_URL) + .await? + .json::>() + .await?; + Ok(res) +} diff --git a/nym-connect/src-tauri/src/operations/mod.rs b/nym-connect/src-tauri/src/operations/mod.rs index 7f7e1771bf..c4a21fb7c9 100644 --- a/nym-connect/src-tauri/src/operations/mod.rs +++ b/nym-connect/src-tauri/src/operations/mod.rs @@ -1,2 +1,3 @@ pub mod connection; +pub mod directory; pub mod window; diff --git a/nym-connect/src/App.tsx b/nym-connect/src/App.tsx index 043d5f4642..10a2995f34 100644 --- a/nym-connect/src/App.tsx +++ b/nym-connect/src/App.tsx @@ -7,6 +7,7 @@ import { ConnectedLayout } from './layouts/ConnectedLayout'; export const App: React.FC = () => { const context = useClientContext(); const [busy, setBusy] = React.useState(); + const handleConnectClick = React.useCallback(async () => { const oldStatus = context.connectionStatus; if (oldStatus === ConnectionStatusKind.connected || oldStatus === ConnectionStatusKind.disconnected) { @@ -29,7 +30,15 @@ export const App: React.FC = () => { context.connectionStatus === ConnectionStatusKind.disconnected || context.connectionStatus === ConnectionStatusKind.connecting ) { - return ; + return ( + + ); } return ( @@ -40,6 +49,7 @@ export const App: React.FC = () => { ipAddress="127.0.0.1" port={1080} connectedSince={context.connectedSince} + serviceProvider={context.serviceProvider} stats={[ { label: 'in:', diff --git a/nym-connect/src/components/AppWindowFrame.tsx b/nym-connect/src/components/AppWindowFrame.tsx index 93b405ca50..c1fc63626b 100644 --- a/nym-connect/src/components/AppWindowFrame.tsx +++ b/nym-connect/src/components/AppWindowFrame.tsx @@ -1,6 +1,5 @@ import React from 'react'; import { Box } from '@mui/material'; -import { invoke } from '@tauri-apps/api/tauri'; export const AppWindowFrame: React.FC = ({ children }) => ( ( }} > - - + + + + {children} diff --git a/nym-connect/src/components/ConnectionButton.tsx b/nym-connect/src/components/ConnectionButton.tsx index 05552edee8..7fe96c6da8 100644 --- a/nym-connect/src/components/ConnectionButton.tsx +++ b/nym-connect/src/components/ConnectionButton.tsx @@ -3,17 +3,21 @@ import { ConnectionStatusKind } from '../types'; export const ConnectionButton: React.FC<{ status: ConnectionStatusKind; + disabled?: boolean; busy?: boolean; isError?: boolean; onClick?: (status: ConnectionStatusKind) => void; -}> = ({ status, isError, onClick, busy }) => { +}> = ({ status, disabled, isError, onClick, busy }) => { const [hover, setHover] = React.useState(false); const handleClick = React.useCallback(() => { + if (disabled === true) { + return; + } if (onClick) { onClick(status); } - }, [status]); + }, [status, disabled]); const statusText = getStatusText(status, hover); const statusTextColor = isError ? '#40475C' : '#FFF'; @@ -21,16 +25,17 @@ export const ConnectionButton: React.FC<{ return ( setHover(true)} - onMouseLeave={() => setHover(false)} + onMouseEnter={() => !disabled && setHover(true)} + onMouseLeave={() => !disabled && setHover(false)} > - + diff --git a/nym-connect/src/components/ConnectionStatus.tsx b/nym-connect/src/components/ConnectionStatus.tsx index 20e778e0ff..a866f8521d 100644 --- a/nym-connect/src/components/ConnectionStatus.tsx +++ b/nym-connect/src/components/ConnectionStatus.tsx @@ -4,6 +4,7 @@ import CheckCircleOutlineIcon from '@mui/icons-material/CheckCircleOutline'; import CircleOutlinedIcon from '@mui/icons-material/CircleOutlined'; import { DateTime } from 'luxon'; import { ConnectionStatusKind } from '../types'; +import { ServiceProvider } from '../types/directory'; const FONT_SIZE = '16px'; const FONT_WEIGHT = '600'; @@ -57,7 +58,8 @@ const ConnectionStatusContent: React.FC<{ export const ConnectionStatus: React.FC<{ status: ConnectionStatusKind; connectedSince?: DateTime; -}> = ({ status, connectedSince }) => { + serviceProvider?: ServiceProvider; +}> = ({ status, connectedSince, serviceProvider }) => { const color = status === ConnectionStatusKind.connected || status === ConnectionStatusKind.disconnecting ? '#21D072' : '#888'; const [duration, setDuration] = React.useState(); @@ -72,13 +74,16 @@ export const ConnectionStatus: React.FC<{ }; }, [status, connectedSince]); return ( - - - + <> + + + + + + {status === ConnectionStatusKind.connected && duration} + - - {status === ConnectionStatusKind.connected && duration} - - + {serviceProvider && {serviceProvider.description}} + ); }; diff --git a/nym-connect/src/components/ServiceProviderSelector.tsx b/nym-connect/src/components/ServiceProviderSelector.tsx new file mode 100644 index 0000000000..8e46cd5b03 --- /dev/null +++ b/nym-connect/src/components/ServiceProviderSelector.tsx @@ -0,0 +1,110 @@ +import React from 'react'; +import IconButton from '@mui/material/IconButton'; +import Menu from '@mui/material/Menu'; +import MenuItem from '@mui/material/MenuItem'; +import ArrowDropDownCircleIcon from '@mui/icons-material/ArrowDropDownCircle'; +import { Box, CircularProgress, Stack, Tooltip, Typography } from '@mui/material'; +import { ServiceProvider, Services } from '../types/directory'; + +export const ServiceProviderSelector: React.FC<{ + onChange?: (serviceProvider: ServiceProvider) => void; + services?: Services; +}> = ({ services, onChange }) => { + const [serviceProvider, setServiceProvider] = React.useState(); + const textEl = React.useRef(null); + const [anchorEl, setAnchorEl] = React.useState(null); + const open = Boolean(anchorEl); + const handleClick = () => { + setAnchorEl(textEl.current); + }; + const handleClose = (newServiceProvider?: ServiceProvider) => { + if (newServiceProvider) { + setServiceProvider(newServiceProvider); + onChange?.(newServiceProvider); + } + setAnchorEl(null); + }; + + if (!services) { + return ( + + theme.palette.common.white}> + + Loading services... + + + + + + ); + } + + return ( + <> + + (serviceProvider ? undefined : theme.palette.primary.main)} + > + {serviceProvider ? serviceProvider.description : 'Select a service'} + + + + + + handleClose()} + MenuListProps={{ + 'aria-labelledby': 'service-provider-button', + }} + > + {services.map((service) => ( + <> + + {service.description} + + {service.items.map((sp) => ( + handleClose(sp)}> + + + {sp.id} + + + {sp.description} + + + Gateway {sp.gateway.slice(0, 10)}... + + + Provider {sp.address.slice(0, 10)}... + + + } + arrow + placement="top" + > + + {sp.description} + + + + ))} + + ))} + + + ); +}; diff --git a/nym-connect/src/context/main.tsx b/nym-connect/src/context/main.tsx index 33e508733a..066d4400d2 100644 --- a/nym-connect/src/context/main.tsx +++ b/nym-connect/src/context/main.tsx @@ -4,6 +4,7 @@ import { invoke } from '@tauri-apps/api/tauri'; import { listen, UnlistenFn } from '@tauri-apps/api/event'; import { ConnectionStatusKind } from '../types'; import { ConnectionStatsItem } from '../components/ConnectionStats'; +import { ServiceProvider, Services } from '../types/directory'; const TAURI_EVENT_STATUS_CHANGED = 'app:connection-status-changed'; @@ -14,11 +15,14 @@ type TClientContext = { connectionStatus: ConnectionStatusKind; connectionStats?: ConnectionStatsItem[]; connectedSince?: DateTime; + services?: Services; + serviceProvider?: ServiceProvider; setMode: (mode: ModeType) => void; setConnectionStatus: (connectionStatus: ConnectionStatusKind) => void; setConnectionStats: (connectionStats: ConnectionStatsItem[] | undefined) => void; setConnectedSince: (connectedSince: DateTime | undefined) => void; + setServiceProvider: (serviceProvider: ServiceProvider) => void; startConnecting: () => Promise; startDisconnecting: () => Promise; @@ -31,6 +35,14 @@ export const ClientContextProvider = ({ children }: { children: React.ReactNode const [connectionStatus, setConnectionStatus] = useState(ConnectionStatusKind.disconnected); const [connectionStats, setConnectionStats] = useState(); const [connectedSince, setConnectedSince] = useState(); + const [services, setServices] = React.useState(); + const [serviceProvider, setRawServiceProvider] = React.useState(); + + useEffect(() => { + invoke('get_services').then((result) => { + setServices(result as Services); + }); + }, []); useEffect(() => { let unlisten: UnlistenFn | undefined; @@ -59,6 +71,12 @@ export const ClientContextProvider = ({ children }: { children: React.ReactNode await invoke('start_disconnecting'); }, []); + const setServiceProvider = useCallback(async (newServiceProvider: ServiceProvider) => { + await invoke('set_gateway', { gateway: newServiceProvider.gateway }); + await invoke('set_service_provider', { serviceProvider: newServiceProvider.address }); + setRawServiceProvider(newServiceProvider); + }, []); + return ( {children} diff --git a/nym-connect/src/layouts/ConnectedLayout.tsx b/nym-connect/src/layouts/ConnectedLayout.tsx index bf838619a9..a031f18a56 100644 --- a/nym-connect/src/layouts/ConnectedLayout.tsx +++ b/nym-connect/src/layouts/ConnectedLayout.tsx @@ -8,6 +8,7 @@ import { ConnectionStats, ConnectionStatsItem } from '../components/ConnectionSt import { NeedHelp } from '../components/NeedHelp'; import { ConnectionButton } from '../components/ConnectionButton'; import { IpAddressAndPort } from '../components/IpAddressAndPort'; +import { ServiceProvider } from '../types/directory'; export const ConnectedLayout: React.FC<{ status: ConnectionStatusKind; @@ -18,15 +19,16 @@ export const ConnectedLayout: React.FC<{ busy?: boolean; isError?: boolean; onConnectClick?: (status: ConnectionStatusKind) => void; -}> = ({ status, stats, ipAddress, port, connectedSince, busy, isError, onConnectClick }) => ( + serviceProvider?: ServiceProvider; +}> = ({ status, stats, ipAddress, port, connectedSince, busy, isError, serviceProvider, onConnectClick }) => ( - + - + {/* */} diff --git a/nym-connect/src/layouts/DefaultLayout.tsx b/nym-connect/src/layouts/DefaultLayout.tsx index 1e38154bd3..42b6357442 100644 --- a/nym-connect/src/layouts/DefaultLayout.tsx +++ b/nym-connect/src/layouts/DefaultLayout.tsx @@ -4,21 +4,39 @@ import { AppWindowFrame } from '../components/AppWindowFrame'; import { ConnectionButton } from '../components/ConnectionButton'; import { ConnectionStatusKind } from '../types'; import { NeedHelp } from '../components/NeedHelp'; +import { ServiceProviderSelector } from '../components/ServiceProviderSelector'; +import { ServiceProvider, Services } from '../types/directory'; export const DefaultLayout: React.FC<{ status: ConnectionStatusKind; + services?: Services; busy?: boolean; isError?: boolean; onConnectClick?: (status: ConnectionStatusKind) => void; -}> = ({ status, busy, isError, onConnectClick }) => ( - - - Connect, your privacy will be 100% protected thanks to the Nym Mixnet - - - You are not protected now - - - - -); + onServiceProviderChange?: (serviceProvider: ServiceProvider) => void; +}> = ({ status, services, busy, isError, onConnectClick, onServiceProviderChange }) => { + const [serviceProvider, setServiceProvider] = React.useState(); + const handleServiceProviderChange = (newServiceProvider: ServiceProvider) => { + setServiceProvider(newServiceProvider); + onServiceProviderChange?.(newServiceProvider); + }; + return ( + + + Connect, your privacy will be 100% protected thanks to the Nym Mixnet + + + You are not protected now + + + + + + ); +}; diff --git a/nym-connect/src/stories/AppFlow.stories.tsx b/nym-connect/src/stories/AppFlow.stories.tsx index 72ca502466..25c8ba8b8e 100644 --- a/nym-connect/src/stories/AppFlow.stories.tsx +++ b/nym-connect/src/stories/AppFlow.stories.tsx @@ -7,6 +7,7 @@ import { useClientContext } from '../context/main'; import { ConnectionStatusKind } from '../types'; import { DefaultLayout } from '../layouts/DefaultLayout'; import { ConnectedLayout } from '../layouts/ConnectedLayout'; +import { Services } from '../types/directory'; export default { title: 'App/Flow', @@ -16,6 +17,19 @@ export default { export const Mock: ComponentStory = () => { const context = useClientContext(); const [busy, setBusy] = React.useState(); + const services: Services = [ + { + id: 'keybase', + description: 'Keybase', + items: [ + { + id: 'nym-keybase', + description: 'Nym Keybase Service Provider', + address: '1234.5678', + }, + ], + }, + ]; const handleConnectClick = React.useCallback(() => { const oldStatus = context.connectionStatus; if (oldStatus === ConnectionStatusKind.connected || oldStatus === ConnectionStatusKind.disconnected) { @@ -53,7 +67,12 @@ export const Mock: ComponentStory = () => { ) { return ( - + ); } @@ -67,6 +86,7 @@ export const Mock: ComponentStory = () => { ipAddress="127.0.0.1" port={1080} connectedSince={context.connectedSince} + serviceProvider={services[0].items[0]} stats={[ { label: 'in:', diff --git a/nym-connect/src/types/directory.ts b/nym-connect/src/types/directory.ts new file mode 100644 index 0000000000..55395192a1 --- /dev/null +++ b/nym-connect/src/types/directory.ts @@ -0,0 +1,14 @@ +export interface ServiceProvider { + id: string; + description: string; + address: string; + gateway: string; +} + +export interface Service { + id: string; + description: string; + items: ServiceProvider[]; +} + +export type Services = Service[];