refactor(vpnapp): node location logic (#4223)

* refactor node location logic

fontend:
remove app local data from state
remove node config from state
use only country location state

backend:
add node location in state
add set_node_location command

* call backend to update node location

* clean code
This commit is contained in:
Pierre Dommerc
2023-12-06 15:39:53 +01:00
committed by GitHub
parent 74481003e6
commit 26a8dec707
21 changed files with 136 additions and 118 deletions
+1 -1
View File
@@ -1,5 +1,5 @@
<!doctype html>
<html lang="en" class="text-[14px]">
<html lang="en" class="text-[12px]">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
@@ -37,7 +37,7 @@ pub fn get_node_countries() -> Result<Vec<Country>, CmdError> {
Ok(countries)
}
#[instrument]
#[instrument(skip(state))]
#[tauri::command]
pub async fn set_app_data(
state: State<'_, SharedAppData>,
@@ -56,7 +56,7 @@ pub async fn set_app_data(
Ok(())
}
#[instrument]
#[instrument(skip_all)]
#[tauri::command]
pub async fn get_app_data(
state: State<'_, SharedAppData>,
@@ -75,7 +75,7 @@ pub async fn get_app_data(
Ok(data)
}
#[instrument(skip_all)]
#[instrument(skip(data_state))]
#[tauri::command]
pub async fn set_ui_theme(
data_state: State<'_, SharedAppData>,
@@ -182,7 +182,7 @@ pub async fn get_connection_start_time(
Ok(app_state.connection_start_time.map(|t| t.unix_timestamp()))
}
#[instrument(skip_all)]
#[instrument(skip(app_state, data_state))]
#[tauri::command]
pub async fn set_vpn_mode(
app_state: State<'_, SharedAppState>,
+1
View File
@@ -1,2 +1,3 @@
pub mod app_data;
pub mod connection;
pub mod node_location;
@@ -0,0 +1,59 @@
use serde::{Deserialize, Serialize};
use tauri::State;
use tracing::{debug, instrument};
use ts_rs::TS;
use crate::{
error::{CmdError, CmdErrorSource},
states::{app::Country, SharedAppData, SharedAppState},
};
#[derive(Debug, Serialize, Deserialize, TS, Clone)]
pub enum NodeType {
Entry,
Exit,
}
#[instrument(skip(app_state, data_state))]
#[tauri::command]
pub async fn set_node_location(
app_state: State<'_, SharedAppState>,
data_state: State<'_, SharedAppData>,
node_type: NodeType,
country: Country,
) -> Result<(), CmdError> {
debug!("set_node_location");
let mut state = app_state.lock().await;
match node_type {
NodeType::Entry => {
state.entry_node_location = Some(country.clone());
}
NodeType::Exit => {
state.exit_node_location = Some(country.clone());
}
}
// TODO pick a node according to the selected country
// save the location on disk
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()))?;
match node_type {
NodeType::Entry => {
app_data.entry_node_location = Some(country);
}
NodeType::Exit => {
app_data.exit_node_location = Some(country);
}
}
app_data_store.data = app_data;
app_data_store
.write()
.await
.map_err(|e| CmdError::new(CmdErrorSource::InternalError, e.to_string()))?;
Ok(())
}
+3 -1
View File
@@ -1,7 +1,7 @@
use serde::{Deserialize, Serialize};
use ts_rs::TS;
use crate::states::app::{NodeConfig, VpnMode};
use crate::states::app::{Country, NodeConfig, VpnMode};
#[derive(Default, Serialize, Deserialize, Debug, Clone, TS)]
#[ts(export)]
@@ -21,4 +21,6 @@ pub struct AppData {
pub vpn_mode: Option<VpnMode>,
pub entry_node: Option<NodeConfig>,
pub exit_node: Option<NodeConfig>,
pub entry_node_location: Option<Country>,
pub exit_node_location: Option<Country>,
}
+1
View File
@@ -60,6 +60,7 @@ fn main() -> Result<()> {
app_data::set_app_data,
app_data::set_ui_theme,
app_data::get_node_countries,
node_location::set_node_location,
])
.run(tauri::generate_context!())
.expect("error while running tauri application");
+3 -1
View File
@@ -6,7 +6,7 @@ use ts_rs::TS;
#[ts(export)]
pub struct NodeConfig {
pub id: String,
pub country: String,
pub country: Country,
}
#[derive(Default, Debug, Clone, Copy, Serialize, Deserialize, TS)]
@@ -42,6 +42,8 @@ pub struct AppState {
pub vpn_mode: VpnMode,
pub entry_node: Option<NodeConfig>,
pub exit_node: Option<NodeConfig>,
pub entry_node_location: Option<Country>,
pub exit_node_location: Option<Country>,
pub tunnel: Option<TunnelConfig>,
pub connection_start_time: Option<OffsetDateTime>,
}
+4 -2
View File
@@ -65,13 +65,15 @@ export function mockTauriIPC() {
ui_theme: 'Dark',
vpn_mode: 'TwoHop',
entry_node: {
country: QuickConnectCountry.name,
country: QuickConnectCountry,
id: QuickConnectCountry.code,
},
exit_node: {
country: QuickConnectCountry.name,
country: QuickConnectCountry,
id: QuickConnectCountry.code,
},
entry_node_location: null,
exit_node_location: null,
}),
);
}
+2 -7
View File
@@ -61,14 +61,9 @@ function Home() {
<NetworkModeSelect />
<div className="py-2"></div>
<HopSelect
country={
state.localAppData.exitNode ?? {
country: QuickConnectCountry.name,
id: QuickConnectCountry.code,
}
}
country={state.exitNodeLocation ?? QuickConnectCountry}
onClick={() => navigate(routes.exitNodeLocation)}
nodeHop={{ type: 'exit' }}
nodeHop="exit"
/>
</div>
<Button
+6 -7
View File
@@ -1,9 +1,8 @@
import { useTranslation } from 'react-i18next';
import { NodeConfig } from '../../types';
import { NodeHop } from '../../types/general';
import { Country, NodeHop } from '../../types';
interface HopSelectProps {
country: NodeConfig;
country: Country;
onClick: () => void;
nodeHop: NodeHop;
}
@@ -27,13 +26,13 @@ export default function HopSelect({
readOnly={true}
type="text"
id="floating_outlined"
value={country.country}
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.id.toLowerCase()}.svg`}
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.id}
alt={country.code}
/>
<span className="font-icon scale-125 pointer-events-none absolute fill-current top-1/4 transform -translate-x-1/2 right-2">
arrow_right
@@ -42,7 +41,7 @@ export default function HopSelect({
htmlFor="floating_outlined"
className="dark:text-white bg-blanc-nacre 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] 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"
>
{nodeHop.type === 'entry' ? t('first-hop') : t('last-hop')}
{nodeHop === 'entry' ? t('first-hop') : t('last-hop')}
</label>
</div>
</>
@@ -15,7 +15,7 @@ export default function CountryList({
<ul className="flex flex-col w-full items-stretch p-1">
{countries && countries.length > 0 ? (
countries.map((country) => (
<li key={t(country.name)} className="list-none w-full">
<li key={country.code} className="list-none w-full">
<div
role="presentation"
onKeyDown={() => onClick(country.name, country.code)}
+19 -18
View File
@@ -3,24 +3,20 @@ import { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { invoke } from '@tauri-apps/api';
import { useMainDispatch, useMainState } from '../../contexts';
import { InputEvent, NodeHop } from '../../types/general';
import { Country, StateDispatch } from '../../types';
import { Country, InputEvent, NodeHop, StateDispatch } from '../../types';
import { routes } from '../../constants';
import SearchBox from './SearchBox';
import CountryList from './CountryList';
import QuickConnect from './QuickConnect';
function NodeLocation({ type }: NodeHop) {
const isEntryNodeSelectionScreen = type === 'entry';
function NodeLocation({ node }: { node: NodeHop }) {
const { t } = useTranslation('nodeLocation');
const [countries, setCountries] = useState<Country[]>([]);
const [search, setSearch] = useState('');
const [loading, setLoading] = useState(false);
const [foundCountries, setFoundCountries] = useState<Country[]>([]);
const {
localAppData: { entryNode, exitNode },
} = useMainState();
const { entryNodeLocation, exitNodeLocation } = useMainState();
const dispatch = useMainDispatch() as StateDispatch;
const navigate = useNavigate();
@@ -54,19 +50,24 @@ function NodeLocation({ type }: NodeHop) {
};
const isCountrySelected = (code: string): boolean => {
return isEntryNodeSelectionScreen
? entryNode?.id === code
: exitNode?.id === code;
return node === 'entry'
? entryNodeLocation?.code === code
: exitNodeLocation?.code === code;
};
const setNodeSelection = (name: string, code: string) => {
const nodeType = isEntryNodeSelectionScreen
? 'set-entry-node'
: 'set-exit-node';
dispatch({ type: nodeType, data: { country: name, id: code } });
};
const handleCountrySelection = (name: string, code: string) => {
setNodeSelection(name, code);
const handleCountrySelection = async (name: string, code: string) => {
try {
await invoke<void>('set_node_location', {
nodeType: node === 'entry' ? 'Entry' : 'Exit',
country: { name, code },
});
dispatch({
type: 'set-node-location',
payload: { hop: node, country: { name, code } },
});
} catch (e) {
console.log(e);
}
navigate(routes.root);
};
+3 -1
View File
@@ -1,9 +1,11 @@
import { InputEvent } from '../../types/general';
import { InputEvent } from '../../types';
interface SearchProps {
value: string;
onChange: (e: InputEvent) => void;
placeholder: string;
}
export default function SearchBox({
value,
onChange,
+2 -2
View File
@@ -20,12 +20,12 @@ const router = createBrowserRouter([
{
path: routes.entryNodeLocation,
// eslint-disable-next-line react/jsx-no-undef
element: <NodeLocation type="entry" />,
element: <NodeLocation node="entry" />,
errorElement: <Error />,
},
{
path: routes.exitNodeLocation,
element: <NodeLocation type="exit" />,
element: <NodeLocation node="exit" />,
errorElement: <Error />,
},
],
+16 -38
View File
@@ -1,13 +1,12 @@
import dayjs from 'dayjs';
import {
AppData,
AppState,
ConnectionState,
NodeConfig,
Country,
NodeHop,
UiTheme,
VpnMode,
} from '../types';
import { QuickConnectCountry } from '../constants';
export type StateAction =
| { type: 'set-partial-state'; partialState: Partial<AppState> }
@@ -22,10 +21,8 @@ export type StateAction =
| { type: 'set-connection-start-time'; startTime?: number | null }
| { type: 'set-disconnected' }
| { type: 'reset' }
| { type: 'set-app-data'; data: AppData }
| { type: 'set-ui-theme'; theme: UiTheme }
| { type: 'set-exit-node'; data: NodeConfig }
| { type: 'set-entry-node'; data: NodeConfig };
| { type: 'set-node-location'; payload: { hop: NodeHop; country: Country } };
export const initialState: AppState = {
state: 'Disconnected',
@@ -34,30 +31,27 @@ export const initialState: AppState = {
tunnel: { name: 'nym', id: 'nym' },
uiTheme: 'Light',
progressMessages: [],
localAppData: {
monitoring: false,
autoconnect: false,
killswitch: false,
uiTheme: 'Light',
vpnMode: 'TwoHop',
entryNode: {
country: QuickConnectCountry.name,
id: QuickConnectCountry.code,
},
exitNode: {
country: QuickConnectCountry.name,
id: QuickConnectCountry.code,
},
},
entryNodeLocation: null,
exitNodeLocation: null,
};
export function reducer(state: AppState, action: StateAction): AppState {
switch (action.type) {
case 'set-node-location':
if (action.payload.hop === 'entry') {
return {
...state,
entryNodeLocation: action.payload.country,
};
}
return {
...state,
exitNodeLocation: action.payload.country,
};
case 'set-vpn-mode':
return {
...state,
vpnMode: action.mode,
localAppData: { ...state.localAppData, vpnMode: action.mode },
};
case 'set-partial-state': {
return { ...state, ...action.partialState };
@@ -79,18 +73,6 @@ export function reducer(state: AppState, action: StateAction): AppState {
case 'disconnect': {
return { ...state, state: 'Disconnecting', loading: true };
}
case 'set-exit-node': {
return {
...state,
localAppData: { ...state.localAppData, exitNode: action.data },
};
}
case 'set-entry-node': {
return {
...state,
localAppData: { ...state.localAppData, entryNode: action.data },
};
}
case 'set-connected': {
return {
...state,
@@ -115,9 +97,6 @@ export function reducer(state: AppState, action: StateAction): AppState {
sessionStartDate:
(action.startTime && dayjs.unix(action.startTime)) || null,
};
case 'set-app-data': {
return { ...state, localAppData: action.data };
}
case 'set-error':
return { ...state, error: action.error };
case 'reset-error':
@@ -131,7 +110,6 @@ export function reducer(state: AppState, action: StateAction): AppState {
return {
...state,
uiTheme: action.theme,
localAppData: { ...state.localAppData, uiTheme: action.theme },
};
case 'reset':
return initialState;
+2 -18
View File
@@ -43,29 +43,13 @@ export function MainStateProvider({ children }: Props) {
getAppData()
.then((data) => {
console.log(data);
dispatch({
type: 'set-app-data',
data: {
autoconnect: data.autoconnect || false,
monitoring: data.monitoring || false,
killswitch: data.killswitch || false,
uiTheme: data.ui_theme || 'Light',
vpnMode: data.vpn_mode || 'TwoHop',
entryNode: data.entry_node || {
country: QuickConnectCountry.name,
id: QuickConnectCountry.code,
},
exitNode: data.exit_node || {
country: QuickConnectCountry.name,
id: QuickConnectCountry.code,
},
},
});
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,
},
});
})
+4 -12
View File
@@ -4,7 +4,7 @@ export type UiTheme = 'Dark' | 'Light';
export interface NodeConfig {
id: string;
country: string;
country: Country;
}
export type Country = {
@@ -12,23 +12,15 @@ export type Country = {
code: string;
};
export interface AppData {
monitoring: boolean;
autoconnect: boolean;
killswitch: boolean;
uiTheme: UiTheme;
vpnMode: VpnMode;
entryNode?: NodeConfig | null;
exitNode?: NodeConfig | null;
}
// tauri type, hence the use of snake_case
export interface AppDataFromBackend {
monitoring: boolean | null;
autoconnect: boolean | null;
killswitch: boolean | null;
ui_theme: UiTheme | null;
vpn_mode: VpnMode;
vpn_mode: VpnMode | null;
entry_node: NodeConfig | null;
exit_node: NodeConfig | null;
entry_node_location: Country | null;
exit_node_location: Country | null;
}
+3 -2
View File
@@ -1,7 +1,7 @@
import { Dispatch } from 'react';
import { Dayjs } from 'dayjs';
import { StateAction } from '../state';
import { AppData } from './app-data';
import { Country } from './app-data';
export type ConnectionState =
| 'Connected'
@@ -26,7 +26,8 @@ export type AppState = {
vpnMode: VpnMode;
tunnel: TunnelConfig;
uiTheme: 'Light' | 'Dark';
localAppData: AppData;
entryNodeLocation: Country | null;
exitNodeLocation: Country | null;
};
export type ConnectionEventPayload = {
+1 -3
View File
@@ -2,6 +2,4 @@ import React from 'react';
export type InputEvent = React.ChangeEvent<HTMLInputElement>;
export type NodeHop = {
type: 'entry' | 'exit';
};
export type NodeHop = 'entry' | 'exit';
+1
View File
@@ -2,3 +2,4 @@ export * from './app-state';
export * from './app-data';
export * from './tauri-ipc';
export * from './routes';
export * from './general';