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:
@@ -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`
|
||||
|
||||
@@ -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",
|
||||
|
||||
Generated
+5958
-60
File diff suppressed because it is too large
Load Diff
@@ -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"]
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -7,7 +7,7 @@
|
||||
"withGlobalTauri": false
|
||||
},
|
||||
"package": {
|
||||
"productName": "nymvpn-ui",
|
||||
"productName": "nym-vpn-ui",
|
||||
"version": "0.0.0"
|
||||
},
|
||||
"tauri": {
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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 />}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -76,7 +76,7 @@ export default {
|
||||
icon: [
|
||||
'Material Symbols Outlined',
|
||||
{
|
||||
fontVariationSettings: '"opsz" 24; "wght" 400;',
|
||||
fontVariationSettings: '"opsz" 24;',
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user