diff --git a/nym-vpn/ui/README.md b/nym-vpn/ui/README.md index a1fe5876a6..b4c3e351e1 100644 --- a/nym-vpn/ui/README.md +++ b/nym-vpn/ui/README.md @@ -25,6 +25,25 @@ yarn yarn dev:app ``` +or + +``` +cd src-tauri +cargo tauri dev +``` + +#### Logging + +Rust logging (standard output) is controlled by the `RUST_LOG` +env variable + +Example: + +``` +cd src-tauri +RUST_LOG=trace cargo tauri dev +``` + ## Dev in the browser For convenience and better development experience, we can run the @@ -43,6 +62,22 @@ When creating new tauri command, be sure to add the corresponding mock definition into `nym-vpn/ui/src/dev/tauri-cmd-mocks/` and update `nym-vpn/ui/src/dev/setup.ts` accordingly. +## Type bindings + +[ts-rs](https://github.com/Aleph-Alpha/ts-rs) can be used to generate +TS type definitions from Rust types + +To generate bindings, first +[annotate](https://github.com/Aleph-Alpha/ts-rs/blob/main/example/src/lib.rs) +Rust types, then run + +``` +cd src-tauri +cargo test +``` + +Generated TS types will be located in `src-tauri/bindings/` + ## Build To build as a **shared library** diff --git a/nym-vpn/ui/package.json b/nym-vpn/ui/package.json index 2570661bcc..4be6c83b4e 100644 --- a/nym-vpn/ui/package.json +++ b/nym-vpn/ui/package.json @@ -5,7 +5,7 @@ "type": "module", "scripts": { "dev": "vite", - "dev:app": "WEBKIT_DISABLE_COMPOSITING_MODE=1 tauri dev", + "dev:app": "RUST_LOG=nymvpn_ui=trace tauri dev", "dev:browser": "vite --mode dev-browser", "build": "tsc && vite build", "build:app": "yarn build && cd src-tauri && cargo build --release --lib --features custom-protocol", diff --git a/nym-vpn/ui/src-tauri/.gitignore b/nym-vpn/ui/src-tauri/.gitignore index f4dfb82b2c..a324855cc5 100644 --- a/nym-vpn/ui/src-tauri/.gitignore +++ b/nym-vpn/ui/src-tauri/.gitignore @@ -1,4 +1,3 @@ -# Generated by Cargo -# will have compiled files and executables /target/ +/bindings/ diff --git a/nym-vpn/ui/src-tauri/Cargo.lock b/nym-vpn/ui/src-tauri/Cargo.lock index 4e4061f1a5..58d65c1337 100644 --- a/nym-vpn/ui/src-tauri/Cargo.lock +++ b/nym-vpn/ui/src-tauri/Cargo.lock @@ -2,6 +2,12 @@ # It is not intended for manual editing. version = 3 +[[package]] +name = "Inflector" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe438c63458706e03479442743baae6c88256498e6431708f6dfc520a26515d3" + [[package]] name = "addr2line" version = "0.21.0" @@ -294,8 +300,10 @@ checksum = "7f2c685bad3eb3d45a01354cedb7d5faa66194d1d58ba6e267a8de788f79db38" dependencies = [ "android-tzdata", "iana-time-zone", + "js-sys", "num-traits", "serde", + "wasm-bindgen", "windows-targets", ] @@ -570,6 +578,12 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bd0c93bb4b0c6d9b77f4435b0ae98c24d17f1c45b2ff844c6151a07256ca923b" +[[package]] +name = "dotenvy" +version = "0.15.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" + [[package]] name = "dtoa" version = "1.0.9" @@ -599,7 +613,7 @@ checksum = "f54cc3e827ee1c3812239a9a41dede7b4d7d5d5464faa32d71bd7cba28ce2cb2" dependencies = [ "cc", "rustc_version", - "toml 0.8.2", + "toml 0.8.5", "vswhom", "winreg", ] @@ -1651,10 +1665,19 @@ dependencies = [ name = "nymvpn-ui" version = "0.0.0" dependencies = [ + "anyhow", + "dotenvy", + "once_cell", "serde", "serde_json", "tauri", "tauri-build", + "thiserror", + "tokio", + "toml 0.8.5", + "tracing", + "tracing-subscriber", + "ts-rs", ] [[package]] @@ -2323,9 +2346,9 @@ dependencies = [ [[package]] name = "serde_spanned" -version = "0.6.3" +version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96426c9936fd7a0124915f9185ea1d20aa9445cc9821142f0a73bc9207a2e186" +checksum = "12022b835073e5b11e90a14f86838ceb1c8fb0325b72416845c487ac0fa95e80" dependencies = [ "serde", ] @@ -2557,7 +2580,7 @@ dependencies = [ "cfg-expr 0.15.5", "heck 0.4.1", "pkg-config", - "toml 0.8.2", + "toml 0.8.5", "version-compare 0.1.1", ] @@ -2847,6 +2870,15 @@ dependencies = [ "utf-8", ] +[[package]] +name = "termcolor" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6093bad37da69aab9d123a8091e4be0aa4a03e4d601ec641c327398315f62b64" +dependencies = [ + "winapi-util", +] + [[package]] name = "thin-slice" version = "0.1.1" @@ -2962,21 +2994,21 @@ dependencies = [ [[package]] name = "toml" -version = "0.8.2" +version = "0.8.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "185d8ab0dfbb35cf1399a6344d8484209c088f75f8f68230da55d48d95d43e3d" +checksum = "3efaf127c78d5339cc547cce4e4d973bd5e4f56e949a06d091c082ebeef2f800" dependencies = [ "serde", "serde_spanned", "toml_datetime", - "toml_edit 0.20.2", + "toml_edit 0.20.5", ] [[package]] name = "toml_datetime" -version = "0.6.3" +version = "0.6.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7cda73e2f1397b1262d6dfdcef8aafae14d1de7748d66822d3bfeeb6d03e5e4b" +checksum = "3550f4e9685620ac18a50ed434eb3aec30db8ba93b0287467bca5826ea25baf1" dependencies = [ "serde", ] @@ -2996,9 +3028,9 @@ dependencies = [ [[package]] name = "toml_edit" -version = "0.20.2" +version = "0.20.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "396e4d48bbb2b7554c944bde63101b5ae446cff6ec4a24227428f15eb72ef338" +checksum = "782bf6c2ddf761c1e7855405e8975472acf76f7f36d0d4328bd3b7a2fae12a85" dependencies = [ "indexmap 2.0.2", "serde", @@ -3077,6 +3109,30 @@ dependencies = [ "serde_json", ] +[[package]] +name = "ts-rs" +version = "7.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1ff1f8c90369bc172200013ac17ae86e7b5def580687df4e6127883454ff2b0" +dependencies = [ + "chrono", + "thiserror", + "ts-rs-macros", +] + +[[package]] +name = "ts-rs-macros" +version = "7.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6f41cc0aeb7a4a55730188e147d3795a7349b501f8334697fd37629b896cdc2" +dependencies = [ + "Inflector", + "proc-macro2", + "quote", + "syn 2.0.38", + "termcolor", +] + [[package]] name = "typenum" version = "1.17.0" diff --git a/nym-vpn/ui/src-tauri/Cargo.toml b/nym-vpn/ui/src-tauri/Cargo.toml index 93b390a573..b7eab97c9f 100644 --- a/nym-vpn/ui/src-tauri/Cargo.toml +++ b/nym-vpn/ui/src-tauri/Cargo.toml @@ -14,9 +14,18 @@ crate-type = ["cdylib"] tauri-build = { version = "1.5", features = [] } [dependencies] -tauri = { version = "1.5", features = ["shell-open"] } +tauri = { version = "1.5.2", features = ["shell-open"] } +tokio = { version = "1.33", features = ["rt", "sync", "time", "fs"] } serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" +tracing = "0.1" +tracing-subscriber = { version = "0.3.1", features = ["tracing-log", "env-filter"] } +anyhow = "1.0" +dotenvy = "0.15.7" +thiserror = "1.0" +ts-rs = { version = "7.0.0", features = ["chrono-impl"] } +once_cell = "1.18.0" +toml = "0.8.5" [features] # this feature is used for production builds or when `devPath` points to the filesystem diff --git a/nym-vpn/ui/src-tauri/src/commands/connection.rs b/nym-vpn/ui/src-tauri/src/commands/connection.rs new file mode 100644 index 0000000000..9f44794ac0 --- /dev/null +++ b/nym-vpn/ui/src-tauri/src/commands/connection.rs @@ -0,0 +1,61 @@ +use std::time::Duration; + +use tauri::State; +use tokio::time::sleep; +use tracing::{debug, instrument, trace}; + +use crate::{ + error::CommandError, + states::{app::ConnectionState, SharedAppState}, +}; + +#[instrument] +#[tauri::command] +pub async fn get_connection_state( + state: State<'_, SharedAppState>, +) -> Result { + debug!("get_connection_state"); + let app_state = state.lock().await; + Ok(app_state.state) +} + +#[tauri::command] +pub async fn connect(state: State<'_, SharedAppState>) -> Result { + debug!("connect"); + let mut app_state = state.lock().await; + let ConnectionState::Disconnected = app_state.state else { + return Err(CommandError::CallerError(format!( + "cannot connect from state {:?}", + app_state.state + ))); + }; + + // TODO fake some delay to establish connection + let app_state_cloned = state.inner().clone(); + let task = tokio::spawn(async move { + sleep(Duration::from_secs(2)).await; + trace!("connected"); + app_state_cloned.lock().await.state = ConnectionState::Connected; + }); + + let _ = task.await; + + app_state.state = ConnectionState::Connecting; + Ok(app_state.state) +} + +#[instrument] +#[tauri::command] +pub async fn disconnect(state: State<'_, SharedAppState>) -> Result { + debug!("disconnect"); + let mut app_state = state.lock().await; + let ConnectionState::Connected = app_state.state else { + return Err(CommandError::CallerError(format!( + "cannot disconnect from state {:?}", + app_state.state + ))); + }; + + app_state.state = ConnectionState::Disconnecting; + Ok(app_state.state) +} diff --git a/nym-vpn/ui/src-tauri/src/commands/mod.rs b/nym-vpn/ui/src-tauri/src/commands/mod.rs new file mode 100644 index 0000000000..c28bd8ce47 --- /dev/null +++ b/nym-vpn/ui/src-tauri/src/commands/mod.rs @@ -0,0 +1,11 @@ +use tracing::{debug, instrument}; + +pub mod connection; +pub mod settings; + +#[instrument] +#[tauri::command] +pub fn greet(name: &str) -> String { + debug!("greet"); + format!("Hello, {}! You've been greeted from Rust!", name) +} diff --git a/nym-vpn/ui/src-tauri/src/commands/settings.rs b/nym-vpn/ui/src-tauri/src/commands/settings.rs new file mode 100644 index 0000000000..ee9701ea9e --- /dev/null +++ b/nym-vpn/ui/src-tauri/src/commands/settings.rs @@ -0,0 +1,32 @@ +use tauri::State; +use tracing::{debug, instrument}; + +use crate::{error::CommandError, fs::data::AppData, states::SharedAppData}; + +#[instrument] +#[tauri::command] +pub async fn save_user_settings(state: State<'_, SharedAppData>) -> Result<(), CommandError> { + debug!("save_user_settings"); + let app_data = state.lock().await; + app_data + .write() + .map_err(|e| CommandError::InternalError(e.to_string()))?; + + Ok(()) +} + +#[instrument] +#[tauri::command] +pub async fn set_user_settings( + state: State<'_, SharedAppData>, + settings: AppData, +) -> Result<(), CommandError> { + debug!("set_user_settings"); + let mut app_data = state.lock().await; + app_data.data = settings; + app_data + .write() + .map_err(|e| CommandError::InternalError(e.to_string()))?; + + Ok(()) +} diff --git a/nym-vpn/ui/src-tauri/src/error.rs b/nym-vpn/ui/src-tauri/src/error.rs new file mode 100644 index 0000000000..34f57b66b2 --- /dev/null +++ b/nym-vpn/ui/src-tauri/src/error.rs @@ -0,0 +1,12 @@ +use serde::{Deserialize, Serialize}; +use thiserror::Error; + +#[derive(Error, Debug, Serialize, Deserialize)] +pub enum CommandError { + #[error("internal error: `{0}`")] + InternalError(String), + #[error("caller error: `{0}`")] + CallerError(String), + #[error("unknown error")] + Unknown, +} diff --git a/nym-vpn/ui/src-tauri/src/fs/cache.rs b/nym-vpn/ui/src-tauri/src/fs/cache.rs new file mode 100644 index 0000000000..64bc676b0f --- /dev/null +++ b/nym-vpn/ui/src-tauri/src/fs/cache.rs @@ -0,0 +1,6 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Default, Serialize, Deserialize, Debug, Clone)] +pub struct AppCache { + // TODO +} diff --git a/nym-vpn/ui/src-tauri/src/fs/config.rs b/nym-vpn/ui/src-tauri/src/fs/config.rs new file mode 100644 index 0000000000..3755d701a8 --- /dev/null +++ b/nym-vpn/ui/src-tauri/src/fs/config.rs @@ -0,0 +1,6 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Default, Serialize, Deserialize, Debug, Clone)] +pub struct AppConfig { + // TODO +} diff --git a/nym-vpn/ui/src-tauri/src/fs/data.rs b/nym-vpn/ui/src-tauri/src/fs/data.rs new file mode 100644 index 0000000000..cbc177b281 --- /dev/null +++ b/nym-vpn/ui/src-tauri/src/fs/data.rs @@ -0,0 +1,13 @@ +use serde::{Deserialize, Serialize}; + +use crate::states::app::{NodeConfig, PrivacyMode}; + +#[derive(Default, Serialize, Deserialize, Debug, Clone)] +pub struct AppData { + pub monitoring: Option, + pub autoconnect: Option, + pub killswitch: Option, + pub privacy_mode: Option, + pub entry_node: Option, + pub exit_node: Option, +} diff --git a/nym-vpn/ui/src-tauri/src/fs/log.rs b/nym-vpn/ui/src-tauri/src/fs/log.rs new file mode 100644 index 0000000000..7401d4e653 --- /dev/null +++ b/nym-vpn/ui/src-tauri/src/fs/log.rs @@ -0,0 +1,6 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Default, Serialize, Deserialize, Debug, Clone)] +pub struct AppLog { + // TODO +} diff --git a/nym-vpn/ui/src-tauri/src/fs/mod.rs b/nym-vpn/ui/src-tauri/src/fs/mod.rs new file mode 100644 index 0000000000..429f48cc99 --- /dev/null +++ b/nym-vpn/ui/src-tauri/src/fs/mod.rs @@ -0,0 +1,4 @@ +pub mod config; +pub mod data; +pub mod log; +pub mod storage; diff --git a/nym-vpn/ui/src-tauri/src/fs/storage.rs b/nym-vpn/ui/src-tauri/src/fs/storage.rs new file mode 100644 index 0000000000..70a2e6287b --- /dev/null +++ b/nym-vpn/ui/src-tauri/src/fs/storage.rs @@ -0,0 +1,84 @@ +use anyhow::{anyhow, Context, Result}; +use serde::{de::DeserializeOwned, Serialize}; +use std::{fmt, fs, path::PathBuf, str}; +use tauri::api::path::data_dir; +use tracing::{debug, error, instrument}; + +#[derive(Debug, Clone)] +pub struct AppStorage +where + T: Serialize + DeserializeOwned + Default + fmt::Debug, +{ + pub data: T, + pub dir_path: PathBuf, + pub filename: String, + pub full_path: PathBuf, +} + +fn create_directory_path(path: &PathBuf) -> Result<()> { + let mut data_dir = data_dir().ok_or(anyhow!( + "Failed to retrieve data directory {:?}", + path.display() + ))?; + data_dir.push(path); + + fs::create_dir_all(&data_dir).context(format!( + "Failed to create data directory {}", + data_dir.display() + )) +} + +impl AppStorage +where + T: Serialize + DeserializeOwned + Default + fmt::Debug, +{ + pub fn new(dir_path: PathBuf, filename: &str, data: Option) -> Self { + let mut full_path = dir_path.clone(); + full_path.push(filename); + + Self { + data: data.unwrap_or_default(), + dir_path, + filename: filename.to_owned(), + full_path, + } + } + + #[instrument] + pub fn read(&self) -> Result { + // create the full directory path if it is missing + create_directory_path(&self.dir_path)?; + + debug!("reading stored data from {}", self.full_path.display()); + let content = fs::read(&self.full_path).context(format!( + "Failed to read data from {}", + self.full_path.display() + ))?; + + toml::from_str::(str::from_utf8(&content)?).map_err(|e| { + error!("{e}"); + anyhow!("{e}") + }) + } + + #[instrument] + pub fn write(&self) -> Result<()> { + // create the full directory path if it is missing + create_directory_path(&self.dir_path)?; + + debug!("writing data to {}", self.full_path.display()); + let toml = toml::to_string(&self.data)?; + fs::write(&self.full_path, toml)?; + Ok(()) + } + + #[instrument] + pub fn clear(&self) -> Result<()> { + // create the full directory path if it is missing + create_directory_path(&self.dir_path)?; + + debug!("clearing data {}", self.full_path.display()); + fs::write(&self.full_path, vec![])?; + Ok(()) + } +} diff --git a/nym-vpn/ui/src-tauri/src/main.rs b/nym-vpn/ui/src-tauri/src/main.rs index 523550d3b7..b690e8e804 100644 --- a/nym-vpn/ui/src-tauri/src/main.rs +++ b/nym-vpn/ui/src-tauri/src/main.rs @@ -1,15 +1,64 @@ // Prevents additional console window on Windows in release, DO NOT REMOVE!! #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] -// Learn more about Tauri commands at https://tauri.app/v1/guides/features/command -#[tauri::command] -fn greet(name: &str) -> String { - format!("Hello, {}! You've been greeted from Rust!", name) -} +use std::sync::Arc; + +use anyhow::anyhow; +use anyhow::Result; +use tauri::api::path::{config_dir, data_dir}; +use tokio::sync::Mutex; +use tracing::info; + +mod commands; +mod error; +mod fs; +mod states; + +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_DATA_FILE: &str = "app-data.toml"; +const APP_CONFIG_FILE: &str = "config.toml"; + +fn main() -> Result<()> { + dotenvy::dotenv()?; + + // uses RUST_LOG value for logging level + // eg. RUST_LOG=tauri=debug,nymvpn_ui=trace + tracing_subscriber::fmt::init(); + + 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::::new(app_data_path, APP_DATA_FILE, None); + + 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::::new(app_config_path, APP_CONFIG_FILE, None); + + info!("Starting tauri app"); -fn main() { tauri::Builder::default() - .invoke_handler(tauri::generate_handler![greet]) + .manage(Arc::new(Mutex::new(AppState::default()))) + .manage(Arc::new(Mutex::new(app_data_store))) + .manage(Arc::new(Mutex::new(app_config_store))) + .setup(|_app| { + info!("app setup"); + Ok(()) + }) + .invoke_handler(tauri::generate_handler![ + greet, + connection::get_connection_state, + connection::connect, + connection::disconnect, + settings::save_user_settings, + settings::set_user_settings, + ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); + Ok(()) } diff --git a/nym-vpn/ui/src-tauri/src/states/app.rs b/nym-vpn/ui/src-tauri/src/states/app.rs new file mode 100644 index 0000000000..6354409d92 --- /dev/null +++ b/nym-vpn/ui/src-tauri/src/states/app.rs @@ -0,0 +1,44 @@ +use serde::{Deserialize, Serialize}; +use ts_rs::TS; + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct NodeConfig { + pub id: String, + pub country: String, +} + +#[derive(Default, Debug, Clone, Copy, Serialize, Deserialize, TS)] +#[ts(export)] +pub enum ConnectionState { + Connected, + #[default] + Disconnected, + Connecting, + Disconnecting, + Error, +} + +#[derive(Default, Debug, Serialize, Deserialize, TS, Clone)] +#[ts(export)] +pub enum PrivacyMode { + High, + Medium, + #[default] + Low, +} + +#[derive(Debug, Serialize, Deserialize, TS)] +#[ts(export)] +pub struct TunnelConfig { + pub id: String, + pub name: String, +} + +#[derive(Debug, Default)] +pub struct AppState { + pub state: ConnectionState, + pub privacy_mode: PrivacyMode, + pub entry_node: Option, + pub exit_node: Option, + pub tunnel: Option, +} diff --git a/nym-vpn/ui/src-tauri/src/states/mod.rs b/nym-vpn/ui/src-tauri/src/states/mod.rs new file mode 100644 index 0000000000..b02b90be7b --- /dev/null +++ b/nym-vpn/ui/src-tauri/src/states/mod.rs @@ -0,0 +1,11 @@ +use std::sync::Arc; + +use tokio::sync::Mutex; + +use crate::fs::{config::AppConfig, data::AppData, storage::AppStorage}; + +pub mod app; + +pub type SharedAppState = Arc>; +pub type SharedAppData = Arc>>; +pub type SharedAppConfig = Arc>>; diff --git a/nym-vpn/ui/src-tauri/tauri.conf.json b/nym-vpn/ui/src-tauri/tauri.conf.json index a1983b2f27..059b30d70d 100644 --- a/nym-vpn/ui/src-tauri/tauri.conf.json +++ b/nym-vpn/ui/src-tauri/tauri.conf.json @@ -29,7 +29,7 @@ "bundle": { "active": true, "targets": "all", - "identifier": "com.tauri.dev", + "identifier": "net.nymtech.vpn", "icon": [ "icons/32x32.png", "icons/128x128.png", @@ -46,8 +46,8 @@ "fullscreen": false, "resizable": true, "title": "NymVPN", - "width": 800, - "height": 600 + "width": 200, + "height": 200 } ] }