feat(vpn-desktop): integrate nym-vpn-lib (#4244)

* fix initial selected vpn mode

* wip

* Set gateway config

* Init procedure and reading config

* Update two comments

* add nym-api field to app config

* Remove hardcoded RUST_LOG from package.json

* Use scope instead of explicit drop

* Spawn vpn client in separate thread and separate runtime

* Re-set nym-vpn-lib in Cargo.toml

* add vpn handle to app state

* add vpn client call to disconnect cmd

* wip

* Setup listener tasks

* Read entire env after all

* add env config file to app config

* doc: add notes on config

* refactor env config file as optional

* add logic to connection status changes

* refactor disconnect command

* fix handle click connect button

* update doc

* add some fake delay to establish connection

* localize backend messages

* refactor extract registering listeners into modules

* add more tracing logs

* refactor clean code

* refactor clean code

* refactor vpn config creation

* fix connect app_config read

* refactor rename listener functions

* add backend support for twohop mode

* copy change

* base connected status on Ready message

Ready message sent from vpn client

* filter out specific error

* add logs

* use exported receiver types from nym_vpn_lib

* Handle exit message

* Change to nym-vpn for consistency

* prefix comment with TODO

* update doc

* remove nym_api config property

use the one provided in the env config file

* fix css compile error

* log received backend error (frontend)

---------

Co-authored-by: Jon Häggblad <jon.haggblad@gmail.com>
This commit is contained in:
Pierre Dommerc
2023-12-15 13:25:49 +01:00
committed by GitHub
parent e8956603d7
commit 82bfab48a5
21 changed files with 6462 additions and 219 deletions
+34
View File
@@ -19,6 +19,32 @@ To install:
yarn
```
## Required config
First you can provide a network configuration using en env file,
pick the relevant one [here](https://github.com/nymtech/nym/tree/develop/envs).
The mainnet config will be used by default if not provided.
Then create the main app config file `config.toml` under `nym-vpn`
directory, full path is platform specific:
- Linux: Resolves to `$XDG_CONFIG_HOME` or `$HOME/.config`
- macOS: Resolves to `$HOME/Library/Application Support`
- Windows: Resolves to `{FOLDERID_RoamingAppData}`
For example on Linux the path would be `~/.config/nym-vpn/config.toml`
```toml
# example config on Linux
# 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
```
@@ -32,6 +58,14 @@ cd src-tauri
cargo tauri dev
```
**NOTE** Starting a VPN connection requires root privileges as it will set up a link interface.
If you want to connect during development, you need to run the app as root,
likely using `sudo` (or equivalent)
```shell
sudo -E RUST_LOG=debug cargo tauri dev
```
#### Logging
Rust logging (standard output) is controlled by the `RUST_LOG`
+1 -1
View File
@@ -5,7 +5,7 @@
"type": "module",
"scripts": {
"dev": "vite",
"dev:app": "RUST_LOG=nymvpn_ui=trace tauri dev",
"dev:app": "tauri dev",
"dev:browser": "vite --mode dev-browser",
"build": "tsc && vite build",
"build:app": "yarn tauri build",
+5958 -60
View File
File diff suppressed because it is too large Load Diff
+9 -1
View File
@@ -1,5 +1,5 @@
[package]
name = "nymvpn-ui"
name = "nym-vpn-ui"
version = "0.0.0"
description = "Application UI for Nym VPN desktop clients"
authors = ["you"]
@@ -24,8 +24,16 @@ ts-rs = { version = "7.0.0", features = ["chrono-impl"] }
once_cell = "1.18.0"
toml = "0.8.5"
time = "0.3.9"
nym-vpn-lib = { path = "../../../../nym-vpn-client/nym-vpn-lib" }
futures = "0.3.15"
# TODO Ugly workaround to force a working setup for nym-vpn-lib
# We should get rid of this ASAP
shadowsocks = { version = "~1.14.2" }
shadowsocks-service = { version = "~1.14.3" }
[features]
# this feature is used for production builds or when `devPath` points to the filesystem
# DO NOT REMOVE!!
custom-protocol = ["tauri/custom-protocol"]
+132 -98
View File
@@ -1,43 +1,21 @@
use std::time::Duration;
use futures::SinkExt;
use nym_vpn_lib::{NymVpnCtrlMessage, NymVpnHandle};
use tauri::{Manager, State};
use time::OffsetDateTime;
use tokio::time::sleep;
use tracing::{debug, error, instrument, trace};
use tracing::{debug, error, info, instrument, trace};
use crate::{
error::{CmdError, CmdErrorSource},
states::{
app::{ConnectionState, VpnMode},
SharedAppData, SharedAppState,
SharedAppConfig, SharedAppData, SharedAppState,
},
vpn_client::{
create_vpn_config, spawn_exit_listener, spawn_status_listener, ConnectProgressMsg,
ConnectionEventPayload, ProgressEventPayload, EVENT_CONNECTION_PROGRESS,
EVENT_CONNECTION_STATE,
},
};
const EVENT_CONNECTION_STATE: &str = "connection-state";
const EVENT_CONNECTION_PROGRESS: &str = "connection-progress";
#[derive(Clone, serde::Serialize)]
struct ConnectionEventPayload {
state: ConnectionState,
error: Option<String>,
start_time: Option<i64>, // unix timestamp in seconds
}
impl ConnectionEventPayload {
fn new(state: ConnectionState, error: Option<String>, start_time: Option<i64>) -> Self {
Self {
state,
error,
start_time,
}
}
}
#[derive(Clone, serde::Serialize)]
struct ProgressEventPayload {
message: String,
}
#[instrument(skip_all)]
#[tauri::command]
pub async fn get_connection_state(
@@ -53,74 +31,111 @@ pub async fn get_connection_state(
pub async fn connect(
app: tauri::AppHandle,
state: State<'_, SharedAppState>,
config_store: State<'_, SharedAppConfig>,
) -> Result<ConnectionState, CmdError> {
debug!("connect");
let mut app_state = state.lock().await;
let ConnectionState::Disconnected = app_state.state else {
return Err(CmdError::new(
CmdErrorSource::CallerError,
format!("cannot connect from state {:?}", app_state.state),
));
};
{
let mut app_state = state.lock().await;
if app_state.state != ConnectionState::Disconnected {
return Err(CmdError::new(
CmdErrorSource::CallerError,
format!("cannot connect from state {:?}", app_state.state),
));
};
// switch to "Connecting" state
app_state.state = ConnectionState::Connecting;
// unlock the mutex
drop(app_state);
// switch to "Connecting" state
trace!("update connection state [Connecting]");
app_state.state = ConnectionState::Connecting;
}
debug!("sending event [{}]: Connecting", EVENT_CONNECTION_STATE);
app.emit_all(
EVENT_CONNECTION_STATE,
ConnectionEventPayload::new(ConnectionState::Connecting, None, None),
)
.ok();
// TODO fake some delay to establish connection
let app_state_cloned = state.inner().clone();
let task = tokio::spawn(async move {
app.emit_all(
EVENT_CONNECTION_PROGRESS,
ProgressEventPayload {
message: "Connecting to the network…".to_string(),
},
trace!(
"sending event [{}]: Initializing",
EVENT_CONNECTION_PROGRESS
);
app.emit_all(
EVENT_CONNECTION_PROGRESS,
ProgressEventPayload {
key: ConnectProgressMsg::Initializing,
},
)
.ok();
let app_config = config_store.lock().await.read().await.map_err(|e| {
CmdError::new(
CmdErrorSource::InternalError,
format!("failed to read app config: {}", e),
)
.ok();
sleep(Duration::from_millis(300)).await;
app.emit_all(
EVENT_CONNECTION_PROGRESS,
ProgressEventPayload {
message: "Fetching nodes and gateways…".to_string(),
},
)
.ok();
sleep(Duration::from_millis(400)).await;
app.emit_all(
EVENT_CONNECTION_PROGRESS,
ProgressEventPayload {
message: "Done".to_string(),
},
)
.ok();
sleep(Duration::from_millis(200)).await;
trace!("connected");
let now = OffsetDateTime::now_utc();
let mut state = app_state_cloned.lock().await;
state.state = ConnectionState::Connected;
state.connection_start_time = Some(now);
debug!("sending event [{}]: connected", EVENT_CONNECTION_STATE);
})?;
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");
}
}
// vpn_config.disable_routing = true;
// spawn the VPN client and start a new connection
let NymVpnHandle {
vpn_ctrl_tx,
vpn_status_rx,
vpn_exit_rx,
} = nym_vpn_lib::spawn_nym_vpn(vpn_config).map_err(|e| {
let err_message = format!("fail to initialize Nym VPN client: {}", e);
error!(err_message);
debug!("sending event [{}]: Disconnected", EVENT_CONNECTION_STATE);
app.emit_all(
EVENT_CONNECTION_STATE,
ConnectionEventPayload::new(
ConnectionState::Connected,
ConnectionState::Disconnected,
Some(err_message.clone()),
None,
Some(now.unix_timestamp()),
),
)
.ok();
});
CmdError::new(CmdErrorSource::InternalError, err_message)
})?;
info!("nym vpn client spawned");
trace!("sending event [{}]: InitDone", EVENT_CONNECTION_PROGRESS);
app.emit_all(
EVENT_CONNECTION_PROGRESS,
ProgressEventPayload {
key: ConnectProgressMsg::InitDone,
},
)
.ok();
let _ = task.await;
// Start exit message listener
// This will listen for the (single) exit message from the VPN client and update the UI accordingly
debug!("starting exit listener");
spawn_exit_listener(app.clone(), state.inner().clone(), vpn_exit_rx)
.await
.ok();
let app_state = state.lock().await;
Ok(app_state.state)
// Start the VPN status listener
// This will listen for status messages from the VPN client and update the UI accordingly
debug!("starting status listener");
spawn_status_listener(app, state.inner().clone(), vpn_status_rx)
.await
.ok();
// Store the vpn control tx in the app state, which will be used to send control messages to
// the running background VPN task, such as to disconnect.
trace!("added vpn_ctrl_tx to app state");
let mut state = state.lock().await;
state.vpn_ctrl_tx = Some(vpn_ctrl_tx);
Ok(state.state)
}
#[instrument(skip_all)]
@@ -131,7 +146,7 @@ pub async fn disconnect(
) -> Result<ConnectionState, CmdError> {
debug!("disconnect");
let mut app_state = state.lock().await;
let ConnectionState::Connected = app_state.state else {
if app_state.state != ConnectionState::Connected {
return Err(CmdError::new(
CmdErrorSource::CallerError,
format!("cannot disconnect from state {:?}", app_state.state),
@@ -139,36 +154,55 @@ pub async fn disconnect(
};
// switch to "Disconnecting" state
trace!("update connection state [Disconnecting]");
app_state.state = ConnectionState::Disconnecting;
// unlock the mutex
drop(app_state);
debug!("sending event [{}]: Disconnecting", EVENT_CONNECTION_STATE);
app.emit_all(
EVENT_CONNECTION_STATE,
ConnectionEventPayload::new(ConnectionState::Disconnecting, None, None),
)
.ok();
// TODO fake some delay to confirm disconnection
let app_state_cloned = state.inner().clone();
let task = tokio::spawn(async move {
sleep(Duration::from_secs(1)).await;
trace!("disconnected");
let mut state = app_state_cloned.lock().await;
state.state = ConnectionState::Disconnected;
state.connection_start_time = None;
debug!("sending event [{}]: disconnected", EVENT_CONNECTION_STATE);
let Some(ref mut vpn_tx) = app_state.vpn_ctrl_tx else {
trace!("update connection state [Disconnected]");
app_state.state = ConnectionState::Disconnected;
app_state.connection_start_time = None;
debug!("sending event [{}]: Disconnected", EVENT_CONNECTION_STATE);
app.emit_all(
EVENT_CONNECTION_STATE,
ConnectionEventPayload::new(ConnectionState::Disconnected, None, None),
ConnectionEventPayload::new(
ConnectionState::Disconnected,
Some("vpn handle has not been initialized".to_string()),
None,
),
)
.ok();
});
return Err(CmdError::new(
CmdErrorSource::InternalError,
"vpn handle has not been initialized".to_string(),
));
};
let _ = task.await;
// send Stop message to the VPN client
debug!("sending Stop message to VPN client");
vpn_tx.send(NymVpnCtrlMessage::Stop).await.map_err(|e| {
let err_message = format!("failed to send Stop message to VPN client: {}", e);
error!(err_message);
debug!("sending event [{}]: Disconnected", EVENT_CONNECTION_STATE);
app.emit_all(
EVENT_CONNECTION_STATE,
ConnectionEventPayload::new(
ConnectionState::Disconnected,
Some(err_message.clone()),
None,
),
)
.ok();
CmdError::new(CmdErrorSource::InternalError, err_message)
})?;
debug!("Stop message sent");
let app_state = state.lock().await;
Ok(app_state.state)
}
+8 -1
View File
@@ -1,6 +1,13 @@
use std::path::PathBuf;
use serde::{Deserialize, Serialize};
#[derive(Default, Serialize, Deserialize, Debug, Clone)]
pub struct AppConfig {
// TODO
/// 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,
}
+69 -23
View File
@@ -1,44 +1,89 @@
// Prevents additional console window on Windows in release, DO NOT REMOVE!!
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
use std::sync::Arc;
use std::{env, sync::Arc};
use anyhow::anyhow;
use anyhow::Result;
use anyhow::{anyhow, Context, Result};
use tauri::api::path::{config_dir, data_dir};
use tokio::sync::Mutex;
use tracing::info;
use tokio::{fs::try_exists, sync::Mutex};
use tracing::{debug, error, info};
use commands::*;
use states::app::AppState;
use nym_vpn_lib::nym_config;
use crate::fs::{config::AppConfig, data::AppData, storage::AppStorage};
mod commands;
mod error;
mod fs;
mod states;
mod vpn_client;
use commands::*;
use states::app::AppState;
use crate::fs::config::AppConfig;
use crate::fs::data::AppData;
use crate::fs::storage::AppStorage;
const APP_DIR: &str = "nymvpn";
const APP_DIR: &str = "nym-vpn";
const APP_DATA_FILE: &str = "app-data.toml";
const APP_CONFIG_FILE: &str = "config.toml";
fn main() -> Result<()> {
pub fn setup_logging() {
let filter = tracing_subscriber::EnvFilter::builder()
.with_default_directive(tracing_subscriber::filter::LevelFilter::INFO.into())
.from_env()
.unwrap()
.add_directive("hyper::proto=info".parse().unwrap())
.add_directive("netlink_proto=info".parse().unwrap());
tracing_subscriber::fmt()
.with_env_filter(filter)
.compact()
.init();
}
#[tokio::main]
async fn main() -> Result<()> {
dotenvy::dotenv().ok();
setup_logging();
// uses RUST_LOG value for logging level
// eg. RUST_LOG=tauri=debug,nymvpn_ui=trace
tracing_subscriber::fmt::init();
let app_data_store = {
let mut app_data_path =
data_dir().ok_or(anyhow!("Failed to retrieve data directory path"))?;
app_data_path.push(APP_DIR);
AppStorage::<AppData>::new(app_data_path, APP_DATA_FILE, None)
};
debug!("app_data_store: {}", app_data_store.full_path.display());
let mut app_data_path = data_dir().ok_or(anyhow!("Failed to retrieve data directory"))?;
app_data_path.push(APP_DIR);
let app_data_store = AppStorage::<AppData>::new(app_data_path, APP_DATA_FILE, None);
let app_config_store = {
let mut app_config_path =
config_dir().ok_or(anyhow!("Failed to retrieve config directory path"))?;
app_config_path.push(APP_DIR);
AppStorage::<AppConfig>::new(app_config_path, APP_CONFIG_FILE, None)
};
debug!(
"app_config_store: {}",
&app_config_store.full_path.display()
);
let mut app_config_path = config_dir().ok_or(anyhow!("Failed to retrieve config directory"))?;
app_config_path.push(APP_DIR);
let app_config_store = AppStorage::<AppConfig>::new(app_config_path, APP_CONFIG_FILE, None);
let app_config = app_config_store.read().await?;
debug!("app_config: {app_config:?}");
// check for the existence of the env_config_file if provided
if let Some(env_config_file) = &app_config.env_config_file {
debug!("provided env_config_file: {}", env_config_file.display());
if !(try_exists(env_config_file)
.await
.context("an error happened while trying to read env_config_file `{}`")?)
{
let err_message = format!(
"app config, env_config_file `{}`: file not found",
env_config_file.display()
);
error!(err_message);
return Err(anyhow!(err_message));
}
}
// Read the env variables in the provided file and export them all to the local environment.
nym_config::defaults::setup_env(app_config.env_config_file);
info!("Starting tauri app");
@@ -64,5 +109,6 @@ fn main() -> Result<()> {
])
.run(tauri::generate_context!())
.expect("error while running tauri application");
Ok(())
}
+5 -2
View File
@@ -1,3 +1,5 @@
use futures::channel::mpsc::UnboundedSender;
use nym_vpn_lib::NymVpnCtrlMessage;
use serde::{Deserialize, Serialize};
use time::OffsetDateTime;
use ts_rs::TS;
@@ -9,7 +11,7 @@ pub struct NodeConfig {
pub country: Country,
}
#[derive(Default, Debug, Clone, Copy, Serialize, Deserialize, TS)]
#[derive(Default, Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, TS)]
#[ts(export)]
pub enum ConnectionState {
Connected,
@@ -20,7 +22,7 @@ pub enum ConnectionState {
Unknown,
}
#[derive(Default, Debug, Serialize, Deserialize, TS, Clone)]
#[derive(Default, Debug, Serialize, Deserialize, TS, Clone, PartialEq, Eq)]
#[ts(export)]
pub enum VpnMode {
Mixnet,
@@ -46,6 +48,7 @@ pub struct AppState {
pub exit_node_location: Option<Country>,
pub tunnel: Option<TunnelConfig>,
pub connection_start_time: Option<OffsetDateTime>,
pub vpn_ctrl_tx: Option<UnboundedSender<NymVpnCtrlMessage>>,
}
#[derive(Default, Serialize, Deserialize, Debug, Clone, TS)]
+2 -4
View File
@@ -1,8 +1,6 @@
use std::sync::Arc;
use tokio::sync::Mutex;
use crate::fs::{config::AppConfig, data::AppData, storage::AppStorage};
use std::sync::Arc;
use tokio::sync::Mutex;
pub mod app;
+173
View File
@@ -0,0 +1,173 @@
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::nym_config::OptionalSet;
use nym_vpn_lib::{NymVpn, NymVpnExitError, NymVpnExitStatusMessage, StatusReceiver};
use tauri::Manager;
use time::OffsetDateTime;
use tracing::{debug, error, info, instrument, trace};
pub const EVENT_CONNECTION_STATE: &str = "connection-state";
pub const EVENT_CONNECTION_PROGRESS: &str = "connection-progress";
#[derive(Clone, serde::Serialize)]
pub enum ConnectProgressMsg {
Initializing,
InitDone,
}
#[derive(Clone, serde::Serialize)]
pub struct ProgressEventPayload {
pub key: ConnectProgressMsg,
}
#[derive(Clone, serde::Serialize)]
pub struct ConnectionEventPayload {
state: ConnectionState,
error: Option<String>,
start_time: Option<i64>, // unix timestamp in seconds
}
impl ConnectionEventPayload {
pub fn new(state: ConnectionState, error: Option<String>, start_time: Option<i64>) -> Self {
Self {
state,
error,
start_time,
}
}
}
fn handle_vpn_exit_error(e: Box<dyn std::error::Error + Send + Sync>) -> String {
match e.downcast::<Box<NymVpnExitError>>() {
Ok(e) => {
// TODO The double boxing here is unexpected, we should look into that
match **e {
NymVpnExitError::Generic { reason } => reason.to_string(),
NymVpnExitError::FailedToResetFirewallPolicy { reason } => reason.to_string(),
NymVpnExitError::FailedToResetDnsMonitor { reason } => reason.to_string(),
}
}
Err(e) => format!("unknown error: {e}"),
}
}
#[instrument(skip_all)]
pub async fn spawn_exit_listener(
app: tauri::AppHandle,
app_state: SharedAppState,
exit_rx: OneshotReceiver<NymVpnExitStatusMessage>,
) -> Result<()> {
tokio::spawn(async move {
match exit_rx.await {
Ok(res) => {
debug!("received vpn exit message: {res:?}");
match res {
NymVpnExitStatusMessage::Stopped => {
info!("vpn connection stopped");
debug!(
"vpn stopped, sending event [{}]: disconnected",
EVENT_CONNECTION_STATE
);
app.emit_all(
EVENT_CONNECTION_STATE,
ConnectionEventPayload::new(ConnectionState::Disconnected, None, None),
)
.ok();
}
NymVpnExitStatusMessage::Failed(e) => {
let error = handle_vpn_exit_error(e);
debug!(
"vpn failed, sending event [{}]: disconnected",
EVENT_CONNECTION_STATE
);
app.emit_all(
EVENT_CONNECTION_STATE,
ConnectionEventPayload::new(
ConnectionState::Disconnected,
Some(error),
None,
),
)
.ok();
}
}
}
Err(e) => {
error!("vpn_exit_rx failed to receive exit message: {}", e);
app.emit_all(
EVENT_CONNECTION_STATE,
ConnectionEventPayload::new(
ConnectionState::Disconnected,
Some("exit channel with vpn client has been closed".to_string()),
None,
),
)
.ok();
}
}
// update the connection state
let mut state = app_state.lock().await;
state.state = ConnectionState::Disconnected;
state.connection_start_time = None;
info!("vpn exit listener has exited");
});
Ok(())
}
#[instrument(skip_all)]
pub async fn spawn_status_listener(
app: tauri::AppHandle,
app_state: SharedAppState,
mut status_rx: StatusReceiver,
) -> Result<()> {
tokio::spawn(async move {
while let Some(msg) = status_rx.next().await {
info!("received vpn status message: {msg:?}");
if "Ready" == msg.to_string().as_str() {
info!("vpn connection has been established");
let now = OffsetDateTime::now_utc();
{
let mut state = app_state.lock().await;
trace!("update connection state [Connected]");
state.state = ConnectionState::Connected;
state.connection_start_time = Some(now);
}
debug!("sending event [{}]: Connected", EVENT_CONNECTION_STATE);
app.emit_all(
EVENT_CONNECTION_STATE,
ConnectionEventPayload::new(
ConnectionState::Connected,
None,
Some(now.unix_timestamp()),
),
)
.ok();
}
}
info!("vpn status listener has exited");
});
Ok(())
}
fn setup_gateway_client_config(private_key: Option<&str>) -> GatewayClientConfig {
let mut config = GatewayClientConfig::default()
// Read in the environment variable NYM_API if it exists
.with_optional_env(GatewayClientConfig::with_custom_api_url, None, "NYM_API");
info!("Using nym-api: {}", config.api_url());
if let Some(key) = private_key {
config = config.with_local_private_key(key.into());
}
config
}
#[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);
nym_vpn.gateway_config = setup_gateway_client_config(None);
nym_vpn
}
+1 -1
View File
@@ -7,7 +7,7 @@
"withGlobalTauri": false
},
"package": {
"productName": "nymvpn-ui",
"productName": "nym-vpn-ui",
"version": "0.0.0"
},
"tauri": {
+3 -1
View File
@@ -4,6 +4,7 @@ import common from './en/common.json';
import home from './en/home.json';
import settings from './en/settings.json';
import nodeLocation from './en/node-location.json';
import backendMessages from './en/backend-messages.json';
const defaultNS = 'common';
@@ -16,9 +17,10 @@ i18n.use(initReactI18next).init({
home,
settings,
nodeLocation,
backendMessages,
},
},
ns: ['common', 'home', 'settings', 'nodeLocation'],
ns: ['common', 'home', 'settings', 'nodeLocation', 'backendMessages'],
defaultNS,
interpolation: {
@@ -0,0 +1,6 @@
{
"connection-progress": {
"Initializing": "Initializing Nym VPN client…",
"InitDone": "Nym VPN client initialized"
}
}
+1 -1
View File
@@ -19,7 +19,7 @@
"desc": "Best for payments, emails, messages"
},
"twohop-mode": {
"title": "2-hop WireGuard",
"title": "2-hop",
"desc": "Best for browsing, streaming, sharing"
},
"last-node-select": {
@@ -69,7 +69,14 @@ function ConnectionStatus() {
<div className="flex flex-1">
{state.loading && state.progressMessages.length > 0 && (
<p className="text-dim-gray dark:text-mercury-mist font-bold">
{state.progressMessages[state.progressMessages.length - 1]}
{t(
`connection-progress.${
state.progressMessages[state.progressMessages.length - 1]
}`,
{
ns: 'backendMessages',
},
)}
</p>
)}
{state.state === 'Connected' && <ConnectionTimer />}
+31 -18
View File
@@ -1,36 +1,49 @@
import { useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { invoke } from '@tauri-apps/api';
import clsx from 'clsx';
import { Button } from '@mui/base';
import { useNavigate } from 'react-router-dom';
import { useMainDispatch, useMainState } from '../../contexts';
import { ConnectionState, StateDispatch } from '../../types';
import { StateDispatch } from '../../types';
import { QuickConnectCountry, routes } from '../../constants';
import NetworkModeSelect from './NetworkModeSelect';
import ConnectionStatus from './ConnectionStatus';
import HopSelect from './HopSelect';
function Home() {
const state = useMainState();
const { state, loading, exitNodeLocation } = useMainState();
const dispatch = useMainDispatch() as StateDispatch;
const navigate = useNavigate();
const { t } = useTranslation('home');
const handleClick = async () => {
if (state.state === 'Connected') {
dispatch({ type: 'disconnect' });
invoke('disconnect').then((result) => {
console.log(result);
});
} else if (state.state === 'Disconnected') {
dispatch({ type: 'disconnect' });
if (state === 'Connected') {
invoke('disconnect')
.then((result) => {
console.log('disconnect result');
console.log(result);
})
.catch((e) => {
console.log(e);
dispatch({ type: 'set-error', error: e });
});
} else if (state === 'Disconnected') {
dispatch({ type: 'connect' });
invoke('connect').then((result) => {
console.log(result);
});
invoke('connect')
.then((result) => {
console.log('connect result');
console.log(result);
})
.catch((e) => {
console.log(e);
dispatch({ type: 'set-error', error: e });
});
}
};
const getButtonText = (state: ConnectionState) => {
const getButtonText = useCallback(() => {
switch (state) {
case 'Connected':
return t('disconnect');
@@ -51,7 +64,7 @@ function Home() {
case 'Unknown':
return t('status.unknown');
}
};
}, [state, t]);
return (
<div className="h-full flex flex-col p-4">
@@ -61,7 +74,7 @@ function Home() {
<NetworkModeSelect />
<div className="py-2"></div>
<HopSelect
country={state.exitNodeLocation ?? QuickConnectCountry}
country={exitNodeLocation ?? QuickConnectCountry}
onClick={() => navigate(routes.exitNodeLocation)}
nodeHop="exit"
/>
@@ -69,15 +82,15 @@ function Home() {
<Button
className={clsx([
'rounded-lg text-lg font-bold py-4 px-6 h-16 focus:outline-none focus:ring-4 focus:ring-black focus:dark:ring-white shadow',
(state.state === 'Disconnected' || state.state === 'Connecting') &&
(state === 'Disconnected' || state === 'Connecting') &&
'bg-melon text-white dark:text-baltic-sea',
(state.state === 'Connected' || state.state === 'Disconnecting') &&
(state === 'Connected' || state === 'Disconnecting') &&
'bg-cornflower text-white dark:text-baltic-sea',
])}
onClick={handleClick}
disabled={state.loading}
disabled={loading}
>
{getButtonText(state.state)}
{getButtonText()}
</Button>
</div>
</div>
+12 -2
View File
@@ -1,6 +1,7 @@
import dayjs from 'dayjs';
import {
AppState,
ConnectProgressMsg,
ConnectionState,
Country,
NodeHop,
@@ -14,7 +15,7 @@ export type StateAction =
| { type: 'set-vpn-mode'; mode: VpnMode }
| { type: 'set-error'; error: string }
| { type: 'reset-error' }
| { type: 'new-progress-message'; message: string }
| { type: 'new-progress-message'; message: ConnectProgressMsg }
| { type: 'connect' }
| { type: 'disconnect' }
| { type: 'set-connected'; startTime: number }
@@ -27,7 +28,7 @@ export type StateAction =
export const initialState: AppState = {
state: 'Disconnected',
loading: false,
vpnMode: 'TwoHop',
vpnMode: 'Mixnet',
tunnel: { name: 'nym', id: 'nym' },
uiTheme: 'Light',
progressMessages: [],
@@ -57,6 +58,9 @@ export function reducer(state: AppState, action: StateAction): AppState {
return { ...state, ...action.partialState };
}
case 'change-connection-state': {
console.log(
`__REDUCER [change-connection-state] changing connection state to ${action.state}`,
);
if (action.state === state.state) {
return state;
}
@@ -68,12 +72,18 @@ export function reducer(state: AppState, action: StateAction): AppState {
};
}
case 'connect': {
console.log(
`__REDUCER [connect] changing connection state to Connecting`,
);
return { ...state, state: 'Connecting', loading: true };
}
case 'disconnect': {
return { ...state, state: 'Disconnecting', loading: true };
}
case 'set-connected': {
console.log(
`__REDUCER [set-connected] changing connection state to Connected`,
);
return {
...state,
state: 'Connected',
+3 -2
View File
@@ -13,6 +13,7 @@ function handleError(dispatch: StateDispatch, error?: string | null) {
dispatch({ type: 'reset-error' });
return;
}
console.warn(`received backend error: ${error}`);
dispatch({ type: 'set-error', error });
}
@@ -53,11 +54,11 @@ export function useTauriEvents(dispatch: StateDispatch) {
const registerProgressListener = useCallback(() => {
return listen<ProgressEventPayload>(ProgressEvent, (event) => {
console.log(
`received event ${event.event}, message: ${event.payload.message}`,
`received event ${event.event}, message: ${event.payload.key}`,
);
dispatch({
type: 'new-progress-message',
message: event.payload.message,
message: event.payload.key,
});
});
}, [dispatch]);
+1
View File
@@ -6,6 +6,7 @@
@font-face {
font-family: 'Material Symbols Outlined';
font-style: normal;
font-wight: 400;
src: url(assets/fonts/MaterialSymbolsOutlined.woff2) format('woff2');
}
}
+4 -2
View File
@@ -21,7 +21,7 @@ export type AppState = {
state: ConnectionState;
loading: boolean;
error?: string | null;
progressMessages: string[];
progressMessages: ConnectProgressMsg[];
sessionStartDate?: Dayjs | null;
vpnMode: VpnMode;
tunnel: TunnelConfig;
@@ -36,8 +36,10 @@ export type ConnectionEventPayload = {
start_time?: number | null; // unix timestamp in seconds
};
export type ConnectProgressMsg = 'Initializing' | 'InitDone';
export type ProgressEventPayload = {
message: string;
key: ConnectProgressMsg;
};
export type StateDispatch = Dispatch<StateAction>;
+1 -1
View File
@@ -76,7 +76,7 @@ export default {
icon: [
'Material Symbols Outlined',
{
fontVariationSettings: '"opsz" 24; "wght" 400;',
fontVariationSettings: '"opsz" 24;',
},
],
},