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:
Zane Schepke
2023-12-19 05:15:11 -05:00
committed by GitHub
parent f4dd9a915d
commit f6c2cab531
28 changed files with 335 additions and 146 deletions
-3
View File
@@ -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
+1
View File
@@ -5267,6 +5267,7 @@ dependencies = [
"nym-task",
"nym-validator-client",
"nym-wireguard-types",
"rand 0.7.3",
"serde",
"serde_json",
"signature 1.4.0",
+25 -23
View File
@@ -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(())
}
+42 -14
View File
@@ -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())
}
+25
View File
@@ -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(),
},
]
});
+2 -4
View File
@@ -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>,
}
+1
View File
@@ -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>,
+6 -1
View File
@@ -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");
+13
View File
@@ -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 {
+3 -4
View File
@@ -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
}
+1 -1
View File
@@ -7,7 +7,7 @@
"withGlobalTauri": false
},
"package": {
"productName": "nym-vpn-ui",
"productName": "nym-vpn",
"version": "0.0.0"
},
"tauri": {
+1 -4
View File
@@ -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';
+21 -5
View File
@@ -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,
+2 -3
View File
@@ -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"
}
+3 -1
View File
@@ -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.${
+24 -14
View File
@@ -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([
+13 -6
View File
@@ -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>
);
+10 -20
View File
@@ -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>
+10 -9
View File
@@ -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>
);
}
+4 -4
View File
@@ -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>
+11 -7
View File
@@ -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',
])}
/>
+26 -2
View File
@@ -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;
}
+70 -17
View File
@@ -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}`,
);
});
}, []);
+1
View File
@@ -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;
+3
View File
@@ -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 = {