feat(vpn-desktop): add exit node location (#4234)
* add entry node selection * update quick prefix to fastest * add entry node selection * update quick prefix to fastest * add country selection * add country selection * remove config properties * disable entry location for now * refactor: rename entry location selector command * remove un-related comment * refactor: renamed entry location selector * use translation key for copy * fix on connect error handling * use a default entry location for now * refactor(backend): move country list into module * refactor(backend): init app state based on restored app data * fix(backend): connect command * add comments * fix country select bug * add entry_node_location to app config file * add proper default location handling * clean code --------- Co-authored-by: pierre <dommerc.pierre@gmail.com>
This commit is contained in:
@@ -40,9 +40,6 @@ For example on Linux the path would be `~/.config/nym-vpn/config.toml`
|
||||
# path to the env config file if you provide one
|
||||
env_config_file = "$HOME/.config/nym-vpn/qa.env"
|
||||
|
||||
# pick a gateway and exit router accordingly to the selected env
|
||||
entry_gateway = "xxx"
|
||||
exit_router = "xxx"
|
||||
```
|
||||
|
||||
## Dev
|
||||
|
||||
Generated
+1
@@ -5267,6 +5267,7 @@ dependencies = [
|
||||
"nym-task",
|
||||
"nym-validator-client",
|
||||
"nym-wireguard-types",
|
||||
"rand 0.7.3",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"signature 1.4.0",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
use tauri::State;
|
||||
use tracing::{debug, instrument};
|
||||
|
||||
use crate::country::COUNTRIES;
|
||||
use crate::states::app::Country;
|
||||
use crate::{
|
||||
error::{CmdError, CmdErrorSource},
|
||||
@@ -12,29 +13,8 @@ use crate::{
|
||||
#[tauri::command]
|
||||
pub fn get_node_countries() -> Result<Vec<Country>, CmdError> {
|
||||
debug!("get_node_countries");
|
||||
let countries: Vec<Country> = vec![
|
||||
Country {
|
||||
name: "United States".to_string(),
|
||||
code: "US".to_string(),
|
||||
},
|
||||
Country {
|
||||
name: "France".to_string(),
|
||||
code: "FR".to_string(),
|
||||
},
|
||||
Country {
|
||||
name: "Switzerland".to_string(),
|
||||
code: "CH".to_string(),
|
||||
},
|
||||
Country {
|
||||
name: "Sweden".to_string(),
|
||||
code: "SE".to_string(),
|
||||
},
|
||||
Country {
|
||||
name: "Germany".to_string(),
|
||||
code: "DE".to_string(),
|
||||
},
|
||||
];
|
||||
Ok(countries)
|
||||
// TODO fetch the list of countries from some API
|
||||
Ok(COUNTRIES.clone())
|
||||
}
|
||||
|
||||
#[instrument(skip(state))]
|
||||
@@ -97,3 +77,25 @@ pub async fn set_ui_theme(
|
||||
.map_err(|e| CmdError::new(CmdErrorSource::InternalError, e.to_string()))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[instrument(skip(data_state))]
|
||||
#[tauri::command]
|
||||
pub async fn set_entry_location_selector(
|
||||
data_state: State<'_, SharedAppData>,
|
||||
entry_selector: bool,
|
||||
) -> Result<(), CmdError> {
|
||||
debug!("set_entry_location_selector");
|
||||
|
||||
let mut app_data_store = data_state.lock().await;
|
||||
let mut app_data = app_data_store
|
||||
.read()
|
||||
.await
|
||||
.map_err(|e| CmdError::new(CmdErrorSource::InternalError, e.to_string()))?;
|
||||
app_data.entry_location_selector = Some(entry_selector);
|
||||
app_data_store.data = app_data;
|
||||
app_data_store
|
||||
.write()
|
||||
.await
|
||||
.map_err(|e| CmdError::new(CmdErrorSource::InternalError, e.to_string()))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
use futures::SinkExt;
|
||||
use nym_vpn_lib::gateway_client::{EntryPoint, ExitPoint};
|
||||
use nym_vpn_lib::{NymVpnCtrlMessage, NymVpnHandle};
|
||||
use tauri::{Manager, State};
|
||||
use tracing::{debug, error, info, instrument, trace};
|
||||
@@ -16,6 +17,8 @@ use crate::{
|
||||
},
|
||||
};
|
||||
|
||||
const DEFAULT_NODE_LOCATION: &str = "DE";
|
||||
|
||||
#[instrument(skip_all)]
|
||||
#[tauri::command]
|
||||
pub async fn get_connection_state(
|
||||
@@ -67,23 +70,48 @@ pub async fn connect(
|
||||
)
|
||||
.ok();
|
||||
|
||||
let app_config = config_store.lock().await.read().await.map_err(|e| {
|
||||
CmdError::new(
|
||||
CmdErrorSource::InternalError,
|
||||
format!("failed to read app config: {}", e),
|
||||
)
|
||||
})?;
|
||||
let mut vpn_config = create_vpn_config(&app_config);
|
||||
{
|
||||
let app_state = state.lock().await;
|
||||
if let VpnMode::TwoHop = app_state.vpn_mode {
|
||||
info!("2-hop mode enabled");
|
||||
vpn_config.enable_two_hop = true;
|
||||
} else {
|
||||
info!("5-hop mode enabled");
|
||||
let app_state = state.lock().await;
|
||||
|
||||
// get entry node location from app config file
|
||||
let app_config = config_store
|
||||
.lock()
|
||||
.await
|
||||
.read()
|
||||
.await
|
||||
.map_err(|e| CmdError::new(CmdErrorSource::InternalError, e.to_string()))?;
|
||||
let entry_location = app_config
|
||||
.entry_node_location
|
||||
.clone()
|
||||
.unwrap_or_else(|| DEFAULT_NODE_LOCATION.to_string());
|
||||
// !! release config_store mutex
|
||||
drop(app_config);
|
||||
|
||||
debug!("using entry node location: {}", entry_location);
|
||||
let entry_point = EntryPoint::Location(entry_location);
|
||||
let exit_point = match app_state.exit_node_location {
|
||||
Some(ref exit_node_location) => {
|
||||
debug!("exit node location set, using: {}", exit_node_location.code);
|
||||
ExitPoint::Location(exit_node_location.code.clone())
|
||||
}
|
||||
_ => {
|
||||
debug!(
|
||||
"exit node location not set, using default: {}",
|
||||
DEFAULT_NODE_LOCATION
|
||||
);
|
||||
ExitPoint::Location(DEFAULT_NODE_LOCATION.into())
|
||||
}
|
||||
};
|
||||
|
||||
let mut vpn_config = create_vpn_config(entry_point, exit_point);
|
||||
if let VpnMode::TwoHop = app_state.vpn_mode {
|
||||
info!("2-hop mode enabled");
|
||||
vpn_config.enable_two_hop = true;
|
||||
} else {
|
||||
info!("5-hop mode enabled");
|
||||
}
|
||||
// vpn_config.disable_routing = true;
|
||||
// !! release app_state mutex
|
||||
drop(app_state);
|
||||
|
||||
// spawn the VPN client and start a new connection
|
||||
let NymVpnHandle {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
use once_cell::sync::Lazy;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tauri::State;
|
||||
use tracing::{debug, instrument};
|
||||
@@ -14,6 +15,11 @@ pub enum NodeType {
|
||||
Exit,
|
||||
}
|
||||
|
||||
static DEFAULT_NODE_LOCATION: Lazy<Country> = Lazy::new(|| Country {
|
||||
code: "DE".to_string(),
|
||||
name: "Germany".to_string(),
|
||||
});
|
||||
|
||||
#[instrument(skip(app_state, data_state))]
|
||||
#[tauri::command]
|
||||
pub async fn set_node_location(
|
||||
@@ -57,3 +63,10 @@ pub async fn set_node_location(
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[instrument]
|
||||
#[tauri::command]
|
||||
pub async fn get_default_node_location() -> Result<Country, CmdError> {
|
||||
debug!("get_default_node_location");
|
||||
Ok(DEFAULT_NODE_LOCATION.clone())
|
||||
}
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
use once_cell::sync::Lazy;
|
||||
|
||||
use crate::states::app::Country;
|
||||
|
||||
// TODO use hardcoded country list for now
|
||||
pub static COUNTRIES: Lazy<Vec<Country>> = Lazy::new(|| {
|
||||
vec![
|
||||
Country {
|
||||
name: "Ireland".to_string(),
|
||||
code: "IE".to_string(),
|
||||
},
|
||||
Country {
|
||||
name: "Germany".to_string(),
|
||||
code: "DE".to_string(),
|
||||
},
|
||||
Country {
|
||||
name: "Japan".to_string(),
|
||||
code: "JP".to_string(),
|
||||
},
|
||||
Country {
|
||||
name: "Great Britain".to_string(),
|
||||
code: "GB".to_string(),
|
||||
},
|
||||
]
|
||||
});
|
||||
@@ -6,8 +6,6 @@ use serde::{Deserialize, Serialize};
|
||||
pub struct AppConfig {
|
||||
/// Path pointing to an env configuration file describing the network
|
||||
pub env_config_file: Option<PathBuf>,
|
||||
/// Mixnet public ID of the entry gateway
|
||||
pub entry_gateway: String,
|
||||
/// Mixnet recipient address
|
||||
pub exit_router: String,
|
||||
/// Country code (two letters format, eg. FR)
|
||||
pub entry_node_location: Option<String>,
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ pub struct AppData {
|
||||
pub monitoring: Option<bool>,
|
||||
pub autoconnect: Option<bool>,
|
||||
pub killswitch: Option<bool>,
|
||||
pub entry_location_selector: Option<bool>,
|
||||
pub ui_theme: Option<UiTheme>,
|
||||
pub vpn_mode: Option<VpnMode>,
|
||||
pub entry_node: Option<NodeConfig>,
|
||||
|
||||
@@ -16,6 +16,7 @@ use nym_vpn_lib::nym_config;
|
||||
use crate::fs::{config::AppConfig, data::AppData, storage::AppStorage};
|
||||
|
||||
mod commands;
|
||||
mod country;
|
||||
mod error;
|
||||
mod fs;
|
||||
mod states;
|
||||
@@ -63,6 +64,8 @@ async fn main() -> Result<()> {
|
||||
&app_config_store.full_path.display()
|
||||
);
|
||||
|
||||
let app_data = app_data_store.read().await?;
|
||||
debug!("app_data: {app_data:?}");
|
||||
let app_config = app_config_store.read().await?;
|
||||
debug!("app_config: {app_config:?}");
|
||||
|
||||
@@ -88,7 +91,7 @@ async fn main() -> Result<()> {
|
||||
info!("Starting tauri app");
|
||||
|
||||
tauri::Builder::default()
|
||||
.manage(Arc::new(Mutex::new(AppState::default())))
|
||||
.manage(Arc::new(Mutex::new(AppState::from(&app_data))))
|
||||
.manage(Arc::new(Mutex::new(app_data_store)))
|
||||
.manage(Arc::new(Mutex::new(app_config_store)))
|
||||
.setup(|_app| {
|
||||
@@ -104,8 +107,10 @@ async fn main() -> Result<()> {
|
||||
app_data::get_app_data,
|
||||
app_data::set_app_data,
|
||||
app_data::set_ui_theme,
|
||||
app_data::set_entry_location_selector,
|
||||
app_data::get_node_countries,
|
||||
node_location::set_node_location,
|
||||
node_location::get_default_node_location,
|
||||
])
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running tauri application");
|
||||
|
||||
@@ -4,6 +4,8 @@ use serde::{Deserialize, Serialize};
|
||||
use time::OffsetDateTime;
|
||||
use ts_rs::TS;
|
||||
|
||||
use crate::fs::data::AppData;
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone, TS)]
|
||||
#[ts(export)]
|
||||
pub struct NodeConfig {
|
||||
@@ -51,6 +53,17 @@ pub struct AppState {
|
||||
pub vpn_ctrl_tx: Option<UnboundedSender<NymVpnCtrlMessage>>,
|
||||
}
|
||||
|
||||
impl From<&AppData> for AppState {
|
||||
fn from(app_data: &AppData) -> Self {
|
||||
AppState {
|
||||
entry_node_location: app_data.entry_node_location.clone(),
|
||||
exit_node_location: app_data.exit_node_location.clone(),
|
||||
vpn_mode: app_data.vpn_mode.clone().unwrap_or_default(),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default, Serialize, Deserialize, Debug, Clone, TS)]
|
||||
#[ts(export)]
|
||||
pub struct Country {
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
use crate::fs::config::AppConfig;
|
||||
use crate::states::{app::ConnectionState, SharedAppState};
|
||||
use anyhow::Result;
|
||||
use futures::channel::oneshot::Receiver as OneshotReceiver;
|
||||
use futures::StreamExt;
|
||||
use nym_vpn_lib::gateway_client::Config as GatewayClientConfig;
|
||||
use nym_vpn_lib::gateway_client::{Config as GatewayClientConfig, EntryPoint, ExitPoint};
|
||||
use nym_vpn_lib::nym_config::OptionalSet;
|
||||
use nym_vpn_lib::{NymVpn, NymVpnExitError, NymVpnExitStatusMessage, StatusReceiver};
|
||||
use tauri::Manager;
|
||||
@@ -166,8 +165,8 @@ fn setup_gateway_client_config(private_key: Option<&str>) -> GatewayClientConfig
|
||||
}
|
||||
|
||||
#[instrument(skip_all)]
|
||||
pub fn create_vpn_config(app_config: &AppConfig) -> NymVpn {
|
||||
let mut nym_vpn = NymVpn::new(&app_config.entry_gateway, &app_config.exit_router);
|
||||
pub fn create_vpn_config(entry_point: EntryPoint, exit_point: ExitPoint) -> NymVpn {
|
||||
let mut nym_vpn = NymVpn::new(entry_point, exit_point);
|
||||
nym_vpn.gateway_config = setup_gateway_client_config(None);
|
||||
nym_vpn
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
"withGlobalTauri": false
|
||||
},
|
||||
"package": {
|
||||
"productName": "nym-vpn-ui",
|
||||
"productName": "nym-vpn",
|
||||
"version": "0.0.0"
|
||||
},
|
||||
"tauri": {
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import { Country } from './types';
|
||||
|
||||
export const routes = {
|
||||
root: '/',
|
||||
settings: '/settings',
|
||||
@@ -10,5 +8,4 @@ export const routes = {
|
||||
export const AppName = 'NymVPN';
|
||||
export const ConnectionEvent = 'connection-state';
|
||||
export const ProgressEvent = 'connection-progress';
|
||||
//putting this here for now until decided how default country is determined
|
||||
export const QuickConnectCountry: Country = { name: 'Germany', code: 'DE' };
|
||||
export const QuickConnectPrefix = 'Fastest';
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { mockIPC, mockWindows } from '@tauri-apps/api/mocks';
|
||||
import { emit } from '@tauri-apps/api/event';
|
||||
import { AppDataFromBackend, ConnectionState, Country } from '../types';
|
||||
import { ConnectionEvent, QuickConnectCountry } from '../constants';
|
||||
import { ConnectionEvent } from '../constants';
|
||||
|
||||
export function mockTauriIPC() {
|
||||
mockWindows('main');
|
||||
@@ -56,21 +56,37 @@ export function mockTauriIPC() {
|
||||
);
|
||||
}
|
||||
|
||||
if (cmd === 'get_default_node_location') {
|
||||
return new Promise<Country>((resolve) =>
|
||||
resolve({
|
||||
name: 'France',
|
||||
code: 'FR',
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
if (cmd === 'get_app_data') {
|
||||
return new Promise<AppDataFromBackend>((resolve) =>
|
||||
resolve({
|
||||
monitoring: false,
|
||||
autoconnect: false,
|
||||
killswitch: false,
|
||||
entry_location_selector: false,
|
||||
ui_theme: 'Dark',
|
||||
vpn_mode: 'TwoHop',
|
||||
entry_node: {
|
||||
country: QuickConnectCountry,
|
||||
id: QuickConnectCountry.code,
|
||||
country: {
|
||||
name: 'France',
|
||||
code: 'FR',
|
||||
},
|
||||
id: 'nodeOne',
|
||||
},
|
||||
exit_node: {
|
||||
country: QuickConnectCountry,
|
||||
id: QuickConnectCountry.code,
|
||||
country: {
|
||||
name: 'France',
|
||||
code: 'FR',
|
||||
},
|
||||
id: 'nodeTwo',
|
||||
},
|
||||
entry_node_location: null,
|
||||
exit_node_location: null,
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
{
|
||||
"loading": "Loading..",
|
||||
"none-found": "No results found!",
|
||||
"none-found": "No results found. Please try another search",
|
||||
"selected": "Selected",
|
||||
"search-country": "Search country",
|
||||
"quick-connect": "Quick connect"
|
||||
"search-country": "Search country"
|
||||
}
|
||||
|
||||
@@ -1 +1,3 @@
|
||||
{}
|
||||
{
|
||||
"entry-selector": "Entry location selector"
|
||||
}
|
||||
|
||||
@@ -66,8 +66,8 @@ function ConnectionStatus() {
|
||||
{getStatusText(state.state)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-1">
|
||||
{state.loading && state.progressMessages.length > 0 && (
|
||||
<div className="flex-col flex-1 text-center">
|
||||
{state.loading && state.progressMessages.length > 0 && !state.error && (
|
||||
<p className="text-dim-gray dark:text-mercury-mist font-bold">
|
||||
{t(
|
||||
`connection-progress.${
|
||||
|
||||
@@ -5,14 +5,15 @@ import clsx from 'clsx';
|
||||
import { Button } from '@mui/base';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useMainDispatch, useMainState } from '../../contexts';
|
||||
import { StateDispatch } from '../../types';
|
||||
import { QuickConnectCountry, routes } from '../../constants';
|
||||
import { CmdError, StateDispatch } from '../../types';
|
||||
import { routes } from '../../constants';
|
||||
import NetworkModeSelect from './NetworkModeSelect';
|
||||
import ConnectionStatus from './ConnectionStatus';
|
||||
import HopSelect from './HopSelect';
|
||||
|
||||
function Home() {
|
||||
const { state, loading, exitNodeLocation } = useMainState();
|
||||
const { state, loading, exitNodeLocation, defaultNodeLocation } =
|
||||
useMainState();
|
||||
const dispatch = useMainDispatch() as StateDispatch;
|
||||
const navigate = useNavigate();
|
||||
const { t } = useTranslation('home');
|
||||
@@ -25,9 +26,9 @@ function Home() {
|
||||
console.log('disconnect result');
|
||||
console.log(result);
|
||||
})
|
||||
.catch((e) => {
|
||||
console.log(e);
|
||||
dispatch({ type: 'set-error', error: e });
|
||||
.catch((e: CmdError) => {
|
||||
console.warn(`backend error: ${e.source} - ${e.message}`);
|
||||
dispatch({ type: 'set-error', error: e.message });
|
||||
});
|
||||
} else if (state === 'Disconnected') {
|
||||
dispatch({ type: 'connect' });
|
||||
@@ -36,9 +37,9 @@ function Home() {
|
||||
console.log('connect result');
|
||||
console.log(result);
|
||||
})
|
||||
.catch((e) => {
|
||||
console.log(e);
|
||||
dispatch({ type: 'set-error', error: e });
|
||||
.catch((e: CmdError) => {
|
||||
console.warn(`backend error: ${e.source} - ${e.message}`);
|
||||
dispatch({ type: 'set-error', error: e.message });
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -73,11 +74,20 @@ function Home() {
|
||||
<div className="flex flex-col justify-between">
|
||||
<NetworkModeSelect />
|
||||
<div className="py-2"></div>
|
||||
<HopSelect
|
||||
country={exitNodeLocation ?? QuickConnectCountry}
|
||||
onClick={() => navigate(routes.exitNodeLocation)}
|
||||
nodeHop="exit"
|
||||
/>
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="mt-3 font-semibold text-lg">
|
||||
{t('select-node-title')}
|
||||
</div>
|
||||
<HopSelect
|
||||
country={exitNodeLocation || defaultNodeLocation}
|
||||
onClick={() => {
|
||||
if (state === 'Disconnected') {
|
||||
navigate(routes.exitNodeLocation);
|
||||
}
|
||||
}}
|
||||
nodeHop="exit"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
className={clsx([
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Country, NodeHop } from '../../types';
|
||||
import { QuickConnectPrefix } from '../../constants';
|
||||
|
||||
interface HopSelectProps {
|
||||
country: Country;
|
||||
@@ -15,7 +16,6 @@ export default function HopSelect({
|
||||
const { t } = useTranslation('home');
|
||||
return (
|
||||
<>
|
||||
<div className="my-3 font-semibold text-lg">Connect to</div>
|
||||
<div
|
||||
className="relative w-full flex flex-row justify-center cursor-pointer"
|
||||
onKeyDown={onClick}
|
||||
@@ -29,11 +29,18 @@ export default function HopSelect({
|
||||
value={country.name}
|
||||
className="dark:bg-baltic-sea cursor-pointer pl-11 dark:placeholder-white border border-gun-powder block px-2.5 pb-4 pt-4 w-full text-sm text-gray-900 bg-transparent rounded-lg border-1 border-gray-300 appearance-none dark:text-white dark:border-gray-600 dark:focus:border-blue-500 focus:outline-none focus:ring-0 focus:border-blue-600 peer"
|
||||
/>
|
||||
<img
|
||||
src={`./flags/${country.code.toLowerCase()}.svg`}
|
||||
className="h-8 scale-75 pointer-events-none absolute fill-current top-1/2 transform -translate-y-1/2 left-2"
|
||||
alt={country.code}
|
||||
/>
|
||||
<div className="top-1/2 transform -translate-y-1/2 left-2 absolute pointer-events-none">
|
||||
{country.name.includes(QuickConnectPrefix) ? (
|
||||
<span className="font-icon cursor-pointer px-2">bolt</span>
|
||||
) : (
|
||||
<img
|
||||
src={`./flags/${country.code.toLowerCase()}.svg`}
|
||||
className="h-8 scale-75 pointer-events-none fill-current"
|
||||
alt={country.code}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<span className="font-icon scale-125 pointer-events-none absolute fill-current top-1/4 transform -translate-x-1/2 right-2">
|
||||
arrow_right
|
||||
</span>
|
||||
|
||||
@@ -12,7 +12,7 @@ export default function CountryList({
|
||||
}: CountryListProps) {
|
||||
const { t } = useTranslation('nodeLocation');
|
||||
return (
|
||||
<ul className="flex flex-col w-full items-stretch p-1">
|
||||
<ul className="flex flex-col w-full items-stretch px-6 gap-4">
|
||||
{countries && countries.length > 0 ? (
|
||||
countries.map((country) => (
|
||||
<li key={country.code} className="list-none w-full">
|
||||
@@ -39,7 +39,7 @@ export default function CountryList({
|
||||
</li>
|
||||
))
|
||||
) : (
|
||||
<p>{t('none-found')}</p>
|
||||
<p className="flex justify-center">{t('none-found')}</p>
|
||||
)}
|
||||
</ul>
|
||||
);
|
||||
|
||||
@@ -11,28 +11,22 @@ import QuickConnect from './QuickConnect';
|
||||
|
||||
function NodeLocation({ node }: { node: NodeHop }) {
|
||||
const { t } = useTranslation('nodeLocation');
|
||||
const [countries, setCountries] = useState<Country[]>([]);
|
||||
const { entryNodeLocation, exitNodeLocation, countries } = useMainState();
|
||||
const [search, setSearch] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [foundCountries, setFoundCountries] = useState<Country[]>([]);
|
||||
const [foundCountries, setFoundCountries] = useState<Country[]>(countries);
|
||||
|
||||
const { entryNodeLocation, exitNodeLocation } = useMainState();
|
||||
const dispatch = useMainDispatch() as StateDispatch;
|
||||
|
||||
const navigate = useNavigate();
|
||||
|
||||
//request backend to update countries cache
|
||||
useEffect(() => {
|
||||
setLoading(true);
|
||||
const getNodeCountries = async () => {
|
||||
const countries = await invoke<Country[]>('get_node_countries');
|
||||
setTimeout(() => {
|
||||
setCountries(countries);
|
||||
setFoundCountries(countries);
|
||||
setLoading(false);
|
||||
}, 1000);
|
||||
dispatch({ type: 'set-countries', countries });
|
||||
};
|
||||
getNodeCountries().catch(console.error);
|
||||
}, []);
|
||||
}, [dispatch]);
|
||||
|
||||
const filter = (e: InputEvent) => {
|
||||
const keyword = e.target.value;
|
||||
@@ -82,15 +76,11 @@ function NodeLocation({ node }: { node: NodeHop }) {
|
||||
placeholder={t('search-country')}
|
||||
/>
|
||||
<span className="mt-3" />
|
||||
{!loading ? (
|
||||
<CountryList
|
||||
countries={foundCountries}
|
||||
onClick={handleCountrySelection}
|
||||
isSelected={isCountrySelected}
|
||||
/>
|
||||
) : (
|
||||
<div>{t('loading')}</div>
|
||||
)}
|
||||
<CountryList
|
||||
countries={foundCountries}
|
||||
onClick={handleCountrySelection}
|
||||
isSelected={isCountrySelected}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,25 +1,26 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { QuickConnectCountry } from '../../constants';
|
||||
import { useMainState } from '../../contexts';
|
||||
import { QuickConnectPrefix } from '../../constants';
|
||||
|
||||
interface QuickConnectProps {
|
||||
onClick: (name: string, code: string) => void;
|
||||
}
|
||||
|
||||
export default function QuickConnect({ onClick }: QuickConnectProps) {
|
||||
const { t } = useTranslation('nodeLocation');
|
||||
const { defaultNodeLocation } = useMainState();
|
||||
|
||||
return (
|
||||
<div
|
||||
role="presentation"
|
||||
onKeyDown={() =>
|
||||
onClick(QuickConnectCountry.name, QuickConnectCountry.code)
|
||||
onClick(defaultNodeLocation.name, defaultNodeLocation.code)
|
||||
}
|
||||
className="flex flex-row w-full py-8 cursor-pointer"
|
||||
className="flex px-5 flex-row w-full py-8 cursor-pointer"
|
||||
onClick={() =>
|
||||
onClick(QuickConnectCountry.name, QuickConnectCountry.code)
|
||||
onClick(defaultNodeLocation.name, defaultNodeLocation.code)
|
||||
}
|
||||
>
|
||||
<span className="font-icon px-4 cursor-pointer">bolt</span>
|
||||
<div className="cursor-pointer">{`${t('quick-connect')} (${
|
||||
QuickConnectCountry.name
|
||||
})`}</div>
|
||||
<div className="cursor-pointer">{`${QuickConnectPrefix} ${defaultNodeLocation.name}`}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -12,21 +12,21 @@ export default function SearchBox({
|
||||
placeholder,
|
||||
}: SearchProps) {
|
||||
return (
|
||||
<div className="relative w-full flex flex-row justify-center px-1.5">
|
||||
<div className="relative w-full flex flex-row justify-center px-6">
|
||||
<input
|
||||
type="text"
|
||||
id="floating_outlined"
|
||||
value={value}
|
||||
className="dark:bg-baltic-sea pl-9 dark:placeholder-white border border-gun-powder block px-2.5 pb-4 pt-4 w-full text-sm text-gray-900 bg-transparent rounded-lg border-1 border-gray-300 appearance-none dark:text-white dark:border-gray-600 dark:focus:border-blue-500 focus:outline-none focus:ring-0 focus:border-blue-600 peer"
|
||||
className="dark:bg-baltic-sea pl-9 dark:placeholder-white border border-gun-powder block px-2.5 pb-4 pt-4 w-full text-sm text-gray-900 bg-transparent rounded-lg border-1 border-gray-300 appearance-none dark:text-white dark:border-gray-600 focus:outline-none focus:ring-0 peer"
|
||||
placeholder={placeholder}
|
||||
onChange={onChange}
|
||||
/>
|
||||
<span className="font-icon scale-125 pointer-events-none absolute fill-current top-1/2 transform -translate-y-1/2 left-4">
|
||||
<span className="font-icon scale-125 pointer-events-none absolute fill-current top-1/2 transform -translate-y-1/2 left-9">
|
||||
search
|
||||
</span>
|
||||
<label
|
||||
htmlFor="floating_outlined"
|
||||
className="dark:text-white dark:bg-baltic-sea absolute text-sm text-gray-500 dark:text-gray-400 ml-4 duration-300 transform -translate-y-4 scale-75 top-2 z-10 origin-[0] bg-blanc-nacre dark:bg-gray-900 px-2 peer-placeholder-shown:px-2 peer-placeholder-shown:text-blue-600 peer-placeholder-shown:dark:text-blue-500 peer-placeholder-shown:top-2 peer-placeholder-shown:scale-75 peer-placeholder-shown:-translate-y-4 rtl:peer-placeholder-shown:translate-x-1/4 rtl:peer-placeholder-shown:left-auto start-1"
|
||||
className="dark:text-white dark:bg-baltic-sea absolute text-sm text-gray-500 dark:text-gray-400 ml-8 duration-300 transform -translate-y-4 scale-75 top-2 z-10 origin-[0] bg-blanc-nacre dark:bg-gray-900 px-2 peer-placeholder-shown:px-2 peer-placeholder-shown:top-2 peer-placeholder-shown:scale-75 peer-placeholder-shown:-translate-y-4 rtl:peer-placeholder-shown:translate-x-1/4 rtl:peer-placeholder-shown:left-auto start-1"
|
||||
>
|
||||
Search
|
||||
</label>
|
||||
|
||||
@@ -9,11 +9,13 @@ function Settings() {
|
||||
const state = useMainState();
|
||||
const dispatch = useMainDispatch() as StateDispatch;
|
||||
|
||||
const [enabled, setEnabled] = useState(state.uiTheme === 'Dark');
|
||||
const [darkModeEnabled, setDarkModeEnabled] = useState(
|
||||
state.uiTheme === 'Dark',
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setEnabled(state.uiTheme === 'Dark');
|
||||
}, [state.uiTheme]);
|
||||
setDarkModeEnabled(state.uiTheme === 'Dark');
|
||||
}, [state]);
|
||||
|
||||
const handleThemeChange = async (darkMode: boolean) => {
|
||||
if (darkMode && state.uiTheme === 'Light') {
|
||||
@@ -29,23 +31,25 @@ function Settings() {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col p-4">
|
||||
<div className="h-full flex flex-col p-4 gap-4">
|
||||
<div className="flex flex-row justify-between items-center">
|
||||
<p className="text-lg text-baltic-sea dark:text-mercury-pinkish">
|
||||
Dark Mode
|
||||
</p>
|
||||
<Switch
|
||||
checked={enabled}
|
||||
checked={darkModeEnabled}
|
||||
onChange={handleThemeChange}
|
||||
className={clsx([
|
||||
enabled ? 'bg-melon' : 'bg-mercury-pinkish dark:bg-gun-powder',
|
||||
darkModeEnabled
|
||||
? 'bg-melon'
|
||||
: 'bg-mercury-pinkish dark:bg-gun-powder',
|
||||
'relative inline-flex h-6 w-11 items-center rounded-full',
|
||||
])}
|
||||
>
|
||||
<span className="sr-only">Dark mode</span>
|
||||
<span
|
||||
className={clsx([
|
||||
enabled ? 'translate-x-6' : 'translate-x-1',
|
||||
darkModeEnabled ? 'translate-x-6' : 'translate-x-1',
|
||||
'inline-block h-4 w-4 transform rounded-full bg-ciment-feet dark:bg-white transition',
|
||||
])}
|
||||
/>
|
||||
|
||||
@@ -13,6 +13,7 @@ export type StateAction =
|
||||
| { type: 'set-partial-state'; partialState: Partial<AppState> }
|
||||
| { type: 'change-connection-state'; state: ConnectionState }
|
||||
| { type: 'set-vpn-mode'; mode: VpnMode }
|
||||
| { type: 'set-entry-selector'; entrySelector: boolean }
|
||||
| { type: 'set-error'; error: string }
|
||||
| { type: 'reset-error' }
|
||||
| { type: 'new-progress-message'; message: ConnectProgressMsg }
|
||||
@@ -23,17 +24,25 @@ export type StateAction =
|
||||
| { type: 'set-disconnected' }
|
||||
| { type: 'reset' }
|
||||
| { type: 'set-ui-theme'; theme: UiTheme }
|
||||
| { type: 'set-node-location'; payload: { hop: NodeHop; country: Country } };
|
||||
| { type: 'set-countries'; countries: Country[] }
|
||||
| { type: 'set-node-location'; payload: { hop: NodeHop; country: Country } }
|
||||
| { type: 'set-default-node-location'; country: Country };
|
||||
|
||||
export const initialState: AppState = {
|
||||
state: 'Disconnected',
|
||||
loading: false,
|
||||
vpnMode: 'Mixnet',
|
||||
vpnMode: 'TwoHop',
|
||||
entrySelector: false,
|
||||
tunnel: { name: 'nym', id: 'nym' },
|
||||
uiTheme: 'Light',
|
||||
progressMessages: [],
|
||||
entryNodeLocation: null,
|
||||
exitNodeLocation: null,
|
||||
defaultNodeLocation: {
|
||||
name: 'France',
|
||||
code: 'FR',
|
||||
},
|
||||
countries: [],
|
||||
};
|
||||
|
||||
export function reducer(state: AppState, action: StateAction): AppState {
|
||||
@@ -54,6 +63,16 @@ export function reducer(state: AppState, action: StateAction): AppState {
|
||||
...state,
|
||||
vpnMode: action.mode,
|
||||
};
|
||||
case 'set-entry-selector':
|
||||
return {
|
||||
...state,
|
||||
entrySelector: action.entrySelector,
|
||||
};
|
||||
case 'set-countries':
|
||||
return {
|
||||
...state,
|
||||
countries: action.countries,
|
||||
};
|
||||
case 'set-partial-state': {
|
||||
return { ...state, ...action.partialState };
|
||||
}
|
||||
@@ -121,6 +140,11 @@ export function reducer(state: AppState, action: StateAction): AppState {
|
||||
...state,
|
||||
uiTheme: action.theme,
|
||||
};
|
||||
case 'set-default-node-location':
|
||||
return {
|
||||
...state,
|
||||
defaultNodeLocation: action.country,
|
||||
};
|
||||
case 'reset':
|
||||
return initialState;
|
||||
}
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
import React, { useEffect, useReducer } from 'react';
|
||||
import { invoke } from '@tauri-apps/api';
|
||||
import { MainDispatchContext, MainStateContext } from '../contexts';
|
||||
import { AppDataFromBackend, CmdError, ConnectionState } from '../types';
|
||||
import { QuickConnectCountry } from '../constants';
|
||||
import {
|
||||
AppDataFromBackend,
|
||||
CmdError,
|
||||
ConnectionState,
|
||||
Country,
|
||||
} from '../types';
|
||||
import { initialState, reducer } from './main';
|
||||
import { useTauriEvents } from './useTauriEvents';
|
||||
|
||||
@@ -26,12 +30,53 @@ export function MainStateProvider({ children }: Props) {
|
||||
return await invoke<number | undefined>('get_connection_start_time');
|
||||
};
|
||||
|
||||
getInitialConnectionState().then((state) =>
|
||||
dispatch({ type: 'change-connection-state', state }),
|
||||
);
|
||||
getSessionStartTime().then((startTime) =>
|
||||
dispatch({ type: 'set-connection-start-time', startTime }),
|
||||
);
|
||||
// init country list
|
||||
const getCountries = async () => {
|
||||
return await invoke<Country[]>('get_node_countries');
|
||||
};
|
||||
|
||||
// init default node location
|
||||
const getDefaultNodeLocation = async () => {
|
||||
return await invoke<Country>('get_default_node_location');
|
||||
};
|
||||
|
||||
getInitialConnectionState()
|
||||
.then((state) => dispatch({ type: 'change-connection-state', state }))
|
||||
.catch((e: CmdError) => {
|
||||
console.warn(
|
||||
`command [get_connection_state] returned an error: ${e.source} - ${e.message}`,
|
||||
);
|
||||
});
|
||||
|
||||
getSessionStartTime()
|
||||
.then((startTime) =>
|
||||
dispatch({ type: 'set-connection-start-time', startTime }),
|
||||
)
|
||||
.catch((e: CmdError) => {
|
||||
console.warn(
|
||||
`command [get_connection_start_time] returned an error: ${e.source} - ${e.message}`,
|
||||
);
|
||||
});
|
||||
|
||||
getCountries()
|
||||
.then((countries) => {
|
||||
dispatch({ type: 'set-countries', countries });
|
||||
})
|
||||
.catch((e: CmdError) => {
|
||||
console.warn(
|
||||
`command [get_node_countries] returned an error: ${e.source} - ${e.message}`,
|
||||
);
|
||||
});
|
||||
|
||||
getDefaultNodeLocation()
|
||||
.then((country) => {
|
||||
dispatch({ type: 'set-default-node-location', country });
|
||||
})
|
||||
.catch((e: CmdError) => {
|
||||
console.warn(
|
||||
`command [get_default_node_location] returned an error: ${e.source} - ${e.message}`,
|
||||
);
|
||||
});
|
||||
}, []);
|
||||
|
||||
// get saved on disk app data and restore state from it
|
||||
@@ -42,20 +87,28 @@ export function MainStateProvider({ children }: Props) {
|
||||
|
||||
getAppData()
|
||||
.then((data) => {
|
||||
console.log('app data read from disk:');
|
||||
console.log(data);
|
||||
const partialState: Partial<typeof initialState> = {
|
||||
entrySelector: data.entry_location_selector || false,
|
||||
uiTheme: data.ui_theme || 'Light',
|
||||
vpnMode: data.vpn_mode || 'TwoHop',
|
||||
};
|
||||
if (data.entry_node_location) {
|
||||
partialState.entryNodeLocation = data.entry_node_location;
|
||||
}
|
||||
if (data.exit_node_location) {
|
||||
partialState.exitNodeLocation = data.exit_node_location;
|
||||
}
|
||||
dispatch({
|
||||
type: 'set-partial-state',
|
||||
partialState: {
|
||||
uiTheme: data.ui_theme || 'Light',
|
||||
vpnMode: data.vpn_mode || 'TwoHop',
|
||||
entryNodeLocation: data.entry_node_location || QuickConnectCountry,
|
||||
exitNodeLocation: data.exit_node_location || QuickConnectCountry,
|
||||
},
|
||||
partialState,
|
||||
});
|
||||
})
|
||||
.catch((err: CmdError) => {
|
||||
// TODO handle error properly
|
||||
console.log(err);
|
||||
.catch((e: CmdError) => {
|
||||
console.warn(
|
||||
`command [get_app_data] returned an error: ${e.source} - ${e.message}`,
|
||||
);
|
||||
});
|
||||
}, []);
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ export interface AppDataFromBackend {
|
||||
monitoring: boolean | null;
|
||||
autoconnect: boolean | null;
|
||||
killswitch: boolean | null;
|
||||
entry_location_selector: boolean | null;
|
||||
ui_theme: UiTheme | null;
|
||||
vpn_mode: VpnMode | null;
|
||||
entry_node: NodeConfig | null;
|
||||
|
||||
@@ -26,8 +26,11 @@ export type AppState = {
|
||||
vpnMode: VpnMode;
|
||||
tunnel: TunnelConfig;
|
||||
uiTheme: 'Light' | 'Dark';
|
||||
entrySelector: boolean;
|
||||
entryNodeLocation: Country | null;
|
||||
exitNodeLocation: Country | null;
|
||||
defaultNodeLocation: Country;
|
||||
countries: Country[];
|
||||
};
|
||||
|
||||
export type ConnectionEventPayload = {
|
||||
|
||||
Reference in New Issue
Block a user