diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index a2e72a1cfc..b3945372ff 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -81,7 +81,7 @@ jobs: uses: actions-rs/cargo@v1 with: command: test - args: --workspace --all-features -- --ignored + args: --workspace -- --ignored - uses: actions-rs/clippy-check@v1 name: Clippy checks diff --git a/.github/workflows/wallet.yml b/.github/workflows/wallet.yml index a2ffef460e..896b3103c0 100644 --- a/.github/workflows/wallet.yml +++ b/.github/workflows/wallet.yml @@ -22,6 +22,7 @@ jobs: steps: - name: Install Dependencies (Linux) run: sudo apt-get update && sudo apt-get -y install libwebkit2gtk-4.0-dev build-essential curl wget libssl-dev libgtk-3-dev squashfs-tools + continue-on-error: true - name: Check out repository code uses: actions/checkout@v2 diff --git a/Cargo.lock b/Cargo.lock index c4c4b6701a..30841aab70 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2169,6 +2169,7 @@ dependencies = [ "gateway-requests", "getrandom 0.2.8", "log", + "mobile-storage", "network-defaults", "nymsphinx", "pemstore", @@ -3274,6 +3275,14 @@ dependencies = [ "version-checker", ] +[[package]] +name = "mobile-storage" +version = "0.1.0" +dependencies = [ + "async-trait", + "thiserror", +] + [[package]] name = "multer" version = "2.0.4" @@ -3829,6 +3838,7 @@ dependencies = [ "lazy_static", "log", "logging", + "mobile-storage", "network-defaults", "nymsphinx", "ordered-buffer", diff --git a/Cargo.toml b/Cargo.toml index d0cd902eb0..8dca8bb3eb 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -38,6 +38,7 @@ members = [ "common/cosmwasm-smart-contracts/mixnet-contract", "common/cosmwasm-smart-contracts/multisig-contract", "common/cosmwasm-smart-contracts/vesting-contract", + "common/mobile-storage", "common/credential-storage", "common/credentials", "common/crypto", @@ -95,7 +96,7 @@ default-members = [ "explorer-api", ] -exclude = ["explorer", "contracts", "clients/webassembly", "nym-wallet", "nym-connect"] +exclude = ["explorer", "contracts", "clients/webassembly", "nym-wallet", "nym-connect", "nym-connect-android"] [workspace.package] authors = ["Nym Technologies SA"] diff --git a/clients/client-core/src/client/base_client/helpers.rs b/clients/client-core/src/client/base_client/helpers.rs new file mode 100644 index 0000000000..21485420bc --- /dev/null +++ b/clients/client-core/src/client/base_client/helpers.rs @@ -0,0 +1,11 @@ +// Copyright 2023 - Nym Technologies SA +// SPDX-License-Identifier: Apache-2.0 +// +use crate::{client::replies::reply_storage, config::DebugConfig}; + +pub fn setup_empty_reply_surb_backend(debug_config: &DebugConfig) -> reply_storage::Empty { + reply_storage::Empty { + min_surb_threshold: debug_config.minimum_reply_surb_storage_threshold, + max_surb_threshold: debug_config.maximum_reply_surb_storage_threshold, + } +} diff --git a/clients/client-core/src/client/base_client/mod.rs b/clients/client-core/src/client/base_client/mod.rs index e30c39affa..67d7661434 100644 --- a/clients/client-core/src/client/base_client/mod.rs +++ b/clients/client-core/src/client/base_client/mod.rs @@ -49,6 +49,8 @@ use super::received_buffer::ReceivedBufferMessage; #[cfg(all(not(target_arch = "wasm32"), feature = "fs-surb-storage"))] pub mod non_wasm_helpers; +pub mod helpers; + pub struct ClientInput { pub connection_command_sender: ConnectionCommandSender, pub input_sender: InputMessageSender, diff --git a/clients/client-core/src/client/base_client/non_wasm_helpers.rs b/clients/client-core/src/client/base_client/non_wasm_helpers.rs index efd7d41bf0..9dde32f9d2 100644 --- a/clients/client-core/src/client/base_client/non_wasm_helpers.rs +++ b/clients/client-core/src/client/base_client/non_wasm_helpers.rs @@ -2,7 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 use crate::client::replies::reply_storage::{ - self, fs_backend, CombinedReplyStorage, ReplyStorageBackend, + fs_backend, CombinedReplyStorage, ReplyStorageBackend, }; use crate::config::DebugConfig; use crate::error::ClientCoreError; @@ -98,10 +98,3 @@ pub async fn setup_fs_reply_surb_backend>( Ok(setup_inactive_backend(debug_config)) } } - -pub fn setup_empty_reply_surb_backend(debug_config: &DebugConfig) -> reply_storage::Empty { - reply_storage::Empty { - min_surb_threshold: debug_config.minimum_reply_surb_storage_threshold, - max_surb_threshold: debug_config.maximum_reply_surb_storage_threshold, - } -} diff --git a/clients/socks5/Cargo.toml b/clients/socks5/Cargo.toml index 46d71c1ab5..c559c6351a 100644 --- a/clients/socks5/Cargo.toml +++ b/clients/socks5/Cargo.toml @@ -33,7 +33,8 @@ client-connections = { path = "../../common/client-connections" } coconut-interface = { path = "../../common/coconut-interface" } config = { path = "../../common/config" } completions = { path = "../../common/completions" } -credential-storage = { path = "../../common/credential-storage" } +credential-storage = { path = "../../common/credential-storage", optional = true } +mobile-storage = { path = "../../common/mobile-storage", optional = true } credentials = { path = "../../common/credentials" } crypto = { path = "../../common/crypto" } logging = { path = "../../common/logging"} @@ -51,4 +52,6 @@ validator-client = { path = "../../common/client-libs/validator-client", feature version-checker = { path = "../../common/version-checker" } [features] +default = ["credential-storage"] eth = [] +mobile = ["mobile-storage", "gateway-client/mobile"] diff --git a/clients/socks5/src/client/config/mod.rs b/clients/socks5/src/client/config/mod.rs index 346eb37b34..8499637c5e 100644 --- a/clients/socks5/src/client/config/mod.rs +++ b/clients/socks5/src/client/config/mod.rs @@ -18,7 +18,7 @@ mod template; const DEFAULT_CONNECTION_START_SURBS: u32 = 20; const DEFAULT_PER_REQUEST_SURBS: u32 = 3; -#[derive(Debug, Default, Deserialize, PartialEq, Serialize)] +#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize)] #[serde(deny_unknown_fields)] pub struct Config { #[serde(flatten)] @@ -36,10 +36,12 @@ impl NymConfig for Config { } fn default_root_directory() -> PathBuf { - dirs::home_dir() - .expect("Failed to evaluate $HOME value") - .join(".nym") - .join("socks5-clients") + #[cfg(not(feature = "mobile"))] + let base_dir = dirs::home_dir().expect("Failed to evaluate $HOME value"); + #[cfg(feature = "mobile")] + let base_dir = PathBuf::from("/tmp"); + + base_dir.join(".nym").join("socks5-clients") } fn try_default_root_directory() -> Option { @@ -176,7 +178,7 @@ impl Config { } } -#[derive(Debug, Deserialize, PartialEq, Eq, Serialize)] +#[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize)] #[serde(deny_unknown_fields)] pub struct Socks5 { /// The port on which the client will be listening for incoming requests @@ -214,7 +216,7 @@ impl Default for Socks5 { } } -#[derive(Debug, Deserialize, PartialEq, Eq, Serialize)] +#[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize)] #[serde(deny_unknown_fields)] pub struct Socks5Debug { /// Number of reply SURBs attached to each `Request::Connect` message. diff --git a/clients/socks5/src/client/mod.rs b/clients/socks5/src/client/mod.rs index 1acea2eed8..ca6348cc45 100644 --- a/clients/socks5/src/client/mod.rs +++ b/clients/socks5/src/client/mod.rs @@ -8,9 +8,12 @@ use crate::socks::{ authentication::{AuthenticationMethods, Authenticator, User}, server::SphinxSocksServer, }; -use client_core::client::base_client::{ - non_wasm_helpers, BaseClientBuilder, ClientInput, ClientOutput, ClientState, -}; + +#[cfg(feature = "mobile")] +use client_core::client::base_client::helpers::setup_empty_reply_surb_backend; +#[cfg(not(feature = "mobile"))] +use client_core::client::base_client::non_wasm_helpers; +use client_core::client::base_client::{BaseClientBuilder, ClientInput, ClientOutput, ClientState}; use client_core::client::key_manager::KeyManager; use client_core::config::persistence::key_pathfinder::ClientKeyPathfinder; use futures::channel::mpsc; @@ -54,6 +57,18 @@ impl NymClient { } } + pub fn new_with_keys(config: Config, key_manager: Option) -> Self { + let key_manager = key_manager.unwrap_or_else(|| { + let pathfinder = ClientKeyPathfinder::new_from_config(config.get_base()); + KeyManager::load_keys(&pathfinder).expect("failed to load stored keys") + }); + + NymClient { + config, + key_manager, + } + } + async fn create_bandwidth_controller(config: &Config) -> BandwidthController { let details = network_defaults::NymNetworkDetails::new_from_env(); let mut client_config = validator_client::Config::try_from_nym_network_details(&details) @@ -72,10 +87,15 @@ impl NymClient { client_config = client_config.with_urls(nyxd_url, api_url); let client = validator_client::Client::new_query(client_config) .expect("Could not construct query client"); - BandwidthController::new( - credential_storage::initialise_storage(config.get_base().get_database_path()).await, - client, - ) + + #[cfg(not(feature = "mobile"))] + let storage = + credential_storage::initialise_storage(config.get_base().get_database_path()).await; + + #[cfg(feature = "mobile")] + let storage = mobile_storage::PersistentStorage {}; + + BandwidthController::new(storage, client) } fn start_socks5_listener( @@ -188,15 +208,22 @@ impl NymClient { } pub async fn start(self) -> Result { + #[cfg(not(feature = "mobile"))] + let fs_reply_surb_backend = non_wasm_helpers::setup_fs_reply_surb_backend( + Some(self.config.get_base().get_reply_surb_database_path()), + self.config.get_debug_settings(), + ) + .await?; + + #[cfg(feature = "mobile")] + let fs_reply_surb_backend = + setup_empty_reply_surb_backend(self.config.get_debug_settings()); + let base_builder = BaseClientBuilder::new_from_base_config( self.config.get_base(), self.key_manager, Some(Self::create_bandwidth_controller(&self.config).await), - non_wasm_helpers::setup_fs_reply_surb_backend( - Some(self.config.get_base().get_reply_surb_database_path()), - self.config.get_debug_settings(), - ) - .await?, + fs_reply_surb_backend, ); let self_address = base_builder.as_mix_recipient(); diff --git a/common/client-libs/gateway-client/Cargo.toml b/common/client-libs/gateway-client/Cargo.toml index 86c76ad169..97014fc999 100644 --- a/common/client-libs/gateway-client/Cargo.toml +++ b/common/client-libs/gateway-client/Cargo.toml @@ -28,6 +28,7 @@ pemstore = { path = "../../pemstore" } validator-client = { path = "../validator-client" } task = { path = "../../task" } serde = { version = "1.0", features = ["derive"]} +mobile-storage = { path = "../../mobile-storage" } [dependencies.tungstenite] @@ -80,3 +81,4 @@ features = ["js"] [features] wasm = [] +mobile = [] diff --git a/common/client-libs/gateway-client/src/bandwidth.rs b/common/client-libs/gateway-client/src/bandwidth.rs index 9b6e5929eb..d2b5406836 100644 --- a/common/client-libs/gateway-client/src/bandwidth.rs +++ b/common/client-libs/gateway-client/src/bandwidth.rs @@ -6,12 +6,22 @@ use crate::error::GatewayClientError; #[cfg(target_arch = "wasm32")] use crate::wasm_mockups::Storage; #[cfg(not(target_arch = "wasm32"))] +#[cfg(not(feature = "mobile"))] use credential_storage::storage::Storage; +#[cfg(not(target_arch = "wasm32"))] +#[cfg(feature = "mobile")] +use mobile_storage::Storage; + +#[cfg(not(target_arch = "wasm32"))] +#[cfg(feature = "mobile")] +use mobile_storage::StorageError; + #[cfg(target_arch = "wasm32")] use crate::wasm_mockups::StorageError; #[cfg(not(target_arch = "wasm32"))] +#[cfg(not(feature = "mobile"))] use credential_storage::error::StorageError; #[cfg(target_arch = "wasm32")] @@ -31,8 +41,13 @@ use { use crate::wasm_mockups::PersistentStorage; #[cfg(not(target_arch = "wasm32"))] +#[cfg(not(feature = "mobile"))] use credential_storage::PersistentStorage; +#[cfg(not(target_arch = "wasm32"))] +#[cfg(feature = "mobile")] +use mobile_storage::PersistentStorage; + #[derive(Clone)] #[allow(dead_code)] pub struct BandwidthController { diff --git a/common/client-libs/gateway-client/src/client.rs b/common/client-libs/gateway-client/src/client.rs index e15b886013..8ecaa532de 100644 --- a/common/client-libs/gateway-client/src/client.rs +++ b/common/client-libs/gateway-client/src/client.rs @@ -26,13 +26,19 @@ use std::time::Duration; use task::TaskClient; use tungstenite::protocol::Message; -#[cfg(not(target_arch = "wasm32"))] -use credential_storage::PersistentStorage; #[cfg(not(target_arch = "wasm32"))] use tokio_tungstenite::connect_async; #[cfg(not(target_arch = "wasm32"))] use validator_client::nyxd::CosmWasmClient; +#[cfg(not(target_arch = "wasm32"))] +#[cfg(not(feature = "mobile"))] +use credential_storage::PersistentStorage; + +#[cfg(not(target_arch = "wasm32"))] +#[cfg(feature = "mobile")] +use mobile_storage::PersistentStorage; + #[cfg(target_arch = "wasm32")] use crate::wasm_mockups::CosmWasmClient; #[cfg(target_arch = "wasm32")] diff --git a/common/client-libs/gateway-client/src/error.rs b/common/client-libs/gateway-client/src/error.rs index 82664c8fd8..7080ce7723 100644 --- a/common/client-libs/gateway-client/src/error.rs +++ b/common/client-libs/gateway-client/src/error.rs @@ -3,9 +3,13 @@ #[cfg(target_arch = "wasm32")] use crate::wasm_mockups::StorageError; +#[cfg(not(feature = "mobile"))] #[cfg(not(target_arch = "wasm32"))] use credential_storage::error::StorageError; use gateway_requests::registration::handshake::error::HandshakeError; +#[cfg(feature = "mobile")] +#[cfg(not(target_arch = "wasm32"))] +use mobile_storage::StorageError; use std::io; use thiserror::Error; use tungstenite::Error as WsError; diff --git a/common/mobile-storage/Cargo.toml b/common/mobile-storage/Cargo.toml new file mode 100644 index 0000000000..3c38de8d2d --- /dev/null +++ b/common/mobile-storage/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "mobile-storage" +version = "0.1.0" +edition = "2021" + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[dependencies] +async-trait = { version = "0.1.51" } +thiserror = "1.0" + diff --git a/common/mobile-storage/src/lib.rs b/common/mobile-storage/src/lib.rs new file mode 100644 index 0000000000..0de91b0ec4 --- /dev/null +++ b/common/mobile-storage/src/lib.rs @@ -0,0 +1,67 @@ +// Copyright 2022 - Nym Technologies SA +// SPDX-License-Identifier: Apache-2.0 + +use async_trait::async_trait; +use thiserror::Error; + +#[derive(Error, Debug)] +pub enum StorageError { + #[error("Android is not yet supported")] + AndroidNotSupported, + + #[allow(dead_code)] + #[error("Code shouldn't reach this point")] + InconsistentData, +} + +#[derive(Clone)] +pub struct PersistentStorage {} + +pub struct CoconutCredential { + pub id: i64, + pub voucher_value: String, + pub voucher_info: String, + pub serial_number: String, + pub binding_number: String, + pub signature: String, + pub epoch_id: String, + pub consumed: bool, +} + +#[async_trait] +pub trait Storage: Send + Sync { + async fn insert_coconut_credential( + &self, + voucher_value: String, + voucher_info: String, + serial_number: String, + binding_number: String, + signature: String, + ) -> Result<(), StorageError>; + + async fn get_next_coconut_credential(&self) -> Result; + + async fn consume_coconut_credential(&self, id: i64) -> Result<(), StorageError>; +} + +#[async_trait] +impl Storage for PersistentStorage { + async fn insert_coconut_credential( + &self, + _voucher_value: String, + _voucher_info: String, + _serial_number: String, + _binding_number: String, + _signature: String, + ) -> Result<(), StorageError> { + Err(StorageError::AndroidNotSupported) + } + + async fn get_next_coconut_credential(&self) -> Result { + Err(StorageError::AndroidNotSupported) + } + + async fn consume_coconut_credential(&self, _id: i64) -> Result<(), StorageError> { + Err(StorageError::AndroidNotSupported) + } +} diff --git a/lerna.json b/lerna.json index a2393382ed..702d14944f 100644 --- a/lerna.json +++ b/lerna.json @@ -3,6 +3,7 @@ "ts-packages/*", "nym-wallet", "nym-connect", + "nym-connect-android", "sdk/typescript/**" ], "version": "0.0.0" diff --git a/nym-connect-android/.DS_Store b/nym-connect-android/.DS_Store new file mode 100644 index 0000000000..7bf1d03999 Binary files /dev/null and b/nym-connect-android/.DS_Store differ diff --git a/nym-connect-android/.babelrc b/nym-connect-android/.babelrc new file mode 100644 index 0000000000..7a840ad24e --- /dev/null +++ b/nym-connect-android/.babelrc @@ -0,0 +1,15 @@ +{ + "presets": [ + [ + "@babel/preset-env", + { + "targets": { + "esmodules": true + } + } + ], + "@babel/preset-react", + "@babel/preset-typescript" + ], + "plugins": ["@babel/plugin-transform-async-to-generator"] +} diff --git a/nym-connect-android/.env.sample b/nym-connect-android/.env.sample new file mode 100644 index 0000000000..cc3febee19 --- /dev/null +++ b/nym-connect-android/.env.sample @@ -0,0 +1 @@ +ADMIN_ADDRESS= \ No newline at end of file diff --git a/nym-connect-android/.eslintrc b/nym-connect-android/.eslintrc new file mode 100644 index 0000000000..3ea1281af1 --- /dev/null +++ b/nym-connect-android/.eslintrc @@ -0,0 +1,8 @@ +{ + "extends": [ + "@nymproject/eslint-config-react-typescript" + ], + "parserOptions": { + "project": "./tsconfig.eslint.json" + } +} diff --git a/nym-connect-android/.nvmrc b/nym-connect-android/.nvmrc new file mode 100644 index 0000000000..da2d3988d7 --- /dev/null +++ b/nym-connect-android/.nvmrc @@ -0,0 +1 @@ +14 \ No newline at end of file diff --git a/nym-connect-android/.prettierrc b/nym-connect-android/.prettierrc new file mode 100644 index 0000000000..1bc34d0c3f --- /dev/null +++ b/nym-connect-android/.prettierrc @@ -0,0 +1,7 @@ +{ + "trailingComma": "all", + "singleQuote": true, + "printWidth": 120, + "tabWidth": 2, + "semi": true +} diff --git a/nym-connect-android/.storybook/main.js b/nym-connect-android/.storybook/main.js new file mode 100644 index 0000000000..344c27778e --- /dev/null +++ b/nym-connect-android/.storybook/main.js @@ -0,0 +1,73 @@ +/* eslint-disable no-param-reassign */ +const TsconfigPathsPlugin = require('tsconfig-paths-webpack-plugin'); +const ForkTsCheckerWebpackPlugin = require('fork-ts-checker-webpack-plugin'); + +module.exports = { + stories: ['../src/**/*.stories.mdx', '../src/**/*.stories.@(js|jsx|ts|tsx)'], + addons: ['@storybook/addon-links', '@storybook/addon-essentials', '@storybook/addon-interactions'], + framework: '@storybook/react', + core: { + builder: 'webpack5', + }, + typescript: { reactDocgen: false }, + // webpackFinal: async (config, { configType }) => { + // // `configType` has a value of 'DEVELOPMENT' or 'PRODUCTION' + // // You can change the configuration based on that. + // // 'PRODUCTION' is used when building the static version of storybook. + webpackFinal: async (config) => { + config.module.rules.forEach((rule) => { + // look for SVG import rule and replace + // NOTE: the rule before modification is /\.(svg|ico|jpg|jpeg|png|apng|gif|eot|otf|webp|ttf|woff|woff2|cur|ani|pdf)(\?.*)?$/ + if (rule.test?.toString().includes('svg')) { + rule.test = /\.(ico|jpg|jpeg|png|apng|gif|eot|otf|webp|ttf|woff|woff2|cur|ani|pdf)(\?.*)?$/; + } + }); + + // handle asset loading with this + config.module.rules.unshift({ + test: /\.svg(\?.*)?$/i, + issuer: /\.[jt]sx?$/, + use: ['@svgr/webpack'], + }); + + config.module.rules.unshift({ + test: /\.ya?ml$/, + type: 'json', + use: [ + { + loader: 'yaml-loader', + options: { + asJSON: true, + }, + }, + ], + }); + + config.resolve.extensions = ['.tsx', '.ts', '.js']; + config.resolve.plugins = [new TsconfigPathsPlugin()]; + + config.plugins.push( + new ForkTsCheckerWebpackPlugin({ + typescript: { + mode: 'write-references', + diagnosticOptions: { + semantic: true, + syntactic: true, + }, + }, + }), + ); + + if (!config.resolve.alias) { + config.resolve.alias = {}; + } + + config.resolve.alias['@tauri-apps/api'] = `${__dirname}/mocks/tauri`; + + // Return the altered config + return config; + }, + features: { + emotionAlias: false, + }, +}; diff --git a/nym-connect-android/.storybook/mocks/tauri/app.js b/nym-connect-android/.storybook/mocks/tauri/app.js new file mode 100644 index 0000000000..83d80d7037 --- /dev/null +++ b/nym-connect-android/.storybook/mocks/tauri/app.js @@ -0,0 +1,8 @@ +/** + * This is a mock for Tauri's API package (@tauri-apps/api/app), to prevent stories from being excluded, because they either use + * or import dependencies that use Tauri. + */ + +module.exports = { + getVersion: () => undefined, +}; diff --git a/nym-connect-android/.storybook/mocks/tauri/clipboard.js b/nym-connect-android/.storybook/mocks/tauri/clipboard.js new file mode 100644 index 0000000000..a34847db86 --- /dev/null +++ b/nym-connect-android/.storybook/mocks/tauri/clipboard.js @@ -0,0 +1,8 @@ +/** + * This is a mock for Tauri's API package (@tauri-apps/api/clipboard), to prevent stories from being excluded, because they either use + * or import dependencies that use Tauri. + */ + +module.exports = { + writeText: async () => undefined, +} diff --git a/nym-connect-android/.storybook/mocks/tauri/event.js b/nym-connect-android/.storybook/mocks/tauri/event.js new file mode 100644 index 0000000000..a100d30337 --- /dev/null +++ b/nym-connect-android/.storybook/mocks/tauri/event.js @@ -0,0 +1,8 @@ +/** + * This is a mock for Tauri's API package (@tauri-apps/api/clipboard), to prevent stories from being excluded, because they either use + * or import dependencies that use Tauri. + */ + +module.exports = { + listen: async () => undefined, +} diff --git a/nym-connect-android/.storybook/mocks/tauri/index.js b/nym-connect-android/.storybook/mocks/tauri/index.js new file mode 100644 index 0000000000..c411c3bf18 --- /dev/null +++ b/nym-connect-android/.storybook/mocks/tauri/index.js @@ -0,0 +1,14 @@ +/** + * This is a mock for Tauri's API package (@tauri-apps/api), to prevent stories from being excluded, because they either use + * or import dependencies that use Tauri. + */ +module.exports = { + invoke: (operation, args) => { + console.error( + `Tauri cannot be used in Storybook. The operation requested was "${operation}". You can add mock responses to "nym_connect/.storybook/mocks/tauri.js" if you need. The default response is "void".`, + ); + return new Promise((resolve, reject) => { + reject(new Error(`Tauri operation ${operation} not available in storybook.`)); + }); + }, +}; diff --git a/nym-connect-android/.storybook/mocks/tauri/notification.js b/nym-connect-android/.storybook/mocks/tauri/notification.js new file mode 100644 index 0000000000..dcbe18e574 --- /dev/null +++ b/nym-connect-android/.storybook/mocks/tauri/notification.js @@ -0,0 +1,10 @@ +/** + * This is a mock for Tauri's API package (@tauri-apps/api/notification), to prevent stories from being excluded, because they either use + * or import dependencies that use Tauri. + */ + +module.exports = { + isPermissionGranted: () => undefined, + requestPermission: () => undefined, + sendNotification: () => undefined, +}; \ No newline at end of file diff --git a/nym-connect-android/.storybook/mocks/tauri/window.js b/nym-connect-android/.storybook/mocks/tauri/window.js new file mode 100644 index 0000000000..9f0afeeebd --- /dev/null +++ b/nym-connect-android/.storybook/mocks/tauri/window.js @@ -0,0 +1,10 @@ +/** + * This is a mock for Tauri's API package (@tauri-apps/api/window), to prevent stories from being excluded, because they either use + * or import dependencies that use Tauri. + */ + +module.exports = { + appWindow: { + maximize: () => undefined, + } +} diff --git a/nym-connect-android/.storybook/preview-fonts.js b/nym-connect-android/.storybook/preview-fonts.js new file mode 100644 index 0000000000..a7989fca9a --- /dev/null +++ b/nym-connect-android/.storybook/preview-fonts.js @@ -0,0 +1,3 @@ +import '../src/fonts/fonts.css'; + +export const Fonts = ({ children }) => <>{children}; \ No newline at end of file diff --git a/nym-connect-android/.storybook/preview.js b/nym-connect-android/.storybook/preview.js new file mode 100644 index 0000000000..9c14c3ec99 --- /dev/null +++ b/nym-connect-android/.storybook/preview.js @@ -0,0 +1,26 @@ +import { NymMixnetTheme } from '../src/theme'; +import { Fonts } from './preview-fonts'; +import { MockProvider } from '../src/context/mocks/main'; +const withThemeProvider = (Story, context) => { + return ( + + + + + + + + ); +}; + +export const decorators = [withThemeProvider]; + +export const parameters = { + actions: { argTypesRegex: '^on[A-Z].*' }, + controls: { + matchers: { + color: /(background|color)$/i, + date: /Date$/, + }, + }, +}; diff --git a/nym-connect-android/CHANGELOG.md b/nym-connect-android/CHANGELOG.md new file mode 100644 index 0000000000..cddba036e0 --- /dev/null +++ b/nym-connect-android/CHANGELOG.md @@ -0,0 +1,98 @@ +# Changelog + +## UNRELEASED + +## [nym-connect-v1.1.7](https://github.com/nymtech/nym/tree/nym-connect-v1.1.7) (2023-01-24) + +- Remove test and earn ([#2865]) + +[#2865]: https://github.com/nymtech/nym/issue/2865 + +## [nym-connect-v1.1.6](https://github.com/nymtech/nym/tree/nym-connect-v1.1.6) (2023-01-17) + +- part (1) show gateway status on the UI if the gateway is not live, is overloaded or is slow ([#2824]) + +[#2824]: https://github.com/nymtech/nym/pull/2824 + +## [nym-connect-v1.1.5](https://github.com/nymtech/nym/tree/nym-connect-v1.1.5) (2023-01-10) + +- get version number from tauri and display by @fmtabbara in https://github.com/nymtech/nym/pull/2684 +- Feature/nym connect experimental software text by @fmtabbara in https://github.com/nymtech/nym/pull/2692 +- NymConnect - Display service info in tooltip **1.1.5 Release** by @fmtabbara in https://github.com/nymtech/nym/pull/2704 + +## [nym-connect-v1.1.4](https://github.com/nymtech/nym/tree/nym-connect-v1.1.4) (2022-12-20) + +This release contains the new opt-in Test & Earn program, and it uses a stress-tested directory of network requesters to improve reliability. It also has some bugfixes, performance improvements, and better error handling. + +- nym-connect: send status messages from socks5 task to tauri backend by @octol in https://github.com/nymtech/nym/pull/1882 +- socks5: rework waiting in inbound.rs by @octol in https://github.com/nymtech/nym/pull/1880 +- Test&Earn by @mmsinclair in https://github.com/nymtech/nym/pull/2729 + +## [nym-connect-v1.1.3](https://github.com/nymtech/nym/tree/nym-connect-v1.1.3) (2022-12-13) + +- socks5-client: added support for socks4a. + +## [nym-connect-v1.1.2](https://github.com/nymtech/nym/tree/nym-connect-v1.1.2) (2022-12-06) + +- socks5-client: fix error with client failing and disconnecting unnecessarily. + +## [nym-connect-v1.1.1](https://github.com/nymtech/nym/tree/nym-connect-v1.1.1) (2022-11-29) + +- socks5-client: fix multiplex concurrent connections ([#1720], [#1777]) +- socks5-client: fix wait closing inbound connection until data is sent, and throttle incoming data in general ([#1772], [#1783],[#1789]) +- socks5-client: fix shutting down all background workers if anyone of them panics or errors out. This fixes an issue where the nym-connect UI was showing connected even though the socks5 tunnel was non-functional. ([#1805]) +- gateway-libs: fix decryping messages stored on the gateway between reconnects ([#1786]) + +- nymconnect: updated UI +- nymconnect: new help area +- nymconnect: listen for service errors and display on frontend + +[#1720]: https://github.com/nymtech/nym/pull/1720 +[#1772]: https://github.com/nymtech/nym/pull/1772 +[#1777]: https://github.com/nymtech/nym/pull/1777 +[#1783]: https://github.com/nymtech/nym/pull/1783 +[#1786]: https://github.com/nymtech/nym/pull/1786 +[#1789]: https://github.com/nymtech/nym/pull/1789 +[#1805]: https://github.com/nymtech/nym/pull/1805 + +## [nym-connect-v1.1.0](https://github.com/nymtech/nym/tree/nym-connect-v1.1.0) (2022-11-09) + +- nym-connect: rework of rewarding changes the directory data structures that describe the mixnet topology ([#1472]) +- clients: add testing-only support for two more extended packet sizes (8kb and 16kb). +- native-client/socks5-client: `disable_loop_cover_traffic_stream` Debug config option to disable the separate loop cover traffic stream ([#1666]) +- native-client/socks5-client: `disable_main_poisson_packet_distribution` Debug config option to make the client ignore poisson distribution in the main packet stream and ONLY send real message (and as fast as they come) ([#1664]) +- native-client/socks5-client: `use_extended_packet_size` Debug config option to make the client use 'ExtendedPacketSize' for its traffic (32kB as opposed to 2kB in 1.0.2) ([#1671]) +- network-requester: added additional Blockstream Green wallet endpoint to `example.allowed.list` ([#1611]) +- validator-client: added `query_contract_smart` and `query_contract_raw` on `NyxdClient` ([#1558]) + +[#1472]: https://github.com/nymtech/nym/pull/1472 +[#1558]: https://github.com/nymtech/nym/pull/1558 +[#1611]: https://github.com/nymtech/nym/pull/1611 +[#1664]: https://github.com/nymtech/nym/pull/1664 +[#1666]: https://github.com/nymtech/nym/pull/1666 +[#1671]: https://github.com/nymtech/nym/pull/1671 + +## [nym-connect-v1.0.2](https://github.com/nymtech/nym/tree/nym-connect-v1.0.2) (2022-08-18) + +### Changed + +- nym-connect: "load balance" the service providers by picking a random Service Provider for each Service and storing in local storage so it remains sticky for the user ([#1540]) +- nym-connect: the ServiceProviderSelector only displays the available Services, and picks a random Service Provider for Services the user has never used before ([#1540]) +- nym-connect: add `local-forage` for storing user settings ([#1540]) + +[#1540]: https://github.com/nymtech/nym/pull/1540 + +## [nym-connect-v1.0.1](https://github.com/nymtech/nym/tree/nym-connect-v1.0.1) (2022-07-22) + +### Added + +- nym-connect: initial proof-of-concept of a UI around the socks5 client was added +- nym-connect: add ability to select network requester and gateway ([#1427]) +- nym-connect: add ability to export gateway keys as JSON +- nym-connect: add auto updater + +### Changed + +- nym-connect: reuse config id instead of creating a new id on each connection + +[#1427]: https://github.com/nymtech/nym/pull/1427 diff --git a/nym-connect-android/README.md b/nym-connect-android/README.md new file mode 100644 index 0000000000..bbd9b55bbb --- /dev/null +++ b/nym-connect-android/README.md @@ -0,0 +1,95 @@ + + +# Nym Connect + +Nym is an open-source, decentralized and permissionless privacy system. It provides full-stack privacy, allowing other applications, services or blockchains to provide their users with strong metadata protection, at both the network level (mixnet), and the application level (anonymous credentials) without the need to build privacy from scratch. + +Nym Connects sets up a SOCKS5 proxy for local applications to use. + +## Installation prerequisites - Linux / Mac + +- `Yarn` +- `NodeJS >= v16` +- `Rust & cargo` + +## Installation prerequisites - Windows + +- When running on Windows you will need to install c++ build tools +- An easy guide to get rust up and running [Installation]("http://kennykerr.ca/2019/11/18/rust-getting-started/") +- When installing NodeJS please use the `current features` version +- Using a package manager like [Chocolatey]("chocolatey.org") is recommended +- Nym connect requires you to have `Webview2` installed, please head to the [Installer](https://developer.microsoft.com/en-us/microsoft-edge/webview2/#download-section), this will ensure a smooth app launch + +## Installation + +Inside the `nym-connect` directory, run the following command: +``` +yarn install +``` + +## Development mode + +You can compile nym-connect in development mode by running the following command inside the `nym-connect` directory: + +``` +yarn dev +``` +This will produce a binary in - `nym-connect/target/debug/` named `nym-connect` + +To launch, navigate to the directory and run the following command: `./nym-connect` + +## Production mode + +Run the following command from the `nym-connect` folder +``` +yarn build +``` +The output will compile different types of binaries dependent on your hardware / OS system. Once the binaries are built, they can be located as follows: + +### Binary output directory structure +``` +**macos** +| +└─── target/release +| |─ nym-connect +└───target/release/bundle/dmg +│ │─ bundle_dmg.sh +│ │─ nym-connect.*.dmg +└───target/release/bundle/macos/MacOs +│ │─ nym-connect +| +**Linux** +└─── target/release +| │─ nym-connect +└───target/release/bundle/appimage +│ │─ nym-connect_*_.AppImage +│ │─ build_appimage.sh +└───target/release/bundle/deb +│ │─ nym-connect_*_.deb +| +**Windows** +└─── target/release +| │─ nym-connect.exe +└───target/release/bundle/msi +│ │─ nym-connect_*_.msi +``` + +For instructions on how to release nym-connect, please see [RELEASE.md](./docs/release/RELEASE.md). + +# Storybook + +Run storybook with: + +``` +yarn storybook +``` + +And build storybook static site with: + +``` +yarn storybook:build +``` + diff --git a/nym-connect-android/package.json b/nym-connect-android/package.json new file mode 100644 index 0000000000..2d9c094109 --- /dev/null +++ b/nym-connect-android/package.json @@ -0,0 +1,116 @@ +{ + "name": "@nym/nym-connect-android", + "version": "1.1.7", + "main": "index.js", + "license": "MIT", + "scripts": { + "prewebpack:dev": "yarn --cwd .. build", + "webpack:dev": "yarn webpack serve --config webpack.dev.js", + "webpack:dev:onlyThis": "yarn webpack serve --config webpack.dev.js", + "webpack:prod": "yarn webpack --progress --config webpack.prod.js", + "tauri:dev": "RUST_DEBUG=1 yarn tauri dev", + "tauri:build": "yarn tauri build", + "dev": "run-p webpack:dev tauri:dev", + "prebuild": "yarn --cwd .. build", + "build": "run-s webpack:prod tauri:build", + "storybook": "start-storybook -p 6006", + "prestorybook:build": "yarn --cwd .. build", + "storybook:build": "build-storybook", + "tsc": "tsc --noEmit true", + "tsc:watch": "tsc --noEmit true --watch", + "lint": "eslint src", + "lint:fix": "eslint src --fix", + "test": "jest", + "test:watch": "jest --watch" + }, + "dependencies": { + "@emotion/react": "^11.7.0", + "@emotion/styled": "^11.6.0", + "@hookform/resolvers": "^2.8.0", + "@mui/icons-material": "^5.2.0", + "@mui/material": "^5.2.2", + "@mui/styles": "^5.2.2", + "@mui/system": ">= 5", + "@mui/lab": "^5.0.0-alpha.72", + "@nymproject/react": "^1.0.0", + "@tauri-apps/api": "^2.0.0-alpha.0", + "@tauri-apps/tauri-forage": "^1.0.0-beta.2", + "clsx": "^1.1.1", + "luxon": "^2.3.0", + "pretty-bytes": "^6.0.0", + "react": "^18.2.0", + "react-dom": "^18.2.0", + "react-error-boundary": "^3.1.3", + "react-hook-form": "^7.14.2", + "react-markdown": "^8.0.4", + "react-router-dom": "^5.2.0", + "semver": "^6.3.0", + "yup": "^0.32.9" + }, + "devDependencies": { + "@babel/core": "^7.15.0", + "@babel/plugin-transform-async-to-generator": "^7.14.5", + "@babel/preset-env": "^7.15.0", + "@babel/preset-react": "^7.14.5", + "@babel/preset-typescript": "^7.15.0", + "@mdx-js/loader": "^2.1.5", + "@nymproject/eslint-config-react-typescript": "^1.0.0", + "@pmmmwh/react-refresh-webpack-plugin": "^0.5.4", + "@storybook/react": "^6.5.15", + "@svgr/webpack": "^6.1.1", + "@tauri-apps/cli": "^2.0.0-alpha.1", + "@testing-library/jest-dom": "^5.14.1", + "@testing-library/react": "^12.0.0", + "@types/jest": "^27.0.1", + "@types/luxon": "^2.3.2", + "@types/node": "^16.7.13", + "@types/react": "^18.0.26", + "@types/react-dom": "^18.0.10", + "@types/semver": "^7.3.8", + "@types/uuid": "^8.3.4", + "@typescript-eslint/eslint-plugin": "^5.13.0", + "@typescript-eslint/parser": "^5.13.0", + "babel-loader": "^8.3.0", + "babel-plugin-root-import": "^6.6.0", + "clean-webpack-plugin": "^4.0.0", + "css-loader": "^6.7.3", + "css-minimizer-webpack-plugin": "^3.0.2", + "dotenv-webpack": "^7.0.3", + "eslint": "^8.10.0", + "eslint-config-airbnb": "^19.0.4", + "eslint-config-airbnb-typescript": "^16.1.0", + "eslint-config-prettier": "^8.5.0", + "eslint-import-resolver-root-import": "^1.0.4", + "eslint-plugin-import": "^2.25.4", + "eslint-plugin-jest": "^26.1.1", + "eslint-plugin-jsx-a11y": "^6.5.1", + "eslint-plugin-prettier": "^4.0.0", + "eslint-plugin-react": "^7.29.2", + "eslint-plugin-react-hooks": "^4.3.0", + "eslint-plugin-storybook": "^0.5.12", + "favicons": "^7.0.2", + "favicons-webpack-plugin": "^5.0.2", + "file-loader": "^6.2.0", + "fork-ts-checker-webpack-plugin": "^7.2.1", + "html-webpack-plugin": "^5.3.2", + "jest": "^27.1.0", + "mini-css-extract-plugin": "^2.2.2", + "npm-run-all": "^4.1.5", + "prettier": "2.3.2", + "react-refresh": "^0.10.0", + "react-refresh-typescript": "^2.0.2", + "style-loader": "^3.3.1", + "thread-loader": "^3.0.4", + "ts-jest": "^27.0.5", + "ts-loader": "^9.4.2", + "tsconfig-paths-webpack-plugin": "^3.5.2", + "typescript": "^4.6.2", + "url-loader": "^4.1.1", + "webpack": "^5.75.0", + "webpack-cli": "^4.8.0", + "webpack-dev-server": "^4.5.0", + "webpack-favicons": "^1.3.8", + "webpack-merge": "^5.8.0", + "yaml-loader": "^0.8.0" + } +} diff --git a/nym-connect-android/public/favicon.ico b/nym-connect-android/public/favicon.ico new file mode 100644 index 0000000000..bc951a1c6f Binary files /dev/null and b/nym-connect-android/public/favicon.ico differ diff --git a/nym-connect-android/public/favicon.png b/nym-connect-android/public/favicon.png new file mode 100644 index 0000000000..fcf1e747d0 Binary files /dev/null and b/nym-connect-android/public/favicon.png differ diff --git a/nym-connect-android/public/growth.html.back b/nym-connect-android/public/growth.html.back new file mode 100644 index 0000000000..08672aaa3c --- /dev/null +++ b/nym-connect-android/public/growth.html.back @@ -0,0 +1,11 @@ + + + + + + Nym Connect + + +
+ + diff --git a/nym-connect-android/public/index.html b/nym-connect-android/public/index.html new file mode 100644 index 0000000000..2ba643a663 --- /dev/null +++ b/nym-connect-android/public/index.html @@ -0,0 +1,11 @@ + + + + + + Nym Connect + + +
+ + diff --git a/nym-connect-android/public/log.html.back b/nym-connect-android/public/log.html.back new file mode 100644 index 0000000000..5ced2243f1 --- /dev/null +++ b/nym-connect-android/public/log.html.back @@ -0,0 +1,11 @@ + + + + + + Nym Wallet Logs + + +
+ + diff --git a/nym-connect-android/src-tauri/.cargo/config.toml b/nym-connect-android/src-tauri/.cargo/config.toml new file mode 100644 index 0000000000..d4585a1646 --- /dev/null +++ b/nym-connect-android/src-tauri/.cargo/config.toml @@ -0,0 +1,41 @@ +[build] +target = 'x86_64-unknown-linux-gnu' +[target.aarch64-linux-android] +linker = '/home/pierre/.local/share/android/sdk/ndk/25.1.8937393/toolchains/llvm/prebuilt/linux-x86_64/bin/aarch64-linux-android24-clang' +rustflags = [ + '-L', + '/home/pierre/Documents/nym/nym/nym-connect-android/src-tauri/.cargo', + '-Clink-arg=-landroid', + '-Clink-arg=-llog', + '-Clink-arg=-lOpenSLES', +] + +[target.armv7-linux-androideabi] +linker = '/home/pierre/.local/share/android/sdk/ndk/25.1.8937393/toolchains/llvm/prebuilt/linux-x86_64/bin/armv7a-linux-androideabi24-clang' +rustflags = [ + '-L', + '/home/pierre/Documents/nym/nym/nym-connect-android/src-tauri/.cargo', + '-Clink-arg=-landroid', + '-Clink-arg=-llog', + '-Clink-arg=-lOpenSLES', +] + +[target.i686-linux-android] +linker = '/home/pierre/.local/share/android/sdk/ndk/25.1.8937393/toolchains/llvm/prebuilt/linux-x86_64/bin/i686-linux-android24-clang' +rustflags = [ + '-L', + '/home/pierre/Documents/nym/nym/nym-connect-android/src-tauri/.cargo', + '-Clink-arg=-landroid', + '-Clink-arg=-llog', + '-Clink-arg=-lOpenSLES', +] + +[target.x86_64-linux-android] +linker = '/home/pierre/.local/share/android/sdk/ndk/25.1.8937393/toolchains/llvm/prebuilt/linux-x86_64/bin/x86_64-linux-android24-clang' +rustflags = [ + '-L', + '/home/pierre/Documents/nym/nym/nym-connect-android/src-tauri/.cargo', + '-Clink-arg=-landroid', + '-Clink-arg=-llog', + '-Clink-arg=-lOpenSLES', +] diff --git a/nym-connect-android/src-tauri/.cargo/libgcc.a b/nym-connect-android/src-tauri/.cargo/libgcc.a new file mode 100644 index 0000000000..a5f3b046c1 --- /dev/null +++ b/nym-connect-android/src-tauri/.cargo/libgcc.a @@ -0,0 +1 @@ +INPUT(-lunwind) \ No newline at end of file diff --git a/nym-connect-android/src-tauri/.gitignore b/nym-connect-android/src-tauri/.gitignore new file mode 100644 index 0000000000..c123704591 --- /dev/null +++ b/nym-connect-android/src-tauri/.gitignore @@ -0,0 +1,4 @@ +# Generated by Cargo +# will have compiled files and executables +/target/ +WixTools diff --git a/nym-connect-android/src-tauri/Cargo.toml b/nym-connect-android/src-tauri/Cargo.toml new file mode 100644 index 0000000000..ffac476d29 --- /dev/null +++ b/nym-connect-android/src-tauri/Cargo.toml @@ -0,0 +1,66 @@ +[package] +name = "nym-connect-android" +version = "1.1.7" +description = "nym-connect" +authors = ["Nym Technologies SA"] +license = "" +repository = "" +default-run = "nym-connect-android" +edition = "2021" +build = "src/build.rs" +rust-version = "1.58" + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[lib] +crate-type = ["staticlib", "cdylib", "rlib"] + +[build-dependencies] +# tauri-build = { version = "2.0.0-alpha.0", features = [] } +tauri-build = { git = "https://github.com/tauri-apps/tauri", branch = "next", features = [] } + +# tauri-codegen = "2.0.0-alpha.0" +# tauri-macros = "2.0.0-alpha.0" + +[dependencies] +anyhow = "1.0" +bip39 = "1.0" +chrono = "0.4" +dirs = "4.0" +eyre = "0.6.5" +fix-path-env = { git = "https://github.com/tauri-apps/fix-path-env-rs", branch = "release"} +futures = "0.3" +fern = { version = "0.6.1", features = ["colored"] } +itertools = "0.10.5" +log = { version = "0.4", features = ["serde"] } +pretty_env_logger = "0.4.0" +rand = "0.8" +reqwest = { version = "0.11", features = ["json", "socks"] } +rust-embed = { version = "6.4.2", features = ["include-exclude"] } +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" +serde_repr = "0.1" +tap = "1.0.1" +tauri = { git = "https://github.com/tauri-apps/tauri", branch = "next", features = ["clipboard-write-text", "native-tls-vendored", "notification-all", "shell-open", "system-tray", "window-close", "window-minimize", "window-start-dragging"] } +# tauri = { version = "2.0.0-alpha.0", features = ["clipboard-write-text", "native-tls-vendored", "notification-all", "shell-open", "system-tray", "window-close", "window-minimize", "window-start-dragging"] } +tendermint-rpc = "0.23.0" +thiserror = "1.0" +tokio = { version = "1.24.1", features = ["sync", "time"] } +url = "2.2" +yaml-rust = "0.4" + +client-core = { path = "../../clients/client-core" } +config-common = { path = "../../common/config", package = "config" } +crypto = { path = "../../common/crypto" } +logging = { path = "../../common/logging"} +nym-socks5-client = { path = "../../clients/socks5", features = ["mobile"], default-features = false } +task = { path = "../../common/task" } +topology = { path = "../../common/topology" } + +[dev-dependencies] +ts-rs = "6.1.2" +tempfile = "3.3.0" + +[features] +default = ["custom-protocol"] +custom-protocol = ["tauri/custom-protocol"] diff --git a/nym-connect-android/src-tauri/gen/android/nym-connect-android/.editorconfig b/nym-connect-android/src-tauri/gen/android/nym-connect-android/.editorconfig new file mode 100644 index 0000000000..ebe51d3bfa --- /dev/null +++ b/nym-connect-android/src-tauri/gen/android/nym-connect-android/.editorconfig @@ -0,0 +1,12 @@ +# EditorConfig is awesome: https://EditorConfig.org + +# top-most EditorConfig file +root = true + +[*] +indent_style = space +indent_size = 2 +end_of_line = lf +charset = utf-8 +trim_trailing_whitespace = false +insert_final_newline = false \ No newline at end of file diff --git a/nym-connect-android/src-tauri/gen/android/nym-connect-android/.gitignore b/nym-connect-android/src-tauri/gen/android/nym-connect-android/.gitignore new file mode 100644 index 0000000000..1eed4c2fbd --- /dev/null +++ b/nym-connect-android/src-tauri/gen/android/nym-connect-android/.gitignore @@ -0,0 +1,17 @@ +*.iml +.gradle +/local.properties +/.idea/caches +/.idea/libraries +/.idea/modules.xml +/.idea/workspace.xml +/.idea/navEditor.xml +/.idea/assetWizardSettings.xml +.DS_Store +build +/buildSrc/src/main/java/net/nymtech/nym-connect-android/kotlin/BuildTask.kt +/buildSrc/src/main/java/net/nymtech/nym-connect-android/kotlin/RustPlugin.kt +/captures +.externalNativeBuild +.cxx +local.properties diff --git a/nym-connect-android/src-tauri/gen/android/nym-connect-android/app/.gitignore b/nym-connect-android/src-tauri/gen/android/nym-connect-android/app/.gitignore new file mode 100644 index 0000000000..eddc6bd989 --- /dev/null +++ b/nym-connect-android/src-tauri/gen/android/nym-connect-android/app/.gitignore @@ -0,0 +1 @@ +/src/main/java/net/nymtech/nym-connect-android/generated diff --git a/nym-connect-android/src-tauri/gen/android/nym-connect-android/app/build.gradle.kts b/nym-connect-android/src-tauri/gen/android/nym-connect-android/app/build.gradle.kts new file mode 100644 index 0000000000..a81c779421 --- /dev/null +++ b/nym-connect-android/src-tauri/gen/android/nym-connect-android/app/build.gradle.kts @@ -0,0 +1,111 @@ +plugins { + id("com.android.application") + id("org.jetbrains.kotlin.android") + id("rustPlugin") +} + +android { + compileSdk = 33 + defaultConfig { + manifestPlaceholders["usesCleartextTraffic"] = "false" + applicationId = "net.nymtech.nym_connect_android" + minSdk = 24 + targetSdk = 33 + versionCode = 1 + versionName = "1.0" + } + sourceSets.getByName("main") { + // Vulkan validation layers + val ndkHome = System.getenv("NDK_HOME") + jniLibs.srcDir("${ndkHome}/sources/third_party/vulkan/src/build-android/jniLibs") + } + buildTypes { + getByName("debug") { + manifestPlaceholders["usesCleartextTraffic"] = "true" + isDebuggable = true + isJniDebuggable = true + isMinifyEnabled = false + packagingOptions { + jniLibs.keepDebugSymbols.add("*/arm64-v8a/*.so") + + jniLibs.keepDebugSymbols.add("*/armeabi-v7a/*.so") + + jniLibs.keepDebugSymbols.add("*/x86/*.so") + + jniLibs.keepDebugSymbols.add("*/x86_64/*.so") + } + } + getByName("release") { + isMinifyEnabled = false + proguardFiles(getDefaultProguardFile("proguard-android.txt"), "proguard-rules.pro") + } + } + flavorDimensions.add("abi") + productFlavors { + create("universal") { + val abiList = findProperty("abiList") as? String + + dimension = "abi" + ndk { + abiFilters += abiList?.split(",")?.map { it.trim() } ?: listOf( "arm64-v8a", "armeabi-v7a", "x86", "x86_64", + ) + } + } + create("arm64") { + dimension = "abi" + ndk { + abiFilters += listOf("arm64-v8a") + } + } + + create("arm") { + dimension = "abi" + ndk { + abiFilters += listOf("armeabi-v7a") + } + } + + create("x86") { + dimension = "abi" + ndk { + abiFilters += listOf("x86") + } + } + + create("x86_64") { + dimension = "abi" + ndk { + abiFilters += listOf("x86_64") + } + } + } + + assetPacks += mutableSetOf() + namespace = "net.nymtech.nym_connect_android" +} + +rust { + rootDirRel = "../../../../" + targets = listOf("aarch64", "armv7", "i686", "x86_64") + arches = listOf("arm64", "arm", "x86", "x86_64") +} + +dependencies { + implementation("androidx.webkit:webkit:1.5.0") + implementation("androidx.appcompat:appcompat:1.5.1") + implementation("com.google.android.material:material:1.7.0") + testImplementation("junit:junit:4.13.2") + androidTestImplementation("androidx.test.ext:junit:1.1.4") + androidTestImplementation("androidx.test.espresso:espresso-core:3.5.0") +} + +afterEvaluate { + android.applicationVariants.all { + tasks["mergeUniversalReleaseJniLibFolders"].dependsOn(tasks["rustBuildRelease"]) + tasks["mergeUniversalDebugJniLibFolders"].dependsOn(tasks["rustBuildDebug"]) + productFlavors.filter{ it.name != "universal" }.forEach { _ -> + val archAndBuildType = name.capitalize() + tasks["merge${archAndBuildType}JniLibFolders"].dependsOn(tasks["rustBuild${archAndBuildType}"]) + } + } +} diff --git a/nym-connect-android/src-tauri/gen/android/nym-connect-android/app/proguard-rules.pro b/nym-connect-android/src-tauri/gen/android/nym-connect-android/app/proguard-rules.pro new file mode 100644 index 0000000000..481bb43481 --- /dev/null +++ b/nym-connect-android/src-tauri/gen/android/nym-connect-android/app/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/nym-connect-android/src-tauri/gen/android/nym-connect-android/app/src/main/AndroidManifest.xml b/nym-connect-android/src-tauri/gen/android/nym-connect-android/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000000..a459f78ce5 --- /dev/null +++ b/nym-connect-android/src-tauri/gen/android/nym-connect-android/app/src/main/AndroidManifest.xml @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + diff --git a/nym-connect-android/src-tauri/gen/android/nym-connect-android/app/src/main/java/net/nymtech/nym-connect-android/MainActivity.kt b/nym-connect-android/src-tauri/gen/android/nym-connect-android/app/src/main/java/net/nymtech/nym-connect-android/MainActivity.kt new file mode 100644 index 0000000000..5b7526aed3 --- /dev/null +++ b/nym-connect-android/src-tauri/gen/android/nym-connect-android/app/src/main/java/net/nymtech/nym-connect-android/MainActivity.kt @@ -0,0 +1,3 @@ +package net.nymtech.nym_connect_android + +class MainActivity : TauriActivity() diff --git a/nym-connect-android/src-tauri/gen/android/nym-connect-android/app/src/main/jniLibs/x86_64/libnym_connect_android.so b/nym-connect-android/src-tauri/gen/android/nym-connect-android/app/src/main/jniLibs/x86_64/libnym_connect_android.so new file mode 120000 index 0000000000..d6aac3cf45 --- /dev/null +++ b/nym-connect-android/src-tauri/gen/android/nym-connect-android/app/src/main/jniLibs/x86_64/libnym_connect_android.so @@ -0,0 +1 @@ +/home/pierre/Documents/nym/nym/nym-connect-android/src-tauri/target/x86_64-linux-android/debug/libnym_connect_android.so \ No newline at end of file diff --git a/nym-connect-android/src-tauri/gen/android/nym-connect-android/app/src/main/res/drawable-v24/ic_launcher_foreground.xml b/nym-connect-android/src-tauri/gen/android/nym-connect-android/app/src/main/res/drawable-v24/ic_launcher_foreground.xml new file mode 100644 index 0000000000..2b068d1146 --- /dev/null +++ b/nym-connect-android/src-tauri/gen/android/nym-connect-android/app/src/main/res/drawable-v24/ic_launcher_foreground.xml @@ -0,0 +1,30 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/nym-connect-android/src-tauri/gen/android/nym-connect-android/app/src/main/res/drawable/ic_launcher_background.xml b/nym-connect-android/src-tauri/gen/android/nym-connect-android/app/src/main/res/drawable/ic_launcher_background.xml new file mode 100644 index 0000000000..07d5da9cbf --- /dev/null +++ b/nym-connect-android/src-tauri/gen/android/nym-connect-android/app/src/main/res/drawable/ic_launcher_background.xml @@ -0,0 +1,170 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/nym-connect-android/src-tauri/gen/android/nym-connect-android/app/src/main/res/layout/activity_main.xml b/nym-connect-android/src-tauri/gen/android/nym-connect-android/app/src/main/res/layout/activity_main.xml new file mode 100644 index 0000000000..4fc244418b --- /dev/null +++ b/nym-connect-android/src-tauri/gen/android/nym-connect-android/app/src/main/res/layout/activity_main.xml @@ -0,0 +1,18 @@ + + + + + + \ No newline at end of file diff --git a/nym-connect-android/src-tauri/gen/android/nym-connect-android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/nym-connect-android/src-tauri/gen/android/nym-connect-android/app/src/main/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 0000000000..28f1aa1191 Binary files /dev/null and b/nym-connect-android/src-tauri/gen/android/nym-connect-android/app/src/main/res/mipmap-hdpi/ic_launcher.png differ diff --git a/nym-connect-android/src-tauri/gen/android/nym-connect-android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png b/nym-connect-android/src-tauri/gen/android/nym-connect-android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png new file mode 100644 index 0000000000..85d0c88af6 Binary files /dev/null and b/nym-connect-android/src-tauri/gen/android/nym-connect-android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png differ diff --git a/nym-connect-android/src-tauri/gen/android/nym-connect-android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png b/nym-connect-android/src-tauri/gen/android/nym-connect-android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png new file mode 100644 index 0000000000..28f1aa1191 Binary files /dev/null and b/nym-connect-android/src-tauri/gen/android/nym-connect-android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png differ diff --git a/nym-connect-android/src-tauri/gen/android/nym-connect-android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/nym-connect-android/src-tauri/gen/android/nym-connect-android/app/src/main/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 0000000000..73e48dbfb7 Binary files /dev/null and b/nym-connect-android/src-tauri/gen/android/nym-connect-android/app/src/main/res/mipmap-mdpi/ic_launcher.png differ diff --git a/nym-connect-android/src-tauri/gen/android/nym-connect-android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png b/nym-connect-android/src-tauri/gen/android/nym-connect-android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png new file mode 100644 index 0000000000..13dd21476b Binary files /dev/null and b/nym-connect-android/src-tauri/gen/android/nym-connect-android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png differ diff --git a/nym-connect-android/src-tauri/gen/android/nym-connect-android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png b/nym-connect-android/src-tauri/gen/android/nym-connect-android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png new file mode 100644 index 0000000000..73e48dbfb7 Binary files /dev/null and b/nym-connect-android/src-tauri/gen/android/nym-connect-android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png differ diff --git a/nym-connect-android/src-tauri/gen/android/nym-connect-android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/nym-connect-android/src-tauri/gen/android/nym-connect-android/app/src/main/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 0000000000..1d98044f13 Binary files /dev/null and b/nym-connect-android/src-tauri/gen/android/nym-connect-android/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/nym-connect-android/src-tauri/gen/android/nym-connect-android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png b/nym-connect-android/src-tauri/gen/android/nym-connect-android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png new file mode 100644 index 0000000000..a888b336b5 Binary files /dev/null and b/nym-connect-android/src-tauri/gen/android/nym-connect-android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png differ diff --git a/nym-connect-android/src-tauri/gen/android/nym-connect-android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png b/nym-connect-android/src-tauri/gen/android/nym-connect-android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png new file mode 100644 index 0000000000..1d98044f13 Binary files /dev/null and b/nym-connect-android/src-tauri/gen/android/nym-connect-android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png differ diff --git a/nym-connect-android/src-tauri/gen/android/nym-connect-android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/nym-connect-android/src-tauri/gen/android/nym-connect-android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 0000000000..081832466b Binary files /dev/null and b/nym-connect-android/src-tauri/gen/android/nym-connect-android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/nym-connect-android/src-tauri/gen/android/nym-connect-android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png b/nym-connect-android/src-tauri/gen/android/nym-connect-android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png new file mode 100644 index 0000000000..a2a838e7b5 Binary files /dev/null and b/nym-connect-android/src-tauri/gen/android/nym-connect-android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png differ diff --git a/nym-connect-android/src-tauri/gen/android/nym-connect-android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png b/nym-connect-android/src-tauri/gen/android/nym-connect-android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png new file mode 100644 index 0000000000..081832466b Binary files /dev/null and b/nym-connect-android/src-tauri/gen/android/nym-connect-android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png differ diff --git a/nym-connect-android/src-tauri/gen/android/nym-connect-android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/nym-connect-android/src-tauri/gen/android/nym-connect-android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 0000000000..b18bceb64d Binary files /dev/null and b/nym-connect-android/src-tauri/gen/android/nym-connect-android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/nym-connect-android/src-tauri/gen/android/nym-connect-android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png b/nym-connect-android/src-tauri/gen/android/nym-connect-android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png new file mode 100644 index 0000000000..3f8a57f38e Binary files /dev/null and b/nym-connect-android/src-tauri/gen/android/nym-connect-android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png differ diff --git a/nym-connect-android/src-tauri/gen/android/nym-connect-android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png b/nym-connect-android/src-tauri/gen/android/nym-connect-android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png new file mode 100644 index 0000000000..b18bceb64d Binary files /dev/null and b/nym-connect-android/src-tauri/gen/android/nym-connect-android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png differ diff --git a/nym-connect-android/src-tauri/gen/android/nym-connect-android/app/src/main/res/values-night/themes.xml b/nym-connect-android/src-tauri/gen/android/nym-connect-android/app/src/main/res/values-night/themes.xml new file mode 100644 index 0000000000..4e5d6f7ccf --- /dev/null +++ b/nym-connect-android/src-tauri/gen/android/nym-connect-android/app/src/main/res/values-night/themes.xml @@ -0,0 +1,16 @@ + + + + diff --git a/nym-connect-android/src-tauri/gen/android/nym-connect-android/app/src/main/res/values/colors.xml b/nym-connect-android/src-tauri/gen/android/nym-connect-android/app/src/main/res/values/colors.xml new file mode 100644 index 0000000000..f8c6127d32 --- /dev/null +++ b/nym-connect-android/src-tauri/gen/android/nym-connect-android/app/src/main/res/values/colors.xml @@ -0,0 +1,10 @@ + + + #FFBB86FC + #FF6200EE + #FF3700B3 + #FF03DAC5 + #FF018786 + #FF000000 + #FFFFFFFF + \ No newline at end of file diff --git a/nym-connect-android/src-tauri/gen/android/nym-connect-android/app/src/main/res/values/strings.xml b/nym-connect-android/src-tauri/gen/android/nym-connect-android/app/src/main/res/values/strings.xml new file mode 100644 index 0000000000..95988e4202 --- /dev/null +++ b/nym-connect-android/src-tauri/gen/android/nym-connect-android/app/src/main/res/values/strings.xml @@ -0,0 +1,3 @@ + + nym-connect-android + \ No newline at end of file diff --git a/nym-connect-android/src-tauri/gen/android/nym-connect-android/app/src/main/res/values/themes.xml b/nym-connect-android/src-tauri/gen/android/nym-connect-android/app/src/main/res/values/themes.xml new file mode 100644 index 0000000000..8e37feb691 --- /dev/null +++ b/nym-connect-android/src-tauri/gen/android/nym-connect-android/app/src/main/res/values/themes.xml @@ -0,0 +1,16 @@ + + + + diff --git a/nym-connect-android/src-tauri/gen/android/nym-connect-android/build.gradle.kts b/nym-connect-android/src-tauri/gen/android/nym-connect-android/build.gradle.kts new file mode 100644 index 0000000000..8c6fe5848c --- /dev/null +++ b/nym-connect-android/src-tauri/gen/android/nym-connect-android/build.gradle.kts @@ -0,0 +1,25 @@ +// Top-level build file where you can add configuration options common to all sub-projects/modules. +buildscript { + repositories { + google() + mavenCentral() + } + dependencies { + classpath("com.android.tools.build:gradle:7.3.1") + classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:1.6.10") + // NOTE: Do not place your application dependencies here; they belong + // in the individual module build.gradle files + } +} + +allprojects { + repositories { + google() + mavenCentral() + } +} + +tasks.register("clean").configure { + delete("build") +} + diff --git a/nym-connect-android/src-tauri/gen/android/nym-connect-android/buildSrc/build.gradle.kts b/nym-connect-android/src-tauri/gen/android/nym-connect-android/buildSrc/build.gradle.kts new file mode 100644 index 0000000000..45981f7511 --- /dev/null +++ b/nym-connect-android/src-tauri/gen/android/nym-connect-android/buildSrc/build.gradle.kts @@ -0,0 +1,23 @@ +plugins { + `kotlin-dsl` +} + +gradlePlugin { + plugins { + create("pluginsForCoolKids") { + id = "rustPlugin" + implementationClass = "net.nymtech.RustPlugin" + } + } +} + +repositories { + google() + mavenCentral() +} + +dependencies { + compileOnly(gradleApi()) + implementation("com.android.tools.build:gradle:7.3.1") +} + diff --git a/nym-connect-android/src-tauri/gen/android/nym-connect-android/gradle.properties b/nym-connect-android/src-tauri/gen/android/nym-connect-android/gradle.properties new file mode 100644 index 0000000000..cd0519bb2a --- /dev/null +++ b/nym-connect-android/src-tauri/gen/android/nym-connect-android/gradle.properties @@ -0,0 +1,23 @@ +# Project-wide Gradle settings. +# IDE (e.g. Android Studio) users: +# Gradle settings configured through the IDE *will override* +# any settings specified in this file. +# For more details on how to configure your build environment visit +# http://www.gradle.org/docs/current/userguide/build_environment.html +# Specifies the JVM arguments used for the daemon process. +# The setting is particularly useful for tweaking memory settings. +org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 +# When configured, Gradle will run in incubating parallel mode. +# This option should only be used with decoupled projects. More details, visit +# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects +# org.gradle.parallel=true +# AndroidX package structure to make it clearer which packages are bundled with the +# Android operating system, and which are packaged with your app"s APK +# https://developer.android.com/topic/libraries/support-library/androidx-rn +android.useAndroidX=true +# Kotlin code style for this project: "official" or "obsolete": +kotlin.code.style=official +# Enables namespacing of each library's R class so that its R class includes only the +# resources declared in the library itself and none from the library's dependencies, +# thereby reducing the size of the R class for that library +android.nonTransitiveRClass=true \ No newline at end of file diff --git a/nym-connect-android/src-tauri/gen/android/nym-connect-android/gradle/wrapper/gradle-wrapper.jar b/nym-connect-android/src-tauri/gen/android/nym-connect-android/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000000..e708b1c023 Binary files /dev/null and b/nym-connect-android/src-tauri/gen/android/nym-connect-android/gradle/wrapper/gradle-wrapper.jar differ diff --git a/nym-connect-android/src-tauri/gen/android/nym-connect-android/gradle/wrapper/gradle-wrapper.properties b/nym-connect-android/src-tauri/gen/android/nym-connect-android/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000000..de8c362b62 --- /dev/null +++ b/nym-connect-android/src-tauri/gen/android/nym-connect-android/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,6 @@ +#Tue May 10 19:22:52 CST 2022 +distributionBase=GRADLE_USER_HOME +distributionUrl=https\://services.gradle.org/distributions/gradle-7.4-bin.zip +distributionPath=wrapper/dists +zipStorePath=wrapper/dists +zipStoreBase=GRADLE_USER_HOME diff --git a/nym-connect-android/src-tauri/gen/android/nym-connect-android/gradlew b/nym-connect-android/src-tauri/gen/android/nym-connect-android/gradlew new file mode 100755 index 0000000000..4f906e0c81 --- /dev/null +++ b/nym-connect-android/src-tauri/gen/android/nym-connect-android/gradlew @@ -0,0 +1,185 @@ +#!/usr/bin/env sh + +# +# Copyright 2015 the original author or authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +############################################################################## +## +## Gradle start up script for UN*X +## +############################################################################## + +# Attempt to set APP_HOME +# Resolve links: $0 may be a link +PRG="$0" +# Need this for relative symlinks. +while [ -h "$PRG" ] ; do + ls=`ls -ld "$PRG"` + link=`expr "$ls" : '.*-> \(.*\)$'` + if expr "$link" : '/.*' > /dev/null; then + PRG="$link" + else + PRG=`dirname "$PRG"`"/$link" + fi +done +SAVED="`pwd`" +cd "`dirname \"$PRG\"`/" >/dev/null +APP_HOME="`pwd -P`" +cd "$SAVED" >/dev/null + +APP_NAME="Gradle" +APP_BASE_NAME=`basename "$0"` + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD="maximum" + +warn () { + echo "$*" +} + +die () { + echo + echo "$*" + echo + exit 1 +} + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "`uname`" in + CYGWIN* ) + cygwin=true + ;; + Darwin* ) + darwin=true + ;; + MINGW* ) + msys=true + ;; + NONSTOP* ) + nonstop=true + ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD="$JAVA_HOME/jre/sh/java" + else + JAVACMD="$JAVA_HOME/bin/java" + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD="java" + which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." +fi + +# Increase the maximum file descriptors if we can. +if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then + MAX_FD_LIMIT=`ulimit -H -n` + if [ $? -eq 0 ] ; then + if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then + MAX_FD="$MAX_FD_LIMIT" + fi + ulimit -n $MAX_FD + if [ $? -ne 0 ] ; then + warn "Could not set maximum file descriptor limit: $MAX_FD" + fi + else + warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" + fi +fi + +# For Darwin, add options to specify how the application appears in the dock +if $darwin; then + GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" +fi + +# For Cygwin or MSYS, switch paths to Windows format before running java +if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then + APP_HOME=`cygpath --path --mixed "$APP_HOME"` + CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` + + JAVACMD=`cygpath --unix "$JAVACMD"` + + # We build the pattern for arguments to be converted via cygpath + ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` + SEP="" + for dir in $ROOTDIRSRAW ; do + ROOTDIRS="$ROOTDIRS$SEP$dir" + SEP="|" + done + OURCYGPATTERN="(^($ROOTDIRS))" + # Add a user-defined pattern to the cygpath arguments + if [ "$GRADLE_CYGPATTERN" != "" ] ; then + OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" + fi + # Now convert the arguments - kludge to limit ourselves to /bin/sh + i=0 + for arg in "$@" ; do + CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` + CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option + + if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition + eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` + else + eval `echo args$i`="\"$arg\"" + fi + i=`expr $i + 1` + done + case $i in + 0) set -- ;; + 1) set -- "$args0" ;; + 2) set -- "$args0" "$args1" ;; + 3) set -- "$args0" "$args1" "$args2" ;; + 4) set -- "$args0" "$args1" "$args2" "$args3" ;; + 5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; + 6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; + 7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; + 8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; + 9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; + esac +fi + +# Escape application args +save () { + for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done + echo " " +} +APP_ARGS=`save "$@"` + +# Collect all arguments for the java command, following the shell quoting and substitution rules +eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS" + +exec "$JAVACMD" "$@" diff --git a/nym-connect-android/src-tauri/gen/android/nym-connect-android/gradlew.bat b/nym-connect-android/src-tauri/gen/android/nym-connect-android/gradlew.bat new file mode 100644 index 0000000000..107acd32c4 --- /dev/null +++ b/nym-connect-android/src-tauri/gen/android/nym-connect-android/gradlew.bat @@ -0,0 +1,89 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem + +@if "%DEBUG%" == "" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%" == "" set DIRNAME=. +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if "%ERRORLEVEL%" == "0" goto execute + +echo. +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* + +:end +@rem End local scope for the variables with windows NT shell +if "%ERRORLEVEL%"=="0" goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 +exit /b 1 + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/nym-connect-android/src-tauri/gen/android/nym-connect-android/settings.gradle b/nym-connect-android/src-tauri/gen/android/nym-connect-android/settings.gradle new file mode 100644 index 0000000000..e7b4def49c --- /dev/null +++ b/nym-connect-android/src-tauri/gen/android/nym-connect-android/settings.gradle @@ -0,0 +1 @@ +include ':app' diff --git a/nym-connect-android/src-tauri/icons/128x128.png b/nym-connect-android/src-tauri/icons/128x128.png new file mode 100644 index 0000000000..12daccf30f Binary files /dev/null and b/nym-connect-android/src-tauri/icons/128x128.png differ diff --git a/nym-connect-android/src-tauri/icons/128x128@2x.png b/nym-connect-android/src-tauri/icons/128x128@2x.png new file mode 100644 index 0000000000..d98b024d26 Binary files /dev/null and b/nym-connect-android/src-tauri/icons/128x128@2x.png differ diff --git a/nym-connect-android/src-tauri/icons/32x32.png b/nym-connect-android/src-tauri/icons/32x32.png new file mode 100644 index 0000000000..5463a8ff1c Binary files /dev/null and b/nym-connect-android/src-tauri/icons/32x32.png differ diff --git a/nym-connect-android/src-tauri/icons/Square107x107Logo.png b/nym-connect-android/src-tauri/icons/Square107x107Logo.png new file mode 100644 index 0000000000..468b6cf29b Binary files /dev/null and b/nym-connect-android/src-tauri/icons/Square107x107Logo.png differ diff --git a/nym-connect-android/src-tauri/icons/Square142x142Logo.png b/nym-connect-android/src-tauri/icons/Square142x142Logo.png new file mode 100644 index 0000000000..ce58b768ca Binary files /dev/null and b/nym-connect-android/src-tauri/icons/Square142x142Logo.png differ diff --git a/nym-connect-android/src-tauri/icons/Square150x150Logo.png b/nym-connect-android/src-tauri/icons/Square150x150Logo.png new file mode 100644 index 0000000000..b6bc59dfcf Binary files /dev/null and b/nym-connect-android/src-tauri/icons/Square150x150Logo.png differ diff --git a/nym-connect-android/src-tauri/icons/Square284x284Logo.png b/nym-connect-android/src-tauri/icons/Square284x284Logo.png new file mode 100644 index 0000000000..26311d4604 Binary files /dev/null and b/nym-connect-android/src-tauri/icons/Square284x284Logo.png differ diff --git a/nym-connect-android/src-tauri/icons/Square30x30Logo.png b/nym-connect-android/src-tauri/icons/Square30x30Logo.png new file mode 100644 index 0000000000..8916d14e49 Binary files /dev/null and b/nym-connect-android/src-tauri/icons/Square30x30Logo.png differ diff --git a/nym-connect-android/src-tauri/icons/Square310x310Logo.png b/nym-connect-android/src-tauri/icons/Square310x310Logo.png new file mode 100644 index 0000000000..6fd3c36def Binary files /dev/null and b/nym-connect-android/src-tauri/icons/Square310x310Logo.png differ diff --git a/nym-connect-android/src-tauri/icons/Square44x44Logo.png b/nym-connect-android/src-tauri/icons/Square44x44Logo.png new file mode 100644 index 0000000000..e161bd7c4a Binary files /dev/null and b/nym-connect-android/src-tauri/icons/Square44x44Logo.png differ diff --git a/nym-connect-android/src-tauri/icons/Square71x71Logo.png b/nym-connect-android/src-tauri/icons/Square71x71Logo.png new file mode 100644 index 0000000000..45b5126bf6 Binary files /dev/null and b/nym-connect-android/src-tauri/icons/Square71x71Logo.png differ diff --git a/nym-connect-android/src-tauri/icons/Square89x89Logo.png b/nym-connect-android/src-tauri/icons/Square89x89Logo.png new file mode 100644 index 0000000000..386c8bf119 Binary files /dev/null and b/nym-connect-android/src-tauri/icons/Square89x89Logo.png differ diff --git a/nym-connect-android/src-tauri/icons/StoreLogo.png b/nym-connect-android/src-tauri/icons/StoreLogo.png new file mode 100644 index 0000000000..692f1a03f2 Binary files /dev/null and b/nym-connect-android/src-tauri/icons/StoreLogo.png differ diff --git a/nym-connect-android/src-tauri/icons/icon.icns b/nym-connect-android/src-tauri/icons/icon.icns new file mode 100644 index 0000000000..129da9bf18 Binary files /dev/null and b/nym-connect-android/src-tauri/icons/icon.icns differ diff --git a/nym-connect-android/src-tauri/icons/icon.ico b/nym-connect-android/src-tauri/icons/icon.ico new file mode 100644 index 0000000000..6164f98f9c Binary files /dev/null and b/nym-connect-android/src-tauri/icons/icon.ico differ diff --git a/nym-connect-android/src-tauri/icons/icon.png b/nym-connect-android/src-tauri/icons/icon.png new file mode 100644 index 0000000000..fa552c309a Binary files /dev/null and b/nym-connect-android/src-tauri/icons/icon.png differ diff --git a/nym-connect-android/src-tauri/icons/tray_icon.ico b/nym-connect-android/src-tauri/icons/tray_icon.ico new file mode 100644 index 0000000000..25785da3d9 Binary files /dev/null and b/nym-connect-android/src-tauri/icons/tray_icon.ico differ diff --git a/nym-connect-android/src-tauri/icons/tray_icon.png b/nym-connect-android/src-tauri/icons/tray_icon.png new file mode 100644 index 0000000000..afcb965219 Binary files /dev/null and b/nym-connect-android/src-tauri/icons/tray_icon.png differ diff --git a/nym-connect-android/src-tauri/src/build.rs b/nym-connect-android/src-tauri/src/build.rs new file mode 100644 index 0000000000..d860e1e6a7 --- /dev/null +++ b/nym-connect-android/src-tauri/src/build.rs @@ -0,0 +1,3 @@ +fn main() { + tauri_build::build() +} diff --git a/nym-connect-android/src-tauri/src/config/mod.rs b/nym-connect-android/src-tauri/src/config/mod.rs new file mode 100644 index 0000000000..849c89df5c --- /dev/null +++ b/nym-connect-android/src-tauri/src/config/mod.rs @@ -0,0 +1,154 @@ +use crate::{ + error::{BackendError, Result}, + state::State, +}; +use client_core::{client::key_manager::KeyManager, config::Config as BaseConfig}; +use config_common::NymConfig; +use crypto::asymmetric::identity; +use nym_socks5::client::config::Config as Socks5Config; +use std::path::PathBuf; +use std::sync::Arc; +use tap::TapFallible; +use tokio::sync::RwLock; + +static SOCKS5_CONFIG_ID: &str = "nym-connect"; + +pub fn socks5_config_id_appended_with(gateway_id: &str) -> Result { + use std::fmt::Write as _; + let mut id = SOCKS5_CONFIG_ID.to_string(); + write!(id, "-{gateway_id}")?; + Ok(id) +} + +#[tauri::command] +pub async fn get_config_id(state: tauri::State<'_, Arc>>) -> Result { + state.read().await.get_config_id() +} + +#[tauri::command] +pub fn get_config_file_location() -> Result { + Err(BackendError::CouldNotGetConfigFilename) +} + +#[derive(Debug)] +pub struct Config { + pub socks5: Socks5Config, +} + +impl Config { + pub fn new>(id: S, provider_mix_address: S) -> Self { + Config { + socks5: Socks5Config::new(id, provider_mix_address), + } + } + + #[allow(unused)] + pub fn new_with_port>(id: S, provider_mix_address: S, port: u16) -> Self { + Config { + socks5: Socks5Config::new(id, provider_mix_address).with_port(port), + } + } + + pub fn get_socks5(&self) -> &Socks5Config { + &self.socks5 + } + + #[allow(unused)] + pub fn get_socks5_mut(&mut self) -> &mut Socks5Config { + &mut self.socks5 + } + + pub fn get_base(&self) -> &BaseConfig { + self.socks5.get_base() + } + + pub fn get_base_mut(&mut self) -> &mut BaseConfig { + self.socks5.get_base_mut() + } + + pub async fn init( + service_provider: &str, + chosen_gateway_id: &str, + ) -> Result<(Config, KeyManager)> { + log::info!("Initialising..."); + + let service_provider = service_provider.to_owned(); + let chosen_gateway_id = chosen_gateway_id.to_owned(); + + // The client initialization was originally not written for this use case, so there are + // lots of ways it can panic. Until we have proper error handling in the init code for the + // clients we'll catch any panics here by spawning a new runtime in a separate thread. + let (config, keys) = std::thread::spawn(move || { + tokio::runtime::Runtime::new() + .expect("Failed to create tokio runtime") + .block_on( + async move { init_socks5_config(service_provider, chosen_gateway_id).await }, + ) + }) + .join() + .map_err(|_| BackendError::InitializationPanic)??; + + log::info!("Configuration saved 🚀"); + Ok((config, keys)) + } +} + +pub async fn init_socks5_config( + provider_address: String, + chosen_gateway_id: String, +) -> Result<(Config, KeyManager)> { + log::trace!("Initialising client..."); + let mut config = Config::new(SOCKS5_CONFIG_ID, &provider_address); + + if let Ok(raw_validators) = std::env::var(config_common::defaults::var_names::NYM_API) { + config + .get_base_mut() + .set_custom_nym_apis(config_common::parse_urls(&raw_validators)); + } + + let nym_api_endpoints = config.get_base().get_nym_api_endpoints(); + + let chosen_gateway_id = identity::PublicKey::from_base58_string(chosen_gateway_id) + .map_err(|_| BackendError::UnableToParseGateway)?; + + let mut key_manager = client_core::init::new_client_keys(); + + // Setup gateway and register a new key each time + let gateway = client_core::init::register_with_gateway( + &mut key_manager, + nym_api_endpoints, + Some(chosen_gateway_id), + ) + .await?; + + config.get_base_mut().with_gateway_endpoint(gateway); + + print_saved_config(&config); + + let address = *key_manager.identity_keypair().public_key(); + log::info!("The address of this client is: {}", address); + + Ok((config, key_manager)) +} + +fn print_saved_config(config: &Config) { + log::info!( + "Saved configuration file to {:?}", + config.get_socks5().get_config_file_save_location() + ); + log::info!("Gateway id: {}", config.get_base().get_gateway_id()); + log::info!("Gateway owner: {}", config.get_base().get_gateway_owner()); + log::info!( + "Gateway listener: {}", + config.get_base().get_gateway_listener() + ); + log::info!( + "Service provider address: {}", + config.get_socks5().get_provider_mix_address() + ); + log::info!( + "Service provider port: {}", + config.get_socks5().get_listening_port() + ); + log::info!("Client configuration completed."); +} diff --git a/nym-connect-android/src-tauri/src/error.rs b/nym-connect-android/src-tauri/src/error.rs new file mode 100644 index 0000000000..50a672f8ac --- /dev/null +++ b/nym-connect-android/src-tauri/src/error.rs @@ -0,0 +1,91 @@ +use client_core::error::ClientCoreError; +use serde::{Serialize, Serializer}; +use thiserror::Error; + +#[allow(unused)] +#[derive(Error, Debug)] +pub enum BackendError { + #[error("{source}")] + ReqwestError { + #[from] + source: reqwest::Error, + }, + #[error("I/O error: {source}")] + IoError { + #[from] + source: std::io::Error, + }, + #[error("string formatting error: {source}")] + FmtError { + #[from] + source: std::fmt::Error, + }, + #[error("tauri error: {source}")] + TauriError { + #[from] + source: tauri::Error, + }, + #[error("{source}")] + TauriApiError { + #[from] + source: tauri::api::Error, + }, + #[error("{source}")] + SerdeJsonError { + #[from] + source: serde_json::Error, + }, + #[error("{source}")] + ClientCoreError { + #[from] + source: ClientCoreError, + }, + #[error("{source}")] + ApiClientError { + #[from] + source: crate::operations::growth::api_client::ApiClientError, + }, + + #[error("could not send disconnect signal to the SOCKS5 client")] + CoundNotSendDisconnectSignal, + #[error("no service provider set")] + NoServiceProviderSet, + #[error("no gateway provider set")] + NoGatewaySet, + #[error("initialization failed with a panic")] + InitializationPanic, + #[error("could not get config id before gateway is set")] + CouldNotGetIdWithoutGateway, + #[error("could initialize without gateway set")] + CouldNotInitWithoutGateway, + #[error("could initialize without service provider set")] + CouldNotInitWithoutServiceProvider, + #[error("could not get file name")] + CouldNotGetFilename, + #[error("could not get config file location")] + CouldNotGetConfigFilename, + #[error("could not load existing gateway configuration")] + CouldNotLoadExistingGatewayConfiguration(std::io::Error), + #[error("unable to open a new window")] + NewWindowError, + #[error("unable to parse the specified gateway")] + UnableToParseGateway, + + #[error("HTTP get request failed: {status_code}")] + RequestFail { + url: reqwest::Url, + status_code: reqwest::StatusCode, + }, +} + +impl Serialize for BackendError { + fn serialize(&self, serializer: S) -> std::result::Result + where + S: Serializer, + { + serializer.collect_str(self) + } +} + +// Local crate level Result alias +pub(crate) type Result = std::result::Result; diff --git a/nym-connect-android/src-tauri/src/events.rs b/nym-connect-android/src-tauri/src/events.rs new file mode 100644 index 0000000000..13e1ca800d --- /dev/null +++ b/nym-connect-android/src-tauri/src/events.rs @@ -0,0 +1,71 @@ +use std::sync::Arc; + +use client_core::error::ClientCoreStatusMessage; +use task::manager::TaskStatus; +use tauri::async_runtime::RwLock; + +use crate::{ + state::{GatewayConnectivity, State}, + tasks, +}; + +#[derive(Clone, serde::Serialize)] +struct Payload { + title: String, + message: String, +} + +impl Payload { + fn new(title: String, message: String) -> Self { + Self { title, message } + } +} + +pub fn emit_event(event: &str, title: &str, msg: &str, window: &tauri::Window) { + if let Err(err) = window.emit(event, Payload::new(title.into(), msg.into())) { + log::error!("Failed to emit tauri event: {err}"); + } +} + +pub fn emit_status_event(event: &str, msg: &T, window: &tauri::Window) { + if let Err(err) = window.emit(event, Payload::new("SOCKS5 update".into(), msg.to_string())) { + log::error!("Failed to emit tauri event: {err}"); + } +} + +pub async fn handle_task_status( + task_status: &TaskStatus, + state: &Arc>, + window: &tauri::Window, +) { + match task_status { + TaskStatus::Ready => { + { + let mut state_w = state.write().await; + state_w.mark_connected(window); + } + + emit_status_event("socks5-connected-event", task_status, window); + tasks::start_connection_check(state.clone(), window.clone()); + } + } +} + +pub async fn handle_client_status_message( + client_status_message: &ClientCoreStatusMessage, + state: &Arc>, + window: &tauri::Window, +) { + // TODO: use this instead once we change on the frontend too + let _event_name = match client_status_message { + ClientCoreStatusMessage::GatewayIsSlow | ClientCoreStatusMessage::GatewayIsVerySlow => { + "socks5-gateway-status" + } + }; + + if let Ok(connectivity) = GatewayConnectivity::try_from(client_status_message) { + state.write().await.set_gateway_connectivity(connectivity); + } + + emit_status_event("socks5-status-event", client_status_message, window); +} diff --git a/nym-connect-android/src-tauri/src/lib.rs b/nym-connect-android/src-tauri/src/lib.rs new file mode 100644 index 0000000000..f4f42e2f12 --- /dev/null +++ b/nym-connect-android/src-tauri/src/lib.rs @@ -0,0 +1,98 @@ +// Remove me after we've tidied up +#![allow(dead_code)] +#![allow(unused_imports)] + +use config_common::defaults::setup_env; +use std::sync::Arc; +use tauri::{App, Manager}; +use tokio::sync::RwLock; + +pub mod config; +mod error; +mod events; +mod logging; +mod models; +pub mod operations; +mod state; +mod tasks; + +#[cfg(desktop)] +mod menu; +#[cfg(desktop)] +mod window; + +pub use state::State; + +#[cfg(mobile)] +mod mobile; +#[cfg(mobile)] +pub use mobile::*; + +pub type SetupHook = Box Result<(), Box> + Send>; + +#[derive(Default)] +pub struct AppBuilder { + setup: Option, +} + +impl AppBuilder { + pub fn new() -> Self { + Self::default() + } + + #[must_use] + pub fn setup(mut self, setup: F) -> Self + where + F: FnOnce(&mut App) -> Result<(), Box> + Send + 'static, + { + self.setup.replace(Box::new(setup)); + self + } + + pub fn run(self) { + setup_env(None); + + println!("Starting up***"); + + // As per breaking change description here + // https://github.com/tauri-apps/tauri/blob/feac1d193c6d618e49916ad0707201f43d5cdd36/tooling/bundler/CHANGELOG.md + if let Err(error) = fix_path_env::fix() { + log::warn!("Failed to fix PATH: {error}"); + } + + let setup = self.setup; + tauri::Builder::default() + .manage(Arc::new(RwLock::new(State::default()))) + .invoke_handler(tauri::generate_handler![ + crate::config::get_config_file_location, + crate::config::get_config_id, + crate::operations::connection::status::get_connection_status, + crate::operations::connection::connect::get_gateway, + crate::operations::connection::connect::get_service_provider, + crate::operations::connection::connect::set_gateway, + crate::operations::connection::connect::set_service_provider, + crate::operations::connection::connect::start_connecting, + crate::operations::connection::disconnect::start_disconnecting, + crate::operations::directory::get_services, + crate::operations::export::export_keys, + // crate::operations::window::hide_window, + // crate::operations::growth::test_and_earn::growth_tne_get_client_id, + // crate::operations::growth::test_and_earn::growth_tne_take_part, + // crate::operations::growth::test_and_earn::growth_tne_get_draws, + // crate::operations::growth::test_and_earn::growth_tne_ping, + // crate::operations::growth::test_and_earn::growth_tne_submit_wallet_address, + // crate::operations::growth::test_and_earn::growth_tne_enter_draw, + // crate::operations::growth::test_and_earn::growth_tne_toggle_window, + // crate::operations::help::log::help_log_toggle_window, + ]) + .setup(move |app| { + if let Some(setup) = setup { + logging::setup_logging(app.app_handle())?; + (setup)(app)?; + } + Ok(()) + }) + .run(tauri::generate_context!()) + .expect("error while running tauri application"); + } +} diff --git a/nym-connect-android/src-tauri/src/logging.rs b/nym-connect-android/src-tauri/src/logging.rs new file mode 100644 index 0000000000..804c1f2dae --- /dev/null +++ b/nym-connect-android/src-tauri/src/logging.rs @@ -0,0 +1,118 @@ +use std::str::FromStr; + +use fern::colors::{Color, ColoredLevelConfig}; +use serde::Serialize; +use serde_repr::{Deserialize_repr, Serialize_repr}; +use tauri::Manager; + +pub fn setup_logging(app_handle: tauri::AppHandle) -> Result<(), log::SetLoggerError> { + let colors = ColoredLevelConfig::new() + .trace(Color::Magenta) + .debug(Color::Blue) + .info(Color::Green) + .warn(Color::Yellow) + .error(Color::Red); + let base_config = fern::Dispatch::new() + .level(global_level()) + .filter_lowlevel_external_components() + .show_operations(); + + let stdout_config = fern::Dispatch::new() + .format(move |out, message, record| { + out.finish(format_args!( + "{}[{}][{}] {}", + chrono::Local::now().format("[%Y-%m-%d][%H:%M:%S]"), + record.target(), + colors.color(record.level()), + message, + )) + }) + .chain(std::io::stdout()); + + let tauri_event_config = fern::Dispatch::new() + .format(move |out, message, record| { + out.finish(format_args!( + "{}[{}] {}", + chrono::Local::now().format("[%Y-%m-%d][%H:%M:%S]"), + record.target(), + message, + )) + }) + .chain(fern::Output::call(move |record| { + let msg = LogMessage { + message: record.args().to_string(), + level: record.level().into(), + }; + app_handle.emit_all("log://log", msg).unwrap(); + })); + + base_config + .chain(stdout_config) + .chain(tauri_event_config) + .apply() +} + +trait FernExt { + fn show_operations(self) -> Self; + fn filter_lowlevel_external_components(self) -> Self; +} + +impl FernExt for fern::Dispatch { + fn show_operations(self) -> Self { + if ::std::env::var("RUST_TRACE_OPERATIONS").is_ok() { + self.level_for("nym_connect::operations", log::LevelFilter::Trace) + } else { + self + } + } + + fn filter_lowlevel_external_components(self) -> Self { + self.level_for("hyper", log::LevelFilter::Warn) + .level_for("tokio_reactor", log::LevelFilter::Warn) + .level_for("reqwest", log::LevelFilter::Warn) + .level_for("mio", log::LevelFilter::Warn) + .level_for("want", log::LevelFilter::Warn) + .level_for("sled", log::LevelFilter::Warn) + .level_for("tungstenite", log::LevelFilter::Warn) + .level_for("tokio_tungstenite", log::LevelFilter::Warn) + .level_for("rustls", log::LevelFilter::Warn) + .level_for("tokio_util", log::LevelFilter::Warn) + } +} + +fn global_level() -> log::LevelFilter { + if let Ok(s) = ::std::env::var("RUST_LOG") { + log::LevelFilter::from_str(&s).unwrap_or(log::LevelFilter::Info) + } else { + log::LevelFilter::Info + } +} + +#[derive(Debug, Serialize, Clone)] +struct LogMessage { + message: String, + level: LogLevel, +} + +// Serialize to u16 instead of strings. +#[derive(Debug, Clone, Deserialize_repr, Serialize_repr)] +#[repr(u16)] +enum LogLevel { + Trace = 1, + Debug, + Info, + Warn, + Error, +} + +impl From for LogLevel { + fn from(level: log::Level) -> Self { + match level { + log::Level::Trace => LogLevel::Trace, + log::Level::Debug => LogLevel::Debug, + log::Level::Info => LogLevel::Info, + log::Level::Warn => LogLevel::Warn, + log::Level::Error => LogLevel::Error, + } + } +} diff --git a/nym-connect-android/src-tauri/src/main.rs b/nym-connect-android/src-tauri/src/main.rs new file mode 100644 index 0000000000..e7b0e7c071 --- /dev/null +++ b/nym-connect-android/src-tauri/src/main.rs @@ -0,0 +1,16 @@ +#![cfg_attr( + all(not(debug_assertions), target_os = "windows"), + windows_subsystem = "windows" +)] + +use nym_connect_android::AppBuilder; + +fn main() { + // As per breaking change description here + // https://github.com/tauri-apps/tauri/blob/feac1d193c6d618e49916ad0707201f43d5cdd36/tooling/bundler/CHANGELOG.md + if let Err(error) = fix_path_env::fix() { + log::warn!("Failed to fix PATH: {error}"); + } + + AppBuilder::new().run(); +} diff --git a/nym-connect-android/src-tauri/src/menu.rs b/nym-connect-android/src-tauri/src/menu.rs new file mode 100644 index 0000000000..007d34d68f --- /dev/null +++ b/nym-connect-android/src-tauri/src/menu.rs @@ -0,0 +1,62 @@ +use tauri::{ + AppHandle, CustomMenuItem, Menu, Submenu, SystemTray, SystemTrayEvent, SystemTrayMenu, + SystemTrayMenuItem, Wry, +}; + +use crate::window::window_toggle; + +pub const SHOW_LOG_WINDOW: &str = "show_log_window"; +pub const CLEAR_STORAGE: &str = "clear_storage"; + +pub trait AddDefaultSubmenus { + fn add_default_app_submenus(self) -> Self; +} + +impl AddDefaultSubmenus for Menu { + fn add_default_app_submenus(self) -> Self { + let submenu = Submenu::new( + "Help", + Menu::new().add_item(CustomMenuItem::new(SHOW_LOG_WINDOW, "Show logs")), + ); + self.add_submenu(submenu) + } +} + +pub const TRAY_MENU_QUIT: &str = "quit"; +pub const TRAY_MENU_SHOW_HIDE: &str = "show-hide"; +pub const TRAY_MENU_CONNECTION: &str = "connection"; + +pub(crate) fn create_tray_menu() -> SystemTray { + let quit = CustomMenuItem::new(TRAY_MENU_QUIT, "Quit"); + let hide = CustomMenuItem::new(TRAY_MENU_SHOW_HIDE, "Hide"); + let connection = CustomMenuItem::new(TRAY_MENU_CONNECTION, "Connect"); + let tray_menu = SystemTrayMenu::new() + .add_item(hide) + .add_item(connection) + .add_native_item(SystemTrayMenuItem::Separator) + .add_item(quit); + + SystemTray::new().with_menu(tray_menu) +} + +pub(crate) fn tray_menu_event_handler(app: &AppHandle, event: SystemTrayEvent) { + match event { + SystemTrayEvent::LeftClick { position, size, .. } => { + println!("Event {position:?} {size:?}"); + } + SystemTrayEvent::MenuItemClick { id, .. } => { + println!("Event {id}"); + match id.as_str() { + TRAY_MENU_SHOW_HIDE => { + window_toggle(app); + } + TRAY_MENU_QUIT => { + // TODO: add disconnecting first + app.exit(0); + } + _ => {} + } + } + _ => {} + } +} diff --git a/nym-connect-android/src-tauri/src/mobile.rs b/nym-connect-android/src-tauri/src/mobile.rs new file mode 100644 index 0000000000..d231976e68 --- /dev/null +++ b/nym-connect-android/src-tauri/src/mobile.rs @@ -0,0 +1,4 @@ +#[tauri::mobile_entry_point] +fn main() { + super::AppBuilder::new().run(); +} diff --git a/nym-connect-android/src-tauri/src/models/mod.rs b/nym-connect-android/src-tauri/src/models/mod.rs new file mode 100644 index 0000000000..70b408dc7b --- /dev/null +++ b/nym-connect-android/src-tauri/src/models/mod.rs @@ -0,0 +1,116 @@ +use core::fmt; + +use serde::{Deserialize, Serialize}; + +use crate::state::GatewayConnectivity; + +#[cfg_attr(test, derive(ts_rs::TS))] +#[derive(Serialize, Deserialize, Clone, PartialEq, Eq, Debug)] +pub struct ConnectResult { + pub address: String, +} + +#[cfg_attr(test, derive(ts_rs::TS))] +#[derive(Serialize, Deserialize, Clone, PartialEq, Eq, Debug)] +pub struct DisconnectResult { + pub success: bool, +} + +#[cfg_attr(test, derive(ts_rs::TS))] +#[cfg_attr(test, ts(rename_all = "lowercase"))] +#[derive(Serialize, Deserialize, Clone, PartialEq, Eq, Debug, Default)] +#[serde(rename_all = "camelCase")] +pub enum ConnectionStatusKind { + #[default] + Disconnected, + Disconnecting, + Connected, + Connecting, +} + +#[cfg_attr(test, derive(ts_rs::TS))] +#[cfg_attr(test, ts(rename_all = "lowercase"))] +#[derive(Serialize, Deserialize, Clone, PartialEq, Eq, Debug)] +#[serde(rename_all = "camelCase")] +pub enum GatewayConnectionStatusKind { + Good, + Bad, + VeryBad, +} + +impl From for GatewayConnectionStatusKind { + fn from(conn: GatewayConnectivity) -> Self { + match conn { + GatewayConnectivity::Good => GatewayConnectionStatusKind::Good, + GatewayConnectivity::Bad { .. } => GatewayConnectionStatusKind::Bad, + GatewayConnectivity::VeryBad { .. } => GatewayConnectionStatusKind::VeryBad, + } + } +} + +#[cfg_attr(test, derive(ts_rs::TS))] +#[cfg_attr(test, ts(rename_all = "lowercase"))] +#[derive(Serialize, Deserialize, Clone, Copy, PartialEq, Eq, Debug, Default)] +pub enum ConnectivityTestResult { + #[default] + NotAvailable, + Success, + Fail, +} + +impl fmt::Display for ConnectionStatusKind { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match *self { + ConnectionStatusKind::Disconnected => write!(f, "Disconnected"), + ConnectionStatusKind::Disconnecting => write!(f, "Disconnecting"), + ConnectionStatusKind::Connected => write!(f, "Connected"), + ConnectionStatusKind::Connecting => write!(f, "Connecting"), + } + } +} + +pub const APP_EVENT_CONNECTION_STATUS_CHANGED: &str = "app:connection-status-changed"; + +#[cfg_attr(test, derive(ts_rs::TS))] +#[derive(Clone, Serialize)] +pub struct AppEventConnectionStatusChangedPayload { + pub status: ConnectionStatusKind, +} + +#[cfg_attr(test, derive(ts_rs::TS))] +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct DirectoryService { + pub id: String, + pub description: String, + pub items: Vec, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct HarbourMasterService { + pub service_provider_client_id: String, + pub gateway_identity_key: String, + pub ip_address: String, + pub last_successful_ping_utc: String, + pub last_updated_utc: String, + pub routing_score: f32, +} + +#[cfg_attr(test, derive(ts_rs::TS))] +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct DirectoryServiceProvider { + pub id: String, + pub description: String, + /// Address of the network requester in the form "." + /// e.g. DpB3cHAchJiNBQi5FrZx2csXb1mrHkpYh9Wzf8Rjsuko.ANNWrvHqMYuertHGHUrZdBntQhpzfbWekB39qez9U2Vx@2BuMSfMW3zpeAjKXyKLhmY4QW1DXurrtSPEJ6CjX3SEh + pub address: String, + /// Address of the gateway, e.g. 2BuMSfMW3zpeAjKXyKLhmY4QW1DXurrtSPEJ6CjX3SEh + pub gateway: String, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct PagedResult { + pub page: u32, + pub size: u32, + pub total: i32, + pub items: Vec, +} diff --git a/nym-connect-android/src-tauri/src/operations/connection/connect.rs b/nym-connect-android/src-tauri/src/operations/connection/connect.rs new file mode 100644 index 0000000000..1d83748698 --- /dev/null +++ b/nym-connect-android/src-tauri/src/operations/connection/connect.rs @@ -0,0 +1,68 @@ +use crate::{ + error::{BackendError, Result}, + models::ConnectResult, + tasks, State, +}; +use std::sync::Arc; +use tokio::sync::RwLock; + +#[tauri::command] +pub async fn start_connecting( + state: tauri::State<'_, Arc>>, + window: tauri::Window, +) -> Result { + log::trace!("Start connecting"); + let (msg_receiver, exit_status_receiver) = { + let mut state_w = state.write().await; + state_w.start_connecting(&window).await? + }; + + // Setup task for checking status + let state = state.inner().clone(); + tasks::start_disconnect_listener(state.clone(), window.clone(), exit_status_receiver); + tasks::start_status_listener(state, window.clone(), msg_receiver); + + Ok(ConnectResult { + address: "PLACEHOLDER".to_string(), + }) +} + +#[tauri::command] +pub async fn get_service_provider(state: tauri::State<'_, Arc>>) -> Result { + let guard = state.read().await; + guard + .get_service_provider() + .clone() + .ok_or(BackendError::NoServiceProviderSet) +} + +#[tauri::command] +pub async fn set_service_provider( + service_provider: Option, + state: tauri::State<'_, Arc>>, +) -> Result<()> { + log::trace!("Setting service_provider: {:?}", &service_provider); + let mut guard = state.write().await; + guard.set_service_provider(service_provider); + Ok(()) +} + +#[tauri::command] +pub async fn get_gateway(state: tauri::State<'_, Arc>>) -> Result { + let guard = state.read().await; + guard + .get_gateway() + .clone() + .ok_or(BackendError::NoGatewaySet) +} + +#[tauri::command] +pub async fn set_gateway( + gateway: Option, + state: tauri::State<'_, Arc>>, +) -> Result<()> { + log::trace!("Setting gateway: {:?}", &gateway); + let mut guard = state.write().await; + guard.set_gateway(gateway); + Ok(()) +} diff --git a/nym-connect-android/src-tauri/src/operations/connection/disconnect.rs b/nym-connect-android/src-tauri/src/operations/connection/disconnect.rs new file mode 100644 index 0000000000..b75bf49410 --- /dev/null +++ b/nym-connect-android/src-tauri/src/operations/connection/disconnect.rs @@ -0,0 +1,20 @@ +use crate::error::Result; +use crate::models::ConnectResult; +use crate::State; +use std::sync::Arc; +use tokio::sync::RwLock; + +#[tauri::command] +pub async fn start_disconnecting( + state: tauri::State<'_, Arc>>, + window: tauri::Window, +) -> Result { + log::trace!("Start disconnecting"); + let mut guard = state.write().await; + + guard.start_disconnecting(&window).await?; + + Ok(ConnectResult { + address: "PLACEHOLDER".to_string(), + }) +} diff --git a/nym-connect-android/src-tauri/src/operations/connection/health_check.rs b/nym-connect-android/src-tauri/src/operations/connection/health_check.rs new file mode 100644 index 0000000000..3f401933a1 --- /dev/null +++ b/nym-connect-android/src-tauri/src/operations/connection/health_check.rs @@ -0,0 +1,26 @@ +use serde::{Deserialize, Serialize}; + +static HEALTH_CHECK_URL: &str = "https://nymtech.net/.wellknown/connect/healthcheck.json"; + +#[derive(Serialize, Deserialize, Debug)] +struct ConnectionSuccess { + status: String, +} + +pub async fn run_health_check() -> bool { + log::info!("Running network health check"); + match crate::operations::http::socks5_get::<_, ConnectionSuccess>(HEALTH_CHECK_URL).await { + Ok(res) if res.status == "ok" => { + log::info!("Healthcheck success!"); + true + } + Ok(res) => { + log::error!("Healthcheck failed with status: {}", res.status); + false + } + Err(err) => { + log::error!("Healthcheck failed: {err}"); + false + } + } +} diff --git a/nym-connect-android/src-tauri/src/operations/connection/mod.rs b/nym-connect-android/src-tauri/src/operations/connection/mod.rs new file mode 100644 index 0000000000..171d07cd83 --- /dev/null +++ b/nym-connect-android/src-tauri/src/operations/connection/mod.rs @@ -0,0 +1,4 @@ +pub mod connect; +pub mod disconnect; +pub mod health_check; +pub mod status; diff --git a/nym-connect-android/src-tauri/src/operations/connection/status.rs b/nym-connect-android/src-tauri/src/operations/connection/status.rs new file mode 100644 index 0000000000..7710524fb5 --- /dev/null +++ b/nym-connect-android/src-tauri/src/operations/connection/status.rs @@ -0,0 +1,44 @@ +use crate::error::Result; +use crate::tasks; +use std::sync::Arc; + +use tokio::sync::RwLock; + +use crate::models::{ConnectionStatusKind, ConnectivityTestResult, GatewayConnectionStatusKind}; +use crate::state::State; + +#[tauri::command] +pub async fn get_connection_status( + state: tauri::State<'_, Arc>>, +) -> Result { + let state = state.read().await; + Ok(state.get_status()) +} + +#[tauri::command] +pub async fn get_gateway_connection_status( + state: tauri::State<'_, Arc>>, +) -> Result { + let mut state_w = state.write().await; + let gateway_connectivity = state_w.get_gateway_connectivity(); + Ok(gateway_connectivity.into()) +} + +#[tauri::command] +pub async fn get_connection_health_check_status( + state: tauri::State<'_, Arc>>, +) -> Result { + let state = state.read().await; + Ok(state.get_connectivity_test_result()) +} + +// Start a connection check task. This should return with an event within one minute, and update +// the state. +// Trying to run multiple concurrent connection checks probably works but is not supported. +#[tauri::command] +pub fn start_connection_health_check_task( + state: tauri::State<'_, Arc>>, + window: tauri::Window, +) { + tasks::start_connection_check(state.inner().clone(), window); +} diff --git a/nym-connect-android/src-tauri/src/operations/directory/mod.rs b/nym-connect-android/src-tauri/src/operations/directory/mod.rs new file mode 100644 index 0000000000..efc2ba175b --- /dev/null +++ b/nym-connect-android/src-tauri/src/operations/directory/mod.rs @@ -0,0 +1,51 @@ +use itertools::Itertools; + +use crate::error::Result; +use crate::models::{DirectoryService, HarbourMasterService, PagedResult}; + +static SERVICE_PROVIDER_WELLKNOWN_URL: &str = + "https://nymtech.net/.wellknown/connect/service-providers.json"; + +static HARBOUR_MASTER_URL: &str = "https://harbourmaster.nymtech.net/v1/services/?size=100"; + +#[tauri::command] +pub async fn get_services() -> Result> { + log::trace!("Fetching services"); + let res = reqwest::get(SERVICE_PROVIDER_WELLKNOWN_URL) + .await? + .json::>() + .await?; + log::trace!("Received: {:#?}", res); + + // TODO: get paged + log::trace!("Fetching active services"); + let active_services = reqwest::get(HARBOUR_MASTER_URL) + .await? + .json::>() + .await?; + log::trace!("Active: {:#?}", active_services); + + let mut filtered: Vec = vec![]; + + for service in &res { + let items: _ = service + .items + .clone() + .into_iter() + .filter(|sp| { + active_services + .items + .iter() + .any(|active| active.service_provider_client_id == sp.address) + }) + .collect_vec(); + log::trace!("service = {} has {} items", service.id, items.len()); + filtered.push(DirectoryService { + id: service.id.clone(), + description: service.description.clone(), + items, + }) + } + + Ok(filtered) +} diff --git a/nym-connect-android/src-tauri/src/operations/export.rs b/nym-connect-android/src-tauri/src/operations/export.rs new file mode 100644 index 0000000000..93c2e81bc8 --- /dev/null +++ b/nym-connect-android/src-tauri/src/operations/export.rs @@ -0,0 +1,91 @@ +use std::{ffi::OsStr, fs, sync::Arc}; +use tokio::sync::RwLock; + +use crate::{ + error::{BackendError, Result}, + state::State, +}; +use client_core::client::key_manager::KeyManager; +use client_core::config::persistence::key_pathfinder::ClientKeyPathfinder; +use crypto::asymmetric::identity; + +pub async fn get_identity_key( + state: &tauri::State<'_, Arc>>, +) -> Result> { + let config = { + let state = state.read().await; + state.load_socks5_config()? + }; + + let pathfinder = ClientKeyPathfinder::new_from_config(config.get_base()); + let key_manager = KeyManager::load_keys(&pathfinder)?; + let identity_keypair = key_manager.identity_keypair(); + + Ok(identity_keypair) +} + +/// Export the gateway keys as a JSON string blob +#[tauri::command] +pub async fn export_keys(state: tauri::State<'_, Arc>>) -> Result { + let config = { + let state = state.read().await; + state.load_socks5_config()? + }; + + // Get key paths + let ack_key_file = config.get_base().get_ack_key_file(); + let gateway_shared_key_file = config.get_base().get_gateway_shared_key_file(); + + let pub_id_key_file = config.get_base().get_public_identity_key_file(); + let priv_id_key_file = config.get_base().get_private_identity_key_file(); + + let pub_enc_key_file = config.get_base().get_public_encryption_key_file(); + let priv_enc_key_file = config.get_base().get_private_encryption_key_file(); + + // Read file contents + let ack_key = fs::read_to_string(ack_key_file.clone())?; + let gateway_shared_key = fs::read_to_string(gateway_shared_key_file.clone())?; + + let pub_id_key = fs::read_to_string(pub_id_key_file.clone())?; + let priv_id_key = fs::read_to_string(priv_id_key_file.clone())?; + + let pub_enc_key = fs::read_to_string(pub_enc_key_file.clone())?; + let priv_enc_key = fs::read_to_string(priv_enc_key_file.clone())?; + + let ack_key_file = ack_key_file + .file_name() + .map(OsStr::to_string_lossy) + .ok_or(BackendError::CouldNotGetFilename)?; + let gateway_shared_key_file = gateway_shared_key_file + .file_name() + .map(OsStr::to_string_lossy) + .ok_or(BackendError::CouldNotGetFilename)?; + let pub_id_key_file = pub_id_key_file + .file_name() + .map(OsStr::to_string_lossy) + .ok_or(BackendError::CouldNotGetFilename)?; + let priv_id_key_file = priv_id_key_file + .file_name() + .map(OsStr::to_string_lossy) + .ok_or(BackendError::CouldNotGetFilename)?; + let pub_enc_key_file = pub_enc_key_file + .file_name() + .map(OsStr::to_string_lossy) + .ok_or(BackendError::CouldNotGetFilename)?; + let priv_enc_key_file = priv_enc_key_file + .file_name() + .map(OsStr::to_string_lossy) + .ok_or(BackendError::CouldNotGetFilename)?; + + // Format and return as json + let json = serde_json::json!({ + ack_key_file: ack_key, + gateway_shared_key_file: gateway_shared_key, + pub_id_key_file: pub_id_key, + priv_id_key_file: priv_id_key, + pub_enc_key_file: pub_enc_key, + priv_enc_key_file: priv_enc_key, + }); + + Ok(serde_json::to_string_pretty(&json)?) +} diff --git a/nym-connect-android/src-tauri/src/operations/growth/api_client.rs b/nym-connect-android/src-tauri/src/operations/growth/api_client.rs new file mode 100644 index 0000000000..c069cb6a9d --- /dev/null +++ b/nym-connect-android/src-tauri/src/operations/growth/api_client.rs @@ -0,0 +1,270 @@ +use serde::de::DeserializeOwned; +use serde::{Deserialize, Serialize}; +use thiserror::Error; + +#[allow(unused)] +#[derive(Error, Debug)] +pub enum ApiClientError { + #[error("{source}")] + Reqwest { + #[from] + source: reqwest::Error, + }, + #[error("{source}")] + SerdeJson { + #[from] + source: serde_json::Error, + }, + #[error("{0}")] + Status(String), +} + +const API_BASE_URL: &str = "https://growth-api.nymtech.net"; + +// For development mode, switch to this +// const API_BASE_URL: &str = "http://localhost:8000"; + +#[derive(Debug, Clone)] +pub struct GrowthApiClient { + base_url: String, +} + +impl GrowthApiClient { + pub fn new(resource_base: &str) -> Self { + let base_url = std::env::var("API_BASE_URL").unwrap_or_else(|_| API_BASE_URL.to_string()); + GrowthApiClient { + base_url: format!("{base_url}{resource_base}"), + } + } + + pub fn registrations() -> Registrations { + Registrations::new(GrowthApiClient::new("/v1/tne")) + } + + pub fn daily_draws() -> DailyDraws { + DailyDraws::new(GrowthApiClient::new("/v1/tne/daily_draw")) + } + + pub(crate) async fn get(&self, url: &str) -> Result { + log::info!(">>> GET {}", url); + let proxy = reqwest::Proxy::all("socks5h://127.0.0.1:1080")?; + let client = reqwest::Client::builder() + .proxy(proxy) + .timeout(std::time::Duration::from_secs(10)) + .build()?; + + match client.get(format!("{}{}", self.base_url, url)).send().await { + Ok(res) => { + if res.status().is_client_error() || res.status().is_server_error() { + log::error!("<<< {}", res.status()); + return Err(ApiClientError::Status(res.status().to_string())); + } + match res.text().await { + Ok(response_body) => { + log::info!("<<< {}", response_body); + match serde_json::from_str(&response_body) { + Ok(res) => Ok(res), + Err(e) => { + log::error!("<<< JSON parsing error: {}", e); + Err(e.into()) + } + } + } + Err(e) => { + log::error!("<<< Request error: {}", e); + Err(e.into()) + } + } + } + Err(e) => { + log::error!("<<< Response parsing error: {}", e); + Err(e.into()) + } + } + } + + // TODO: use the method in `operations::http` instead + pub(crate) async fn post( + &self, + url: &str, + body: &REQ, + ) -> Result { + log::info!(">>> POST {}", url); + let proxy = reqwest::Proxy::all("socks5h://127.0.0.1:1080")?; + let client = reqwest::Client::builder() + .proxy(proxy) + .timeout(std::time::Duration::from_secs(10)) + .build()?; + + match client + .post(format!("{}{}", self.base_url, url)) + .json(body) + .send() + .await + { + Ok(res) => { + if res.status().is_client_error() || res.status().is_server_error() { + log::error!("<<< {}", res.status()); + return Err(ApiClientError::Status(res.status().to_string())); + } + match res.text().await { + Ok(response_body) => { + log::info!("<<< {}", response_body); + match serde_json::from_str(&response_body) { + Ok(res) => Ok(res), + Err(e) => { + log::error!("<<< JSON parsing error: {}", e); + Err(e.into()) + } + } + } + Err(e) => { + log::error!("<<< Request error: {}", e); + Err(e.into()) + } + } + } + Err(e) => { + log::error!("<<< Response parsing error: {}", e); + Err(e.into()) + } + } + } +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct ClientIdPartial { + pub client_id: String, + pub client_id_signature: String, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct Registration { + pub id: String, + pub client_id: String, + pub timestamp: String, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct Ping { + pub client_id: String, + pub timestamp: String, +} + +pub struct Registrations { + client: GrowthApiClient, +} + +impl Registrations { + pub fn new(client: GrowthApiClient) -> Self { + Registrations { client } + } + + pub async fn register( + &self, + registration: &ClientIdPartial, + ) -> Result { + self.client.post("/register", ®istration).await + } + + #[allow(dead_code)] + pub async fn unregister(&self, registration: &ClientIdPartial) -> Result<(), ApiClientError> { + self.client.post("/unregister", ®istration).await + } + + #[allow(dead_code)] + pub async fn status(&self, registration: &ClientIdPartial) -> Result<(), ApiClientError> { + self.client.post("/status", ®istration).await + } + + pub async fn ping(&self, registration: &ClientIdPartial) -> Result<(), ApiClientError> { + self.client.post("/ping", ®istration).await + } + + #[allow(dead_code)] + pub async fn health(&self) -> Result<(), ApiClientError> { + self.client.get("/health").await + } +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct DrawEntryPartial { + pub draw_id: String, + pub client_id: String, + pub client_id_signature: String, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct DrawEntry { + pub id: String, + pub draw_id: String, + pub timestamp: String, + pub status: Option, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct DrawWithWordOfTheDay { + pub id: String, + pub start_utc: String, + pub end_utc: String, + pub word_of_the_day: Option, + pub last_modified: String, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct ClaimPartial { + pub draw_id: String, + pub registration_id: String, + pub client_id: String, + pub client_id_signature: String, + pub wallet_address: String, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct Winner { + pub id: String, + pub client_id: String, + pub draw_id: String, + pub timestamp: String, + pub winner_reg_id: String, + pub winner_wallet_address: Option, + pub winner_claim_timestamp: Option, +} + +pub struct DailyDraws { + client: GrowthApiClient, +} + +impl DailyDraws { + pub fn new(client: GrowthApiClient) -> Self { + DailyDraws { client } + } + + pub async fn current(&self) -> Result { + self.client.get("/current").await + } + + pub async fn next(&self) -> Result { + self.client.get("/next").await + } + + #[allow(dead_code)] + pub async fn status(&self, draw_id: &str) -> Result { + self.client.get(format!("/status/{draw_id}").as_str()).await + } + + pub async fn enter(&self, entry: &DrawEntryPartial) -> Result { + self.client.post("/enter", entry).await + } + + pub async fn entries( + &self, + client_id: &ClientIdPartial, + ) -> Result, ApiClientError> { + self.client.post("/entries", client_id).await + } + + pub async fn claim(&self, claim: &ClaimPartial) -> Result { + self.client.post("/claim", claim).await + } +} diff --git a/nym-connect-android/src-tauri/src/operations/growth/assets.rs b/nym-connect-android/src-tauri/src/operations/growth/assets.rs new file mode 100644 index 0000000000..752b221916 --- /dev/null +++ b/nym-connect-android/src-tauri/src/operations/growth/assets.rs @@ -0,0 +1,57 @@ +use rust_embed::RustEmbed; +extern crate yaml_rust; +use yaml_rust::YamlLoader; + +#[derive(RustEmbed)] +#[folder = "../src/components/Growth/content/"] +#[include = "*.yaml"] +#[exclude = "*.mdx"] +struct Asset; + +#[derive(Debug)] +pub struct NotificationContent { + pub title: String, + pub body: String, +} + +#[derive(Debug)] +pub struct Notifications { + pub you_are_in_draw: NotificationContent, + pub take_part: NotificationContent, +} + +pub struct Content {} + +const RESOURCE_ERROR: &str = "❌ RESOURCE ERROR"; + +fn get_as_string_or_error_message(value: &yaml_rust::Yaml) -> String { + value.as_str().unwrap_or(RESOURCE_ERROR).to_string() +} + +impl Content { + pub fn get_notifications() -> Notifications { + let content = Asset::get("en.yaml").unwrap(); + let s = std::str::from_utf8(content.data.as_ref()).unwrap(); + let content = YamlLoader::load_from_str(s).unwrap(); + let content = &content[0]; + + Notifications { + you_are_in_draw: NotificationContent { + title: get_as_string_or_error_message( + &content["testAndEarn"]["notifications"]["youAreInDraw"]["title"], + ), + body: get_as_string_or_error_message( + &content["testAndEarn"]["notifications"]["youAreInDraw"]["body"], + ), + }, + take_part: NotificationContent { + title: get_as_string_or_error_message( + &content["testAndEarn"]["notifications"]["takePart"]["title"], + ), + body: get_as_string_or_error_message( + &content["testAndEarn"]["notifications"]["takePart"]["body"], + ), + }, + } + } +} diff --git a/nym-connect-android/src-tauri/src/operations/growth/mod.rs b/nym-connect-android/src-tauri/src/operations/growth/mod.rs new file mode 100644 index 0000000000..449c1bcee2 --- /dev/null +++ b/nym-connect-android/src-tauri/src/operations/growth/mod.rs @@ -0,0 +1,3 @@ +pub mod api_client; +pub mod assets; +pub mod test_and_earn; diff --git a/nym-connect-android/src-tauri/src/operations/growth/test_and_earn.rs b/nym-connect-android/src-tauri/src/operations/growth/test_and_earn.rs new file mode 100644 index 0000000000..0162fa9827 --- /dev/null +++ b/nym-connect-android/src-tauri/src/operations/growth/test_and_earn.rs @@ -0,0 +1,159 @@ +use crate::error::BackendError; +use crate::operations::export::get_identity_key; +use crate::operations::growth::api_client::{ + ClaimPartial, ClientIdPartial, DrawEntry, DrawEntryPartial, DrawWithWordOfTheDay, + GrowthApiClient, Registration, Winner, +}; +use crate::State; +use serde::{Deserialize, Serialize}; +use std::sync::Arc; +#[cfg(desktop)] +use tauri::api::notification::Notification; +use tauri::Manager; +use tokio::sync::RwLock; + +async fn get_client_id( + state: &tauri::State<'_, Arc>>, +) -> Result { + let keypair = get_identity_key(state).await?; + let client_id = keypair.public_key().to_base58_string(); + let client_id_signature = keypair + .private_key() + .sign(client_id.as_bytes()) + .to_base58_string(); + Ok(ClientIdPartial { + client_id, + client_id_signature, + }) +} + +#[tauri::command] +pub async fn growth_tne_get_client_id( + state: tauri::State<'_, Arc>>, +) -> Result { + get_client_id(&state).await +} + +#[tauri::command] +pub async fn growth_tne_take_part( + app_handle: tauri::AppHandle, + state: tauri::State<'_, Arc>>, +) -> Result { + let notifications = super::assets::Content::get_notifications(); + + let client_id = get_client_id(&state).await?; + let registration = GrowthApiClient::registrations() + .register(&client_id) + .await?; + + log::info!("<<< Test&Earn: registration details: {:?}", registration); + + #[cfg(desktop)] + if let Err(e) = Notification::new(&app_handle.config().tauri.bundle.identifier) + .title(notifications.take_part.title) + .body(notifications.take_part.body) + .show() + { + log::error!("Could not show notification. Error = {:?}", e); + } + + Ok(registration) +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct Draws { + pub current: Option, + pub next: Option, + pub draws: Vec, +} + +#[tauri::command] +pub async fn growth_tne_get_draws(client_details: ClientIdPartial) -> Result { + let draws_api = GrowthApiClient::daily_draws(); + + let current = draws_api.current().await.ok(); + let next = draws_api.next().await.ok(); + let draws = draws_api.entries(&client_details).await?; + + Ok(Draws { + current, + next, + draws, + }) +} + +#[tauri::command] +pub async fn growth_tne_enter_draw( + client_details: ClientIdPartial, + draw_id: String, +) -> Result { + Ok(GrowthApiClient::daily_draws() + .enter(&DrawEntryPartial { + draw_id, + client_id: client_details.client_id, + client_id_signature: client_details.client_id_signature, + }) + .await?) +} + +#[tauri::command] +pub async fn growth_tne_submit_wallet_address( + client_details: ClientIdPartial, + draw_id: String, + wallet_address: String, + registration_id: String, +) -> Result { + Ok(GrowthApiClient::daily_draws() + .claim(&ClaimPartial { + draw_id, + client_id: client_details.client_id, + client_id_signature: client_details.client_id_signature, + wallet_address, + registration_id, + }) + .await?) +} + +#[tauri::command] +pub async fn growth_tne_ping(client_details: ClientIdPartial) -> Result<(), BackendError> { + log::info!("Test&Earn is sending a ping..."); + Ok(GrowthApiClient::registrations() + .ping(&client_details) + .await?) +} + +#[cfg(desktop)] +#[tauri::command] +pub async fn growth_tne_toggle_window( + app_handle: tauri::AppHandle, + window_title: Option, +) -> Result<(), BackendError> { + if let Some(window) = app_handle.windows().get("growth") { + log::info!("Closing growth window..."); + if let Err(e) = window.close() { + log::error!("Unable to close growth window: {:?}", e); + } + return Ok(()); + } + + log::info!("Creating growth window..."); + match tauri::WindowBuilder::new( + &app_handle, + "growth", + tauri::WindowUrl::App("growth.html".into()), + ) + .title(window_title.unwrap_or_else(|| "NymConnect Test&Earn".to_string())) + .build() + { + Ok(window) => { + if let Err(e) = window.set_focus() { + log::error!("Unable to focus growth window: {:?}", e); + } + Ok(()) + } + Err(e) => { + log::error!("Unable to create growth window: {:?}", e); + Err(BackendError::NewWindowError) + } + } +} diff --git a/nym-connect-android/src-tauri/src/operations/help/log.rs b/nym-connect-android/src-tauri/src/operations/help/log.rs new file mode 100644 index 0000000000..5b31ea023e --- /dev/null +++ b/nym-connect-android/src-tauri/src/operations/help/log.rs @@ -0,0 +1,31 @@ +use crate::error::BackendError; +use tauri::Manager; + +#[cfg(desktop)] +#[tauri::command] +pub fn help_log_toggle_window(app_handle: tauri::AppHandle) -> Result<(), BackendError> { + if let Some(current_log_window) = app_handle.windows().get("log") { + log::info!("Closing log window..."); + if let Err(e) = current_log_window.close() { + log::error!("Unable to close log window: {:?}", e); + } + return Ok(()); + } + + log::info!("Creating log window..."); + match tauri::WindowBuilder::new(&app_handle, "log", tauri::WindowUrl::App("log.html".into())) + .title("Nym Connect Logs") + .build() + { + Ok(window) => { + if let Err(e) = window.set_focus() { + log::error!("Unable to focus log window: {:?}", e); + } + Ok(()) + } + Err(e) => { + log::error!("Unable to create log window: {:?}", e); + Err(BackendError::NewWindowError) + } + } +} diff --git a/nym-connect-android/src-tauri/src/operations/help/mod.rs b/nym-connect-android/src-tauri/src/operations/help/mod.rs new file mode 100644 index 0000000000..52dafe6d0f --- /dev/null +++ b/nym-connect-android/src-tauri/src/operations/help/mod.rs @@ -0,0 +1,2 @@ +pub mod log; +pub mod storage; diff --git a/nym-connect-android/src-tauri/src/operations/help/storage.rs b/nym-connect-android/src-tauri/src/operations/help/storage.rs new file mode 100644 index 0000000000..ab47ffa529 --- /dev/null +++ b/nym-connect-android/src-tauri/src/operations/help/storage.rs @@ -0,0 +1,20 @@ +use crate::error::BackendError; +use serde::Serialize; +use tauri::Manager; + +#[derive(Debug, Serialize, Clone)] +struct ClearStorageEvent { + kind: String, +} + +#[tauri::command] +pub fn help_clear_storage(app_handle: tauri::AppHandle) -> Result<(), BackendError> { + log::info!("Sending event to clear local storage..."); + + let event = ClearStorageEvent { + kind: "local_storage".to_string(), + }; + app_handle.emit_all("help://clear-storage", event)?; + + Ok(()) +} diff --git a/nym-connect-android/src-tauri/src/operations/http.rs b/nym-connect-android/src-tauri/src/operations/http.rs new file mode 100644 index 0000000000..5cfe3e1f53 --- /dev/null +++ b/nym-connect-android/src-tauri/src/operations/http.rs @@ -0,0 +1,37 @@ +use crate::error::{BackendError, Result}; +use serde::de::DeserializeOwned; +use tap::TapFallible; + +pub async fn socks5_get(url: U) -> Result +where + U: reqwest::IntoUrl + std::fmt::Display, + T: DeserializeOwned, +{ + log::info!(">>> GET {url}"); + let proxy = reqwest::Proxy::all("socks5h://127.0.0.1:1080")?; + let client = reqwest::Client::builder() + .proxy(proxy) + .timeout(std::time::Duration::from_secs(20)) + .build()?; + + let resp = client.get(url).send().await.tap_err(|err| { + log::error!("<<< Request send error: {err}"); + })?; + + if resp.status().is_client_error() || resp.status().is_server_error() { + log::error!("<<< {}", resp.status()); + return Err(BackendError::RequestFail { + url: resp.url().clone(), + status_code: resp.status(), + }); + } + + let response_body = resp.text().await.tap_err(|err| { + log::error!("<<< Request error: {err}"); + })?; + log::info!("<<< {response_body}"); + + Ok(serde_json::from_str(&response_body).tap_err(|err| { + log::error!("<<< JSON parsing error: {err}"); + })?) +} diff --git a/nym-connect-android/src-tauri/src/operations/mod.rs b/nym-connect-android/src-tauri/src/operations/mod.rs new file mode 100644 index 0000000000..4a79ddd1b4 --- /dev/null +++ b/nym-connect-android/src-tauri/src/operations/mod.rs @@ -0,0 +1,8 @@ +pub mod connection; +pub mod directory; +pub mod export; +pub mod growth; +pub mod help; +pub mod http; +#[cfg(desktop)] +pub mod window; diff --git a/nym-connect-android/src-tauri/src/operations/window/mod.rs b/nym-connect-android/src-tauri/src/operations/window/mod.rs new file mode 100644 index 0000000000..b89cdb88ff --- /dev/null +++ b/nym-connect-android/src-tauri/src/operations/window/mod.rs @@ -0,0 +1,7 @@ +use crate::window::window_hide; +use tauri::{AppHandle, Wry}; + +#[tauri::command] +pub fn hide_window(app: AppHandle) { + window_hide(&app); +} diff --git a/nym-connect-android/src-tauri/src/state.rs b/nym-connect-android/src-tauri/src/state.rs new file mode 100644 index 0000000000..f98dc3baea --- /dev/null +++ b/nym-connect-android/src-tauri/src/state.rs @@ -0,0 +1,267 @@ +use std::time::Duration; + +use ::config_common::NymConfig; +use client_core::client::key_manager::KeyManager; +use client_core::error::ClientCoreStatusMessage; +use futures::SinkExt; +use tap::TapFallible; +use tauri::Manager; + +use nym_socks5::client::{ + config::Config as Socks5Config, Socks5ControlMessage, Socks5ControlMessageSender, +}; +use tokio::time::Instant; + +use crate::{ + config::{self, socks5_config_id_appended_with, Config}, + error::{BackendError, Result}, + models::{ + AppEventConnectionStatusChangedPayload, ConnectionStatusKind, ConnectivityTestResult, + APP_EVENT_CONNECTION_STATUS_CHANGED, + }, + tasks::{self, ExitStatusReceiver}, +}; + +// The client will emit messages if the connection to the gateway is poor (or the gateway can't +// keep up with the messages we are sendind). If no messages about this has been received for a +// certain duration then we assume it's all good. +const GATEWAY_CONNECTIVITY_TIMEOUT_SECS: u64 = 20; + +#[derive(Clone, Copy, Default)] +pub enum GatewayConnectivity { + #[default] + Good, + Bad { + when: Instant, + }, + VeryBad { + when: Instant, + }, +} + +impl TryFrom<&ClientCoreStatusMessage> for GatewayConnectivity { + type Error = BackendError; + + fn try_from(value: &ClientCoreStatusMessage) -> Result { + let conn = match value { + ClientCoreStatusMessage::GatewayIsSlow => GatewayConnectivity::Bad { + when: Instant::now(), + }, + ClientCoreStatusMessage::GatewayIsVerySlow => GatewayConnectivity::VeryBad { + when: Instant::now(), + }, + }; + Ok(conn) + } +} + +#[derive(Default)] +pub struct State { + /// The current connection status + status: ConnectionStatusKind, + + /// The service provider + service_provider: Option, + + /// The gateway used. Note that this is also used to create the configuration id + gateway: Option, + + /// Channel that is used to send command messages to the SOCKS5 client, such as to disconnect + socks5_client_sender: Option, + + /// The client will periodically report connectivity to the gateway it's connected to. Unless + /// we get a status message from the client we assume it's good. + gateway_connectivity: GatewayConnectivity, + + /// The latest end-to-end connectivity test result. The first test is initiated on connection + /// established. Additional tests can be triggered. + connectivity_test_result: ConnectivityTestResult, +} + +impl State { + pub fn new() -> Self { + State { + status: ConnectionStatusKind::Disconnected, + service_provider: None, + gateway: None, + socks5_client_sender: None, + gateway_connectivity: GatewayConnectivity::Good, + connectivity_test_result: ConnectivityTestResult::NotAvailable, + } + } + + pub fn get_gateway_connectivity(&mut self) -> GatewayConnectivity { + self.gateway_connectivity = match self.gateway_connectivity { + c @ (GatewayConnectivity::Bad { when } | GatewayConnectivity::VeryBad { when }) => { + if Instant::now() > when + Duration::from_secs(GATEWAY_CONNECTIVITY_TIMEOUT_SECS) { + GatewayConnectivity::Good + } else { + c + } + } + current => current, + }; + self.gateway_connectivity + } + + pub fn set_gateway_connectivity(&mut self, gateway_connectivity: GatewayConnectivity) { + self.gateway_connectivity = gateway_connectivity + } + + pub fn get_connectivity_test_result(&self) -> ConnectivityTestResult { + self.connectivity_test_result + } + + pub fn set_connectivity_test_result( + &mut self, + connectivity_test_result: ConnectivityTestResult, + ) { + self.connectivity_test_result = connectivity_test_result; + } + + pub fn get_status(&self) -> ConnectionStatusKind { + self.status.clone() + } + + fn set_state(&mut self, status: ConnectionStatusKind, window: &tauri::Window) { + log::info!("{status}"); + self.status = status.clone(); + window + .emit_all( + APP_EVENT_CONNECTION_STATUS_CHANGED, + AppEventConnectionStatusChangedPayload { status }, + ) + .tap_err(|err| log::warn!("{err}")) + .ok(); + } + + pub fn get_service_provider(&self) -> &Option { + &self.service_provider + } + + pub fn set_service_provider(&mut self, provider: Option) { + self.service_provider = provider; + } + + pub fn get_gateway(&self) -> &Option { + &self.gateway + } + + pub fn set_gateway(&mut self, gateway: Option) { + self.gateway = gateway; + } + + /// The effective config id is the static config id appended with the id of the gateway + pub fn get_config_id(&self) -> Result { + self.get_gateway() + .as_ref() + .ok_or(BackendError::CouldNotGetIdWithoutGateway) + .and_then(|gateway_id| socks5_config_id_appended_with(gateway_id)) + } + + pub fn load_socks5_config(&self) -> Result { + let id = self.get_config_id()?; + let config = Socks5Config::load_from_file(Some(&id)) + .tap_err(|_| log::warn!("Failed to load configuration file"))?; + Ok(config) + } + + /// Start connecting by first creating a config file, followed by starting a thread running the + /// SOCKS5 client. + pub async fn start_connecting( + &mut self, + window: &tauri::Window, + ) -> Result<(task::StatusReceiver, ExitStatusReceiver)> { + self.set_state(ConnectionStatusKind::Connecting, window); + + // Setup configuration by writing to file + //if let Err(err) = self.init_config().await { + // log::error!("Failed to initialize: {err}"); + + // // Wait a little to give the user some rudimentary feedback that the click actually + // // registered. + // tokio::time::sleep(Duration::from_secs(1)).await; + // self.set_state(ConnectionStatusKind::Disconnected, window); + // return Err(err); + //} + + let res = self.init_config().await; + match &res { + Ok(_) => {} + Err(e) => { + dbg!(e); + } + }; + let (config, keys) = res.unwrap(); + + // Kick off the main task and get the channel for controlling it + self.start_nym_socks5_client(config, keys) + } + + /// Create a configuration file + async fn init_config(&self) -> Result<(Config, KeyManager)> { + let service_provider = self + .get_service_provider() + .as_ref() + .ok_or(BackendError::CouldNotInitWithoutServiceProvider)?; + let gateway = self + .get_gateway() + .as_ref() + .ok_or(BackendError::CouldNotInitWithoutGateway)?; + log::trace!(" service_provider: {:?}", service_provider); + log::trace!(" gateway: {:?}", gateway); + + config::Config::init(service_provider, gateway).await + } + + /// Spawn a new thread running the SOCKS5 client + fn start_nym_socks5_client( + &mut self, + config: Config, + keys: KeyManager, + ) -> Result<(task::StatusReceiver, ExitStatusReceiver)> { + let id = self.get_config_id()?; + let (control_tx, msg_rx, exit_status_rx, used_gateway) = + tasks::start_nym_socks5_client(&id, config, keys)?; + self.socks5_client_sender = Some(control_tx); + self.gateway = Some(used_gateway.gateway_id); + Ok((msg_rx, exit_status_rx)) + } + + /// Once the SOCKS5 client is operational, the status listener would call this + pub fn mark_connected(&mut self, window: &tauri::Window) { + log::trace!("state::mark_connected"); + self.set_state(ConnectionStatusKind::Connected, window); + } + + /// Disconnect by sending a message to the SOCKS5 client thread. Once it has finished and is + /// disconnected, the disconnect handler will mark it as disconnected. + pub async fn start_disconnecting(&mut self, window: &tauri::Window) -> Result<()> { + log::trace!("state::start_disconnecting"); + self.set_state(ConnectionStatusKind::Disconnecting, window); + + // Send shutdown message + match self.socks5_client_sender { + Some(ref mut sender) => sender + .send(Socks5ControlMessage::Stop) + .await + .map_err(|err| { + log::warn!("Failed trying to send disconnect signal: {err}"); + BackendError::CoundNotSendDisconnectSignal + }), + None => { + log::warn!( + "Trying to disconnect without being able to talk to the SOCKS5 client, \ + is it running?" + ); + Err(BackendError::CoundNotSendDisconnectSignal) + } + } + } + + /// Once the SOCKS5 client has stopped, this should be called by the disconnect handler to mark + /// the state as disconnected. + pub fn mark_disconnected(&mut self, window: &tauri::Window) { + self.set_state(ConnectionStatusKind::Disconnected, window); + } +} diff --git a/nym-connect-android/src-tauri/src/tasks.rs b/nym-connect-android/src-tauri/src/tasks.rs new file mode 100644 index 0000000000..eb6f691825 --- /dev/null +++ b/nym-connect-android/src-tauri/src/tasks.rs @@ -0,0 +1,205 @@ +use client_core::{ + client::key_manager::KeyManager, + config::{ClientCoreConfigTrait, GatewayEndpointConfig}, + error::ClientCoreStatusMessage, +}; +use futures::{channel::mpsc, StreamExt}; +use std::sync::Arc; +use tap::TapFallible; +use task::manager::TaskStatus; +use tokio::sync::RwLock; + +use config_common::NymConfig; +use nym_socks5::client::NymClient as Socks5NymClient; +use nym_socks5::client::{config::Config as Socks5Config, Socks5ControlMessageSender}; + +use crate::{ + config::Config, + error::Result, + events::{self, emit_event, emit_status_event}, + models::{ConnectionStatusKind, ConnectivityTestResult}, + operations, + state::State, +}; + +pub type ExitStatusReceiver = futures::channel::oneshot::Receiver; + +/// Status messages sent by the SOCKS5 client task to the main tauri task. +#[derive(Debug)] +pub enum Socks5ExitStatusMessage { + /// The SOCKS5 task successfully stopped + Stopped, + /// The SOCKS5 task failed to start + Failed(Box), +} + +/// The main SOCKS5 client task. It loads the configuration from file determined by the `id`. +pub fn start_nym_socks5_client( + id: &str, + config: Config, + keys: KeyManager, +) -> Result<( + Socks5ControlMessageSender, + task::StatusReceiver, + ExitStatusReceiver, + GatewayEndpointConfig, +)> { + log::info!("Loading config from file: {id}"); + let used_gateway = config.get_base().get_gateway_endpoint().clone(); + + let socks5_client = Socks5NymClient::new_with_keys(config.socks5, Some(keys)); + log::info!("Starting socks5 client"); + + // Channel to send control messages to the socks5 client + let (socks5_ctrl_tx, socks5_ctrl_rx) = mpsc::unbounded(); + + // Channel to send status update messages from the background socks5 task to the frontend. + let (socks5_status_tx, socks5_status_rx) = mpsc::channel(128); + + // Channel to signal back to the main task when the socks5 client finishes, and why + let (socks5_exit_tx, socks5_exit_rx) = futures::channel::oneshot::channel(); + + // Spawn a separate runtime for the socks5 client so we can forcefully terminate. + // Once we can gracefully shutdown the socks5 client we can get rid of this. + // The status channel is used to both get the state of the task, and if it's closed, to check + // for panic. + std::thread::spawn(|| { + let result = tokio::runtime::Runtime::new() + .expect("Failed to create runtime for SOCKS5 client") + .block_on(async move { + socks5_client + .run_and_listen(socks5_ctrl_rx, socks5_status_tx) + .await + }); + + if let Err(err) = result { + log::error!("SOCKS5 proxy failed: {err}"); + socks5_exit_tx + .send(Socks5ExitStatusMessage::Failed(err)) + .expect("Failed to send status message back to main task"); + return; + } + + log::info!("SOCKS5 task finished"); + socks5_exit_tx + .send(Socks5ExitStatusMessage::Stopped) + .expect("Failed to send status message back to main task"); + }); + + Ok(( + socks5_ctrl_tx, + socks5_status_rx, + socks5_exit_rx, + used_gateway, + )) +} + +pub fn start_connection_check(state: Arc>, window: tauri::Window) { + log::debug!("Starting connection check handler"); + tokio::spawn(async move { + if state.read().await.get_status() != ConnectionStatusKind::Connected { + log::error!("SOCKS5 connection status check failed: not connected"); + return; + } + + log::info!("Running connection health check"); + if operations::connection::health_check::run_health_check().await { + state + .write() + .await + .set_connectivity_test_result(ConnectivityTestResult::Success); + emit_event( + "socks5-connection-success-event", + "SOCKS5 success", + "SOCKS5 connection health check successful", + &window, + ); + } else if state.read().await.get_status() == ConnectionStatusKind::Connected { + state + .write() + .await + .set_connectivity_test_result(ConnectivityTestResult::Fail); + log::error!("SOCKS5 connection health check failed"); + emit_event( + "socks5-connection-fail-event", + "SOCKS5 error", + "SOCKS5 connection health check failed", + &window, + ); + } else { + log::debug!("SOCKS5 connection status check cancelled: not connected"); + } + + log::debug!("Connection check handler exiting"); + }); +} + +/// The status listener listens for non-exit status messages from the background socks5 proxy task. +pub fn start_status_listener( + state: Arc>, + window: tauri::Window, + mut msg_receiver: task::StatusReceiver, +) { + log::info!("Starting status listener"); + + tokio::spawn(async move { + while let Some(msg) = msg_receiver.next().await { + log::info!("SOCKS5 proxy sent status message: {}", msg); + + if let Some(task_status) = msg.downcast_ref::() { + events::handle_task_status(task_status, &state, &window).await; + } else if let Some(client_status_message) = + msg.downcast_ref::() + { + events::handle_client_status_message(client_status_message, &state, &window).await; + } else { + emit_status_event("socks5-status-event", &msg, &window); + } + } + log::info!("Status listener exiting"); + }); +} + +/// The disconnect listener listens to the channel setup between the socks5 proxy task and the main +/// tauri task. Primarily it listens for shutdown messages, and updates the state accordingly. +pub fn start_disconnect_listener( + state: Arc>, + window: tauri::Window, + exit_status_receiver: ExitStatusReceiver, +) { + log::trace!("Starting disconnect listener"); + tokio::spawn(async move { + match exit_status_receiver.await { + Ok(Socks5ExitStatusMessage::Stopped) => { + log::info!("SOCKS5 task reported it has finished"); + emit_event( + "socks5-event", + "SOCKS5 finished", + "SOCKS5 task reported it has finished", + &window, + ); + } + Ok(Socks5ExitStatusMessage::Failed(err)) => { + log::info!("SOCKS5 task reported error: {err}"); + emit_event( + "socks5-event", + "SOCKS5 error", + &format!("SOCKS5 failed: {err}"), + &window, + ); + } + Err(_) => { + log::info!("SOCKS5 task appears to have stopped abruptly"); + emit_event( + "socks5-event", + "SOCKS5 error", + "SOCKS5 stopped abruptly. Please try reconnecting.", + &window, + ); + } + } + + let mut state_w = state.write().await; + state_w.mark_disconnected(&window); + }); +} diff --git a/nym-connect-android/src-tauri/src/window.rs b/nym-connect-android/src-tauri/src/window.rs new file mode 100644 index 0000000000..84cb3e41cd --- /dev/null +++ b/nym-connect-android/src-tauri/src/window.rs @@ -0,0 +1,30 @@ +use crate::menu::TRAY_MENU_SHOW_HIDE; +use tauri::{AppHandle, Manager}; + +pub(crate) fn window_hide(app: &AppHandle) { + let window = app.get_window("main").unwrap(); + let item_handle = app.tray_handle().get_item(TRAY_MENU_SHOW_HIDE); + if window.is_visible().unwrap() { + window.hide().unwrap(); + item_handle.set_title("Show").unwrap(); + } +} + +pub(crate) fn window_show(app: &AppHandle) { + let window = app.get_window("main").unwrap(); + let item_handle = app.tray_handle().get_item(TRAY_MENU_SHOW_HIDE); + if !window.is_visible().unwrap() { + window.show().unwrap(); + item_handle.set_title("Hide").unwrap(); + window.set_focus().unwrap(); + } +} + +pub(crate) fn window_toggle(app: &AppHandle) { + let window = app.get_window("main").unwrap(); + if window.is_visible().unwrap() { + window_hide(app); + } else { + window_show(app); + } +} diff --git a/nym-connect-android/src-tauri/tauri.conf.json b/nym-connect-android/src-tauri/tauri.conf.json new file mode 100644 index 0000000000..0572848f3b --- /dev/null +++ b/nym-connect-android/src-tauri/tauri.conf.json @@ -0,0 +1,77 @@ +{ + "package": { + "productName": "nym-connect-android", + "version": "1.1.7" + }, + "build": { + "distDir": "../dist", + "devPath": "http://localhost:9000", + "beforeDevCommand": "yarn webpack:dev:onlyThis", + "beforeBuildCommand": "yarn webpack:prod" + }, + "tauri": { + "bundle": { + "active": true, + "targets": "all", + "identifier": "net.nymtech.connect", + "icon": ["icons/32x32.png", "icons/128x128.png", "icons/128x128@2x.png", "icons/icon.icns", "icons/icon.ico"], + "resources": [], + "externalBin": [], + "copyright": "Copyright © 2021-2022 Nym Technologies SA", + "category": "Business", + "shortDescription": "Browse the internet privately using the Nym Mixnet", + "longDescription": "", + "deb": { + "depends": [] + }, + "macOS": { + "frameworks": [], + "minimumSystemVersion": "", + "exceptionDomain": "", + "signingIdentity": null, + "entitlements": null + }, + "windows": { + "certificateThumbprint": null, + "digestAlgorithm": "sha256", + "timestampUrl": "" + } + }, + "systemTray": { + "iconPath": "icons/tray_icon.png", + "iconAsTemplate": true + }, + "updater": { + "active": false + }, + "allowlist": { + "shell": { + "open": true + }, + "clipboard": { + "writeText": true + }, + "window": { + "startDragging": true, + "close": true, + "minimize": true + }, + "notification": { + "all": true + } + }, + "windows": [ + { + "title": "NymConnect", + "width": 240, + "height": 635, + "resizable": false, + "decorations": false, + "transparent": true + } + ], + "security": { + "csp": "default-src blob: data: filesystem: ws: wss: http: https: tauri: 'unsafe-eval' 'unsafe-inline' 'self' img-src: 'self'" + } + } +} diff --git a/nym-connect-android/src/App.tsx b/nym-connect-android/src/App.tsx new file mode 100644 index 0000000000..3b3295ccd8 --- /dev/null +++ b/nym-connect-android/src/App.tsx @@ -0,0 +1,94 @@ +import React, { useEffect } from 'react'; +import { DateTime } from 'luxon'; +import { forage } from '@tauri-apps/tauri-forage'; +import { ConnectionStatusKind } from './types'; +import { useClientContext } from './context/main'; +import { DefaultLayout } from './layouts/DefaultLayout'; +import { ConnectedLayout } from './layouts/ConnectedLayout'; +import { HelpGuideLayout } from './layouts/HelpGuideLayout'; +import { useTauriEvents } from './utils'; + +export const App: FCWithChildren = () => { + const context = useClientContext(); + const [busy, setBusy] = React.useState(); + const [showInfoModal, setShowInfoModal] = React.useState(false); + useTauriEvents('help://clear-storage', (_event) => { + console.log('About to clear local storage...'); + // clear local storage + try { + forage.clear()(); + console.log('Local storage cleared'); + } catch (e) { + console.error('Failed to clear local storage', e); + } + }); + + const handleConnectClick = React.useCallback(async () => { + const currentStatus = context.connectionStatus; + if (currentStatus === ConnectionStatusKind.connected || currentStatus === ConnectionStatusKind.disconnected) { + setBusy(true); + + // eslint-disable-next-line default-case + switch (currentStatus) { + case ConnectionStatusKind.disconnected: + await context.startConnecting(); + context.setConnectedSince(DateTime.now()); + break; + case ConnectionStatusKind.connected: + await context.startDisconnecting(); + context.setConnectedSince(undefined); + break; + } + setBusy(false); + } + }, [context.connectionStatus]); + + useEffect(() => { + if (context.connectionStatus === ConnectionStatusKind.connected) setShowInfoModal(true); + }, [context.connectionStatus]); + + if (context.showHelp) return ; + + if ( + context.connectionStatus === ConnectionStatusKind.disconnected || + context.connectionStatus === ConnectionStatusKind.connecting + ) { + return ( + + ); + } + + return ( + setShowInfoModal(false)} + status={context.connectionStatus} + busy={busy} + onConnectClick={handleConnectClick} + ipAddress="127.0.0.1" + port={1080} + gatewayPerformance={context.gatewayPerformance} + connectedSince={context.connectedSince} + serviceProvider={context.serviceProvider} + stats={[ + { + label: 'in:', + totalBytes: 1024, + rateBytesPerSecond: 1024 * 1024 * 1024 + 10, + }, + { + label: 'out:', + totalBytes: 1024 * 1024 * 1024 * 1024 * 20, + rateBytesPerSecond: 1024 * 1024 + 10, + }, + ]} + /> + ); +}; diff --git a/nym-connect-android/src/assets/help-step-four.png b/nym-connect-android/src/assets/help-step-four.png new file mode 100644 index 0000000000..5507962892 Binary files /dev/null and b/nym-connect-android/src/assets/help-step-four.png differ diff --git a/nym-connect-android/src/assets/help-step-one.png b/nym-connect-android/src/assets/help-step-one.png new file mode 100644 index 0000000000..5e91a29cff Binary files /dev/null and b/nym-connect-android/src/assets/help-step-one.png differ diff --git a/nym-connect-android/src/assets/help-step-three.png b/nym-connect-android/src/assets/help-step-three.png new file mode 100644 index 0000000000..932b6c1171 Binary files /dev/null and b/nym-connect-android/src/assets/help-step-three.png differ diff --git a/nym-connect-android/src/assets/help-step-two.png b/nym-connect-android/src/assets/help-step-two.png new file mode 100644 index 0000000000..f2910c98cf Binary files /dev/null and b/nym-connect-android/src/assets/help-step-two.png differ diff --git a/nym-connect-android/src/components/AppVersion.tsx b/nym-connect-android/src/components/AppVersion.tsx new file mode 100644 index 0000000000..2a0250e86b --- /dev/null +++ b/nym-connect-android/src/components/AppVersion.tsx @@ -0,0 +1,15 @@ +import React from 'react'; +import { Box } from '@mui/material'; +import { useClientContext } from 'src/context/main'; + +export const AppVersion = () => { + const { appVersion } = useClientContext(); + + return ( + + + Version {appVersion} + + + ); +}; diff --git a/nym-connect-android/src/components/AppWindowFrame.tsx b/nym-connect-android/src/components/AppWindowFrame.tsx new file mode 100644 index 0000000000..89a68f7f66 --- /dev/null +++ b/nym-connect-android/src/components/AppWindowFrame.tsx @@ -0,0 +1,20 @@ +import React from 'react'; +import { Box } from '@mui/material'; +import { CustomTitleBar } from './CustomTitleBar'; +import { AppVersion } from './AppVersion'; + +export const AppWindowFrame: FCWithChildren = ({ children }) => ( + + + {children} + + +); diff --git a/nym-connect-android/src/components/ConnectionButton.tsx b/nym-connect-android/src/components/ConnectionButton.tsx new file mode 100644 index 0000000000..29fb90c1db --- /dev/null +++ b/nym-connect-android/src/components/ConnectionButton.tsx @@ -0,0 +1,168 @@ +import React from 'react'; +import { ConnectionStatusKind } from '../types'; + +const getStatusFillColor = (status: ConnectionStatusKind, hover: boolean, isError: boolean): string => { + if (isError && hover) { + return '#21D072'; + } + if (isError) { + return '#40475C'; + } + + switch (status) { + case ConnectionStatusKind.disconnected: + if (hover) { + return '#FFFF33'; + } + return '#FFE600'; + case ConnectionStatusKind.connecting: + case ConnectionStatusKind.disconnecting: + return '#FFE600'; + default: + // connected + if (hover) { + return '#E43E3E'; + } + return '#21D072'; + } +}; + +const getStatusText = (status: ConnectionStatusKind, hover: boolean): string => { + switch (status) { + case ConnectionStatusKind.disconnected: + return 'Connect'; + case ConnectionStatusKind.connecting: + return 'Connecting'; + case ConnectionStatusKind.disconnecting: + return 'Connected'; + default: + // connected + if (hover) { + return 'Disconnect'; + } + return 'Connected'; + } +}; + +export const ConnectionButton: FCWithChildren<{ + status: ConnectionStatusKind; + disabled?: boolean; + busy?: boolean; + isError?: boolean; + onClick?: (status: ConnectionStatusKind) => void; +}> = ({ status, disabled, isError, onClick }) => { + const [hover, setHover] = React.useState(false); + + const handleClick = React.useCallback(() => { + if (disabled === true) { + return; + } + if (onClick) { + onClick(status); + } + }, [status, disabled]); + + const statusText = getStatusText(status, hover); + const statusTextColor = isError ? '#40475C' : '#FFF'; + const statusFillColor = getStatusFillColor(status, hover, Boolean(isError)); + + return ( + + !disabled && setHover(true)} + onMouseLeave={() => !disabled && setHover(false)} + > + + + + + + + + + + {status === ConnectionStatusKind.connected && hover ? ( + + ) : ( + + )} + + {statusText} + + + + + + + + + + + + + + + + + + + + + + + + + + ); +}; diff --git a/nym-connect-android/src/components/ConnectionStats.tsx b/nym-connect-android/src/components/ConnectionStats.tsx new file mode 100644 index 0000000000..f17833e66d --- /dev/null +++ b/nym-connect-android/src/components/ConnectionStats.tsx @@ -0,0 +1,47 @@ +import React from 'react'; +import { Box, Typography } from '@mui/material'; +import prettyBytes from 'pretty-bytes'; + +export interface ConnectionStatsItem { + label: string; + rateBytesPerSecond: number; + totalBytes: number; +} + +const FONT_SIZE = '14px'; + +export const ConnectionStats: FCWithChildren<{ + stats: ConnectionStatsItem[]; +}> = ({ stats }) => ( + +
+ {stats.map((stat) => ( + + {stat.label} + + ))} +
+
+ {stats.map((stat) => ( + + {formatRate(stat.rateBytesPerSecond)} + + ))} +
+
+ {stats.map((stat) => ( + + {formatTotal(stat.totalBytes)} + + ))} +
+
+); + +export function formatRate(bytesPerSecond: number): string { + return `${prettyBytes(bytesPerSecond)}/s`; +} + +export function formatTotal(totalBytes: number): string { + return prettyBytes(totalBytes); +} diff --git a/nym-connect-android/src/components/ConnectionStatus.tsx b/nym-connect-android/src/components/ConnectionStatus.tsx new file mode 100644 index 0000000000..c8dadd2e67 --- /dev/null +++ b/nym-connect-android/src/components/ConnectionStatus.tsx @@ -0,0 +1,90 @@ +import React from 'react'; +import { Box, CircularProgress, Tooltip, Typography } from '@mui/material'; +import { DateTime } from 'luxon'; +import { ConnectionStatusKind, GatewayPerformance } from '../types'; +import { ServiceProvider } from '../types/directory'; +import { ServiceProviderInfo } from './ServiceProviderInfo'; + +const FONT_SIZE = '10px'; +const FONT_WEIGHT = '600'; +const FONT_STYLE = 'normal'; + +const ConnectionStatusContent: FCWithChildren<{ + status: ConnectionStatusKind; +}> = ({ status }) => { + switch (status) { + case ConnectionStatusKind.connected: + return ( + + Connected to + + ); + case ConnectionStatusKind.disconnecting: + return ( + + + + Disconnecting... + + + ); + case ConnectionStatusKind.connecting: + return ( + + + + Connecting... + + + ); + case ConnectionStatusKind.disconnected: + return ( + + You are not protected + + ); + default: + return null; + } +}; + +export const ConnectionStatus: FCWithChildren<{ + status: ConnectionStatusKind; + gatewayPerformance?: GatewayPerformance; + connectedSince?: DateTime; + serviceProvider?: ServiceProvider; +}> = ({ status, serviceProvider, gatewayPerformance }) => { + const color = + status === ConnectionStatusKind.connected || status === ConnectionStatusKind.disconnecting + ? '#21D072' + : 'warning.main'; + + return ( + <> + + {status === ConnectionStatusKind.connected && gatewayPerformance !== 'Good' ? ( + + Gateway has issues + + ) : ( + + )} + + {serviceProvider ? ( + }> + + {serviceProvider && {serviceProvider.description}} + + + ) : null} + + ); +}; diff --git a/nym-connect-android/src/components/ConntectionTimer.tsx b/nym-connect-android/src/components/ConntectionTimer.tsx new file mode 100644 index 0000000000..25e2af8d9c --- /dev/null +++ b/nym-connect-android/src/components/ConntectionTimer.tsx @@ -0,0 +1,26 @@ +import React, { useEffect } from 'react'; +import { Stack, Typography } from '@mui/material'; +import { DateTime } from 'luxon'; + +export const ConnectionTimer = ({ connectedSince }: { connectedSince?: DateTime }) => { + const [duration, setDuration] = React.useState(); + useEffect(() => { + const intervalId = setInterval(() => { + if (connectedSince) { + setDuration(DateTime.now().diff(connectedSince).toFormat('hh:mm:ss')); + } + }, 500); + return () => { + clearInterval(intervalId); + }; + }, [connectedSince]); + + return ( + + + Connection time + + {duration || '00:00:00'} + + ); +}; diff --git a/nym-connect-android/src/components/CopyToClipboard.tsx b/nym-connect-android/src/components/CopyToClipboard.tsx new file mode 100644 index 0000000000..7a48662190 --- /dev/null +++ b/nym-connect-android/src/components/CopyToClipboard.tsx @@ -0,0 +1,69 @@ +import React, { useEffect, useState } from 'react'; +import { Button, IconButton, Tooltip } from '@mui/material'; +import { Check, ContentCopy } from '@mui/icons-material'; +import { clipboard } from '@tauri-apps/api'; + +export const CopyToClipboard = ({ + text = '', + light, + iconButton, +}: { + text?: string; + light?: boolean; + iconButton?: boolean; +}) => { + const [copied, setCopied] = useState(false); + + const handleCopy = async (textToCopy: string) => { + try { + if (clipboard) { + await clipboard.writeText(textToCopy); + } else { + await navigator.clipboard.writeText(textToCopy); + } + setCopied(true); + } catch (e) { + console.log(`failed to copy: ${e}`); + } + }; + + useEffect(() => { + let timer: NodeJS.Timeout; + if (copied) { + timer = setTimeout(() => { + setCopied(false); + }, 2000); + } + return () => clearTimeout(timer); + }, [copied]); + + if (iconButton) + return ( + + handleCopy(text)} + size="small" + sx={{ + color: (theme) => (light ? theme.palette.common.white : theme.palette.nym.background.dark), + }} + > + {!copied ? : } + + + ); + + return ( + + ); +}; diff --git a/nym-connect-android/src/components/CustomTitleBar.tsx b/nym-connect-android/src/components/CustomTitleBar.tsx new file mode 100644 index 0000000000..7f4faa0918 --- /dev/null +++ b/nym-connect-android/src/components/CustomTitleBar.tsx @@ -0,0 +1,43 @@ +import React from 'react'; +import { ArrowBack, HelpOutline } from '@mui/icons-material'; +import { Box, IconButton } from '@mui/material'; +import { NymWordmark } from '@nymproject/react/logo/NymWordmark'; +import { useClientContext } from 'src/context/main'; + +const customTitleBarStyles = { + titlebar: { + background: '#1D2125', + display: 'flex', + justifyContent: 'space-between', + alignItems: 'center', + padding: '16px', + paddingBottom: '0px', + borderTopLeftRadius: '12px', + borderTopRightRadius: '12px', + }, +}; + +const CustomButton = ({ Icon, onClick }: { Icon: React.JSXElementConstructor; onClick: () => void }) => ( + + + +); + +export const CustomTitleBar = () => { + const { showHelp, handleShowHelp } = useClientContext(); + return ( + + {/* set width to keep logo centered */} + + { + handleShowHelp(); + }} + /> + + + + + ); +}; diff --git a/nym-connect-android/src/components/Error.tsx b/nym-connect-android/src/components/Error.tsx new file mode 100644 index 0000000000..f582536b6b --- /dev/null +++ b/nym-connect-android/src/components/Error.tsx @@ -0,0 +1,17 @@ +import React from 'react'; +import { FallbackProps } from 'react-error-boundary'; +import { Alert, AlertTitle, Button } from '@mui/material'; + +export const ErrorFallback = ({ error, resetErrorBoundary }: FallbackProps) => ( +
+ + {error.name} + {error.message} + + + Stack trace + {error.stack} + + +
+); diff --git a/nym-connect-android/src/components/ErrorFallback.tsx b/nym-connect-android/src/components/ErrorFallback.tsx new file mode 100644 index 0000000000..f582536b6b --- /dev/null +++ b/nym-connect-android/src/components/ErrorFallback.tsx @@ -0,0 +1,17 @@ +import React from 'react'; +import { FallbackProps } from 'react-error-boundary'; +import { Alert, AlertTitle, Button } from '@mui/material'; + +export const ErrorFallback = ({ error, resetErrorBoundary }: FallbackProps) => ( +
+ + {error.name} + {error.message} + + + Stack trace + {error.stack} + + +
+); diff --git a/nym-connect-android/src/components/Growth/TestAndEarnButtonArea.tsx b/nym-connect-android/src/components/Growth/TestAndEarnButtonArea.tsx new file mode 100644 index 0000000000..92c88ec321 --- /dev/null +++ b/nym-connect-android/src/components/Growth/TestAndEarnButtonArea.tsx @@ -0,0 +1,84 @@ +import React from 'react'; +import { Badge, Box, Button, Tooltip } from '@mui/material'; +import MonetizationOnIcon from '@mui/icons-material/MonetizationOn'; +import { invoke } from '@tauri-apps/api'; +import Content from './content/en.yaml'; +import { useClientContext } from '../../context/main'; +import { useTestAndEarnContext } from './context/TestAndEarnContext'; +import { NymShipyardTheme } from '../../theme'; +import { ConnectionStatusKind } from '../../types'; + +export const Wrapper: FCWithChildren<{ disabled: boolean }> = ({ disabled, children }) => { + if (disabled) { + return ( + + +
{children}
+
+
+ ); + } + // eslint-disable-next-line react/jsx-no-useless-fragment + return <>{children}; +}; + +export const TestAndEarnButtonArea: FCWithChildren = () => { + const clientContext = useClientContext(); + const context = useTestAndEarnContext(); + const disabled = clientContext.connectionStatus !== ConnectionStatusKind.connected; + const pinger = React.useRef(); + + const doPing = async () => { + if (context.clientDetails) { + try { + await invoke('growth_tne_ping', { clientDetails: context.clientDetails }); + } catch (_e) { + // console.error('Failed to ping: ', e); + } + } + }; + + React.useEffect(() => { + (async () => { + if (!disabled) { + // sleep a little until the SOCKS5 proxy connects + setTimeout(() => { + doPing(); + }, 1000 * 10); + + // update every 15 mins + pinger.current = setInterval(doPing, 1000 * 60 * 15); + } else if (pinger.current) { + clearInterval(pinger.current); + pinger.current = null; + } + })(); + }, [disabled, context.clientDetails]); + + const handleClick = async () => { + if (!disabled) { + await context.toggleGrowthWindow(Content.testAndEarn.popupWindow.title); + } + }; + + return ( + + + + + + + + ); +}; diff --git a/nym-connect-android/src/components/Growth/TestAndEarnCurrentDraw.stories.tsx b/nym-connect-android/src/components/Growth/TestAndEarnCurrentDraw.stories.tsx new file mode 100644 index 0000000000..99b9206e7a --- /dev/null +++ b/nym-connect-android/src/components/Growth/TestAndEarnCurrentDraw.stories.tsx @@ -0,0 +1,94 @@ +import * as React from 'react'; +import { ComponentMeta } from '@storybook/react'; +import { DateTime, Duration } from 'luxon'; +import { + TestAndEarnCurrentDraw, + TestAndEarnCurrentDrawEntered, + TestAndEarnCurrentDrawFuture, +} from './TestAndEarnCurrentDraw'; +import { NymShipyardTheme } from '../../theme'; +import { DrawEntryStatus } from './context/types'; +import { testMarkdown } from './context/mocks/TestAndEarnContext'; + +export default { + title: 'Growth/TestAndEarn/Components/Cards/Current Draw', + component: TestAndEarnCurrentDraw, +} as ComponentMeta; + +export const Valid = () => ( + + + +); + +export const EnteredMalformedDraw = () => ( + + + +); + +export const EnteredDraw = () => ( + + + +); + +export const Future = () => ( + + + +); diff --git a/nym-connect-android/src/components/Growth/TestAndEarnCurrentDraw.tsx b/nym-connect-android/src/components/Growth/TestAndEarnCurrentDraw.tsx new file mode 100644 index 0000000000..5f43f8054e --- /dev/null +++ b/nym-connect-android/src/components/Growth/TestAndEarnCurrentDraw.tsx @@ -0,0 +1,192 @@ +import React from 'react'; +import LoadingButton from '@mui/lab/LoadingButton'; +import { Alert, AlertTitle, Box, Card, CardContent, CardMedia, Link, Typography } from '@mui/material'; +import { SxProps } from '@mui/system'; +import { DateTime } from 'luxon'; +import ReactMarkdown from 'react-markdown'; +import assetAnimation from './content/assets/matrix.webp'; +import { CopyToClipboard } from '../CopyToClipboard'; +import { useTestAndEarnContext } from './context/TestAndEarnContext'; +import { DrawEntryStatus, DrawWithWordOfTheDay } from './context/types'; +import Content from './content/en.yaml'; + +export const TestAndEarnCurrentDrawFuture: FCWithChildren<{ draw?: DrawWithWordOfTheDay }> = ({ draw }) => { + const startsUtc = React.useMemo(() => draw && DateTime.fromISO(draw.start_utc), [draw?.start_utc]); + const startsIn = React.useMemo(() => { + if (draw && startsUtc) { + return startsUtc.toRelative(); + } + return undefined; + }, [draw?.start_utc]); + + if (!draw || !startsUtc) { + return null; + } + return ( + + +

+ {Content.testAndEarn.draw.next.header} {startsIn} ⏰ +

+

on {startsUtc.toLocaleString(DateTime.DATETIME_FULL)}

+
+
+ ); +}; + +export const TestAndEarnCurrentDrawEnter: FCWithChildren<{ draw?: DrawWithWordOfTheDay }> = ({ draw }) => { + const context = useTestAndEarnContext(); + const [busy, setBusy] = React.useState(false); + const [error, setError] = React.useState(); + const handleClick = async () => { + if (!draw) { + setError('No draw selected'); + return; + } + + setBusy(true); + try { + await context.enterDraw(draw.id); + } catch (e) { + const message = `${e}`; + console.error('Could not enter draw', message); + setError(message); + } + setBusy(false); + }; + return ( + + Complete today’s task for the chance to earn 1000 NYMs. + + Start task ✨ + + {error && ( + + + Oh no! Something went wrong. + {error} + + + )} + + ); +}; + +export const TestAndEarnCurrentDrawEntered: FCWithChildren<{ draw?: DrawWithWordOfTheDay }> = ({ draw }) => { + if (!draw || !draw.entry) { + return null; + } + + if (!draw.word_of_the_day) { + return ( + + Oh no! Something is wrong + Someone configured the wrong instructions for the task, you will not be able to see it until this is fixed + + ); + } + + return ( + + + {draw.word_of_the_day} + + + {Content.testAndEarn.task.afterText} + + {draw.entry.id} + + + {Content.testAndEarn.task.beforeSocials} + + + Twitter + {' '} + - remember to + + @nymproject + {' '} + and use the hashtag{' '} + + #PrivacyLovesCompany + + + or + + Nym{' '} + + Telegram channel + + + + ); +}; + +export const TestAndEarnCurrentDraw: FCWithChildren<{ + draw?: DrawWithWordOfTheDay; + sx?: SxProps; +}> = ({ draw }) => { + const [trigger, setTrigger] = React.useState(DateTime.now().toISO()); + const endsUtc = React.useMemo(() => draw && DateTime.fromISO(draw.end_utc), [draw?.end_utc]); + const closesIn = React.useMemo(() => { + if (draw && endsUtc) { + return endsUtc.toRelative(); + } + return undefined; + }, [trigger, endsUtc]); + + React.useEffect(() => { + const timer = setInterval(() => setTrigger(DateTime.now().toISO()), 1000 * 3600 * 15); + return () => clearInterval(timer); + }, []); + + if (draw && closesIn && endsUtc) { + return ( + + +

+ {"Today's task ends "} + {closesIn} + + {endsUtc.weekdayLong} {endsUtc.toLocaleString(DateTime.DATETIME_FULL)} + +

+ {!draw.entry && } + {draw.entry && } +
+ +
+ ); + } + + return null; +}; + +export const TestAndEarnCurrentDrawWithState: FCWithChildren<{ + sx?: SxProps; +}> = ({ sx }) => { + const context = useTestAndEarnContext(); + + if ( + context.draws?.current?.entry?.status === DrawEntryStatus.winner || + context.draws?.current?.entry?.status === DrawEntryStatus.claimed || + context.draws?.current?.entry?.status === DrawEntryStatus.noWin + ) { + return null; + } + + if (!context.draws?.current) { + return ; + } + + return ; +}; diff --git a/nym-connect-android/src/components/Growth/TestAndEarnDraws.stories.tsx b/nym-connect-android/src/components/Growth/TestAndEarnDraws.stories.tsx new file mode 100644 index 0000000000..c70f223420 --- /dev/null +++ b/nym-connect-android/src/components/Growth/TestAndEarnDraws.stories.tsx @@ -0,0 +1,19 @@ +/* eslint-disable react/jsx-pascal-case */ +import * as React from 'react'; +import { ComponentMeta } from '@storybook/react'; +import { NymShipyardTheme } from 'src/theme'; +import { TestAndEarnDraws } from './TestAndEarnDraws'; +import { MockTestAndEarnProvider_RegisteredWithAllDraws } from './context/mocks/TestAndEarnContext'; + +export default { + title: 'Growth/TestAndEarn/Components/Cards/Draws', + component: TestAndEarnDraws, +} as ComponentMeta; + +export const Draws = () => ( + + + + + +); diff --git a/nym-connect-android/src/components/Growth/TestAndEarnDraws.tsx b/nym-connect-android/src/components/Growth/TestAndEarnDraws.tsx new file mode 100644 index 0000000000..24d97b7439 --- /dev/null +++ b/nym-connect-android/src/components/Growth/TestAndEarnDraws.tsx @@ -0,0 +1,196 @@ +/* eslint-disable react/jsx-no-useless-fragment */ +import React from 'react'; +import LoadingButton from '@mui/lab/LoadingButton'; +import { + Alert, + AlertTitle, + Button, + Card, + CardContent, + Chip, + Dialog, + Table, + TableBody, + TableCell, + TableContainer, + TableRow, + Tooltip, + Typography, +} from '@mui/material'; +import { SxProps } from '@mui/system'; +import { DateTime } from 'luxon'; +import { useTestAndEarnContext } from './context/TestAndEarnContext'; +import { DrawEntry, DrawEntryStatus } from './context/types'; +import { CopyToClipboard } from '../CopyToClipboard'; +import { TestAndEarnEnterWalletAddress } from './TestAndEarnEnterWalletAddress'; +import Content from './content/en.yaml'; + +const statusToText = (status: string): string => Content.testAndEarn.status.chip[status] || '-'; + +const statusToColor = (status: string): 'info' | 'success' | 'warning' | undefined => { + switch (status) { + case DrawEntryStatus.pending: + return 'info'; + case DrawEntryStatus.winner: + return 'warning'; + case DrawEntryStatus.claimed: + return 'success'; + default: + return undefined; + } +}; + +const StatusText: FCWithChildren<{ entry: DrawEntry }> = ({ entry }) => { + const context = useTestAndEarnContext(); + const [busy, setBusy] = React.useState(false); + const [error, setError] = React.useState(); + const [showWalletCapture, setShowWalletCapture] = React.useState(false); + + const clear = () => { + setShowWalletCapture(false); + setError(undefined); + setBusy(false); + }; + + const handleStartWalletCapture = async () => { + setBusy(true); + setShowWalletCapture(true); + }; + + const cancelEndWalletCapture = async () => { + setBusy(false); + setShowWalletCapture(false); + }; + + const handleEndWalletCapture = async () => { + setBusy(true); + setShowWalletCapture(false); + + if (!context.walletAddress) { + setError('Wallet address is not set'); + return; + } + if (!entry.draw_id) { + setError('Task id is not set'); + return; + } + + try { + await context.claim(entry.draw_id, context.walletAddress); + } catch (e) { + const message = `${e}`; + console.error('Failed to submit claim'); + setError(message); + } + setBusy(false); + }; + + if (error) { + return ( + + Oh no! Failed to submit claim + {error} + + + ); + } + + if (showWalletCapture) { + return ( + + + + ); + } + + switch (entry.status) { + case DrawEntryStatus.pending: + return <>{Content.testAndEarn.status.text.Pending}; + case DrawEntryStatus.winner: + return ( + <> + {Content.testAndEarn.status.text.Winner} + + {Content.testAndEarn.winner.claimButton.text} + + + ); + case DrawEntryStatus.claimed: + return <>{Content.testAndEarn.status.text.Claimed}; + case DrawEntryStatus.noWin: + return <>{Content.testAndEarn.status.text.NoWin}; + default: + return null; + } +}; + +export const TestAndEarnDraws: FCWithChildren<{ + sx?: SxProps; +}> = () => { + const context = useTestAndEarnContext(); + + const draws = React.useMemo( + () => + (context.draws?.draws || []).map((item) => ({ + ...item, + timestamp: DateTime.fromISO(item.timestamp).toLocaleString(DateTime.DATETIME_FULL), + })), + [context.draws?.draws], + ); + + if (!context.draws) { + return null; + } + + return ( + + + Here is a history of the tasks you have completed: + + + + {draws.map((entry) => ( + + {entry.timestamp} + + + + + + + + + + {entry.id} + + + ))} + +
+
+
+
+ ); +}; + +export const TestAndEarnDrawsWithState: FCWithChildren<{ + sx?: SxProps; +}> = ({ sx }) => { + const context = useTestAndEarnContext(); + + const drawCount = context.draws?.draws?.length || 0; + if (drawCount < 1) { + return null; + } + + return ; +}; diff --git a/nym-connect-android/src/components/Growth/TestAndEarnEnterWalletAddress.stories.tsx b/nym-connect-android/src/components/Growth/TestAndEarnEnterWalletAddress.stories.tsx new file mode 100644 index 0000000000..0f5c4ea0de --- /dev/null +++ b/nym-connect-android/src/components/Growth/TestAndEarnEnterWalletAddress.stories.tsx @@ -0,0 +1,41 @@ +import * as React from 'react'; +import { ComponentMeta } from '@storybook/react'; +import { Box } from '@mui/material'; +import { TestAndEarnEnterWalletAddress } from './TestAndEarnEnterWalletAddress'; +import { TestAndEarnContextProvider } from './context/TestAndEarnContext'; +import { NymShipyardTheme } from '../../theme'; + +export default { + title: 'Growth/TestAndEarn/Components/Enter wallet address', + component: TestAndEarnEnterWalletAddress, +} as ComponentMeta; + +export const Empty = () => ( + + + + + + + +); + +export const ErrorValue = () => ( + + + + + + + +); + +export const ValidValue = () => ( + + + + + + + +); diff --git a/nym-connect-android/src/components/Growth/TestAndEarnEnterWalletAddress.tsx b/nym-connect-android/src/components/Growth/TestAndEarnEnterWalletAddress.tsx new file mode 100644 index 0000000000..60aea1d51b --- /dev/null +++ b/nym-connect-android/src/components/Growth/TestAndEarnEnterWalletAddress.tsx @@ -0,0 +1,37 @@ +import React from 'react'; +import { WalletAddressFormField } from '@nymproject/react/account/WalletAddressFormField'; +import { SxProps } from '@mui/system'; +import { Box, Button, Paper, Stack } from '@mui/material'; +import ArrowCircleRightIcon from '@mui/icons-material/ArrowCircleRight'; +import { useTestAndEarnContext } from './context/TestAndEarnContext'; + +export const TestAndEarnEnterWalletAddress: FCWithChildren<{ + initialValue?: string; + placeholder?: string; + onSubmit?: () => Promise | void; + sx?: SxProps; +}> = ({ initialValue, placeholder, onSubmit }) => { + const context = useTestAndEarnContext(); + const [isAddressValid, setAddressIsValid] = React.useState(false); + return ( + + + + + + + + + + + ); +}; diff --git a/nym-connect-android/src/components/Growth/TestAndEarnError.tsx b/nym-connect-android/src/components/Growth/TestAndEarnError.tsx new file mode 100644 index 0000000000..1ad4f94cc8 --- /dev/null +++ b/nym-connect-android/src/components/Growth/TestAndEarnError.tsx @@ -0,0 +1,13 @@ +import React from 'react'; +import { Box, Button } from '@mui/material'; + +export const TestAndEarnError: FCWithChildren<{ error?: string }> = ({ error = 'An error has occurred' }) => ( + + + {error} + + + +); diff --git a/nym-connect-android/src/components/Growth/TestAndEarnPopup.stories.tsx b/nym-connect-android/src/components/Growth/TestAndEarnPopup.stories.tsx new file mode 100644 index 0000000000..7e000ca093 --- /dev/null +++ b/nym-connect-android/src/components/Growth/TestAndEarnPopup.stories.tsx @@ -0,0 +1,162 @@ +/* eslint-disable react/jsx-pascal-case */ +import * as React from 'react'; +import { ComponentMeta } from '@storybook/react'; +import { Alert, Box } from '@mui/material'; +import { NymShipyardTheme } from 'src/theme'; +import { TestAndEarnPopup, TestAndEarnPopupContent } from './TestAndEarnPopup'; +import { TestAndEarnContextProvider } from './context/TestAndEarnContext'; +import { MockProvider } from '../../context/mocks/main'; +import { ConnectionStatusKind } from '../../types'; +import { + MockTestAndEarnProvider_NotRegistered, + MockTestAndEarnProvider_RegisteredAndError, + MockTestAndEarnProvider_RegisteredWithDraws, + MockTestAndEarnProvider_RegisteredWithDrawsAndEntry, + MockTestAndEarnProvider_RegisteredWithDrawsAndEntryAndNoWinner, + MockTestAndEarnProvider_RegisteredWithDrawsAndEntryAndWinner, + MockTestAndEarnProvider_RegisteredWithDrawsAndEntryAndWinnerClaimed, + MockTestAndEarnProvider_RegisteredWithDrawsAndEntryAndWinnerCollectWallet, + MockTestAndEarnProvider_RegisteredWithDrawsNoCurrent, +} from './context/mocks/TestAndEarnContext'; + +export default { + title: 'Growth/TestAndEarn/Content/Popup', + component: TestAndEarnPopupContent, +} as ComponentMeta; + +const MacOSWindow: FCWithChildren<{ + width?: string | number; + height?: string | number; + title?: string; + children: React.ReactNode; +}> = ({ title, width, height, children }) => ( + + + + + + + + + + + + + + + + + + + {title || 'Window title'} + + + {children} + +); + +const Wrapper: FCWithChildren<{ text: React.ReactNode }> = ({ text }) => ( + + + {text} + + + + + +); + +export const Stage0 = () => ( + + + + + +); + +export const Stage1EnterDraw = () => ( + + + + + +); + +export const Stage2GetTask = () => ( + + + + + +); + +export const Stage3Winner = () => ( + + + + + +); + +export const Stage3NoPrize = () => ( + + + + + +); + +export const Stage4EnterWalletAddress = () => ( + + + + + +); + +export const Stage5ClaimedPrize = () => ( + + + + + +); + +export const Stage6DrawsFinished = () => ( + + + + + +); + +export const Connecting = () => ( + + + + + +); + +export const Disconnected = () => ( + + + + + +); + +export const Error = () => ( + + + + + +); diff --git a/nym-connect-android/src/components/Growth/TestAndEarnPopup.tsx b/nym-connect-android/src/components/Growth/TestAndEarnPopup.tsx new file mode 100644 index 0000000000..dc1148c1e5 --- /dev/null +++ b/nym-connect-android/src/components/Growth/TestAndEarnPopup.tsx @@ -0,0 +1,118 @@ +import React from 'react'; +import { Box, CircularProgress, LinearProgress, Stack, Typography } from '@mui/material'; +import { useClientContext } from '../../context/main'; +import ErrorContent from './content/TestAndEarn/Error.mdx'; +import ContentStep0 from './content/TestAndEarn/Stage0_intro.mdx'; +import ContentNotAvailable from './content/TestAndEarnNotAvaialble.mdx'; +import { ConnectionStatusKind } from '../../types'; +import { useTestAndEarnContext } from './context/TestAndEarnContext'; +import { TestAndEarnWinnerWithState } from './TestAndEarnWinner'; +import { TestAndEarnCurrentDrawWithState } from './TestAndEarnCurrentDraw'; +import { TestAndEarnDrawsWithState } from './TestAndEarnDraws'; + +enum Stages { + mustRegister = 'mustRegister', + registered = 'registered', +} + +export const TestAndEarnPopupContent: FCWithChildren<{ + stage?: string; + connectionStatus?: ConnectionStatusKind; + error?: string; +}> = ({ connectionStatus, error, stage = Stages.mustRegister }) => { + if (error) { + return ( + + + + ); + } + + if (!connectionStatus || connectionStatus === ConnectionStatusKind.disconnected) { + return ( + + + + ); + } + + if (connectionStatus === ConnectionStatusKind.connecting || connectionStatus === ConnectionStatusKind.disconnecting) { + return ( + + + Please wait... + + ); + } + + switch (stage) { + case Stages.mustRegister: + return ( + + + + ); + case Stages.registered: + return ( + + + + + + ); + default: + return ( + + + + Waiting for task information... + + + ); + } +}; + +export const TestAndEarnPopup: FCWithChildren = () => { + const clientContext = useClientContext(); + const context = useTestAndEarnContext(); + + React.useEffect(() => { + if (clientContext.connectionStatus === ConnectionStatusKind.connected) { + context.refresh(); + } + }, [clientContext.connectionStatus]); + + const stage = React.useMemo(() => { + if (context.registration) { + return Stages.registered; + } + return Stages.mustRegister; + }, [context.registration?.id]); + + React.useEffect(() => { + const interval = setInterval(context.refresh, 1000 * 60 * 5); + return () => clearInterval(interval); + }, []); + + if (!context.loadedOnce && clientContext.connectionStatus === ConnectionStatusKind.connected) { + const message = 'Waiting for data to be transferred over the mixnet...'; + return ( + + + + {message} + {/* {process.env.NODE_ENV === 'development' &&
{JSON.stringify(context, null, 2)}
} */} +
+
+ ); + } + + return ( + <> + {context.loading && } + {/* */} + + {/* {process.env.NODE_ENV === 'development' &&
{JSON.stringify(context, null, 2)}
} */} + + ); +}; diff --git a/nym-connect-android/src/components/Growth/TestAndEarnTakePart.tsx b/nym-connect-android/src/components/Growth/TestAndEarnTakePart.tsx new file mode 100644 index 0000000000..b8a096acf6 --- /dev/null +++ b/nym-connect-android/src/components/Growth/TestAndEarnTakePart.tsx @@ -0,0 +1,75 @@ +import React from 'react'; +import { Alert, AlertTitle, Box, Checkbox, Link, Stack } from '@mui/material'; +import LoadingButton from '@mui/lab/LoadingButton'; +import { SxProps } from '@mui/system'; +import ArrowCircleRightIcon from '@mui/icons-material/ArrowCircleRight'; +import { invoke } from '@tauri-apps/api'; +import { useTestAndEarnContext } from './context/TestAndEarnContext'; +import { Registration } from './context/types'; + +export const TestAndEarnTakePart: FCWithChildren<{ + websiteLinkUrl: string; + websiteLinkText: string; + content: string; + sx?: SxProps; +}> = ({ content, websiteLinkText, websiteLinkUrl, sx }) => { + const [agree, setAgree] = React.useState(false); + const [busy, setBusy] = React.useState(false); + const [error, setError] = React.useState(); + const context = useTestAndEarnContext(); + const handleNext = async () => { + try { + setBusy(true); + if (context.clientDetails) { + const registration: Registration = await invoke('growth_tne_take_part'); + console.log('Registration: ', { registration }); + await context.setAndStoreRegistration(registration); + if (registration) { + console.log('Registered...'); + } else { + setError('Failed to get registration details'); + } + } else { + setError('Failed to get client details'); + } + } catch (e) { + const message = `${e}`; + console.error('An error occurred', message); + setError(message); + setBusy(false); // the busy state only resets on errors, for success stats, the context will navigate the window away + } + }; + return ( + <> + + + setAgree(checked)} /> + + {content} + + + + + {websiteLinkText} + + + } + onClick={handleNext} + > + Next + + + {error && ( + + Oh no! Something went wrong + {error} + + )} + + ); +}; diff --git a/nym-connect-android/src/components/Growth/TestAndEarnWinner.stories.tsx b/nym-connect-android/src/components/Growth/TestAndEarnWinner.stories.tsx new file mode 100644 index 0000000000..03ed77aa53 --- /dev/null +++ b/nym-connect-android/src/components/Growth/TestAndEarnWinner.stories.tsx @@ -0,0 +1,19 @@ +/* eslint-disable react/jsx-pascal-case */ +import * as React from 'react'; +import { ComponentMeta } from '@storybook/react'; +import { NymShipyardTheme } from 'src/theme'; +import { TestAndEarnWinner, TestAndEarnWinnerWithState } from './TestAndEarnWinner'; +import { MockTestAndEarnProvider_RegisteredWithDrawsAndEntryAndWinner } from './context/mocks/TestAndEarnContext'; + +export default { + title: 'Growth/TestAndEarn/Components/Cards/Winner', + component: TestAndEarnWinner, +} as ComponentMeta; + +export const Winner = () => ( + + + + + +); diff --git a/nym-connect-android/src/components/Growth/TestAndEarnWinner.tsx b/nym-connect-android/src/components/Growth/TestAndEarnWinner.tsx new file mode 100644 index 0000000000..41db329a2a --- /dev/null +++ b/nym-connect-android/src/components/Growth/TestAndEarnWinner.tsx @@ -0,0 +1,114 @@ +import React from 'react'; +import { Alert, AlertTitle, Button, Card, CardContent, CardMedia, Dialog, Typography } from '@mui/material'; +import { SxProps } from '@mui/system'; +import LoadingButton from '@mui/lab/LoadingButton'; +import winner from './content/assets/winner.webp'; +import { useTestAndEarnContext } from './context/TestAndEarnContext'; +import { DrawEntry, DrawEntryStatus } from './context/types'; +import { TestAndEarnEnterWalletAddress } from './TestAndEarnEnterWalletAddress'; +import Content from './content/en.yaml'; + +export const TestAndEarnWinner: FCWithChildren<{ + sx?: SxProps; + entry?: DrawEntry; +}> = ({ entry }) => { + const context = useTestAndEarnContext(); + const [busy, setBusy] = React.useState(false); + const [error, setError] = React.useState(); + const [showWalletCapture, setShowWalletCapture] = React.useState(false); + + const clear = () => { + setShowWalletCapture(false); + setError(undefined); + setBusy(false); + }; + + const handleStartWalletCapture = async () => { + setBusy(true); + setShowWalletCapture(true); + }; + + const cancelEndWalletCapture = async () => { + setBusy(false); + setShowWalletCapture(false); + }; + + const handleEndWalletCapture = async () => { + setBusy(true); + setShowWalletCapture(false); + + if (!context.walletAddress) { + setError('Wallet address is not set'); + return; + } + if (!entry?.draw_id) { + setError('Draw id is not set'); + return; + } + + try { + await context.claim(entry.draw_id, context.walletAddress); + } catch (e) { + const message = `${e}`; + console.error('Failed to submit claim', entry.draw_id, context.walletAddress); + setError(message); + } + setBusy(false); + }; + + return ( + <> + {showWalletCapture && ( + + + + )} + + + + + {Content.testAndEarn.winner.card.header} + + + {entry && ( + <> + {Content.testAndEarn.winner.card.text} {entry.draw_id}. + + )} + + {Content.testAndEarn.winner.claimButton.text} + + + {error && ( + + Oh no! Failed to submit claim + {error} + + + )} + + + + ); +}; + +export const TestAndEarnWinnerWithState: FCWithChildren<{ + sx?: SxProps; +}> = ({ sx }) => { + const context = useTestAndEarnContext(); + + if (context.draws?.current?.entry?.status === DrawEntryStatus.winner) { + return ; + } + + // when the user does not have any unclaimed prizes, don't render anything + return null; +}; diff --git a/nym-connect-android/src/components/Growth/TestAndEarnWinnerWalletAddress.tsx b/nym-connect-android/src/components/Growth/TestAndEarnWinnerWalletAddress.tsx new file mode 100644 index 0000000000..deea1049e7 --- /dev/null +++ b/nym-connect-android/src/components/Growth/TestAndEarnWinnerWalletAddress.tsx @@ -0,0 +1,12 @@ +import React from 'react'; +import { Box } from '@mui/material'; +import { SxProps } from '@mui/system'; +import Content from './content/TestAndEarn/WinnerEntersWalletAddress.mdx'; + +export const TestAndEarnWinnerWalletAddress: FCWithChildren<{ + sx?: SxProps; +}> = () => ( + + + +); diff --git a/nym-connect-android/src/components/Growth/content/TestAndEarn/Error.mdx b/nym-connect-android/src/components/Growth/content/TestAndEarn/Error.mdx new file mode 100644 index 0000000000..05afd5a492 --- /dev/null +++ b/nym-connect-android/src/components/Growth/content/TestAndEarn/Error.mdx @@ -0,0 +1,17 @@ +import { Alert, AlertTitle, Link } from '@mui/material'; +import { TestAndEarnError } from '../../TestAndEarnError'; + + + Oh no! Something went wrong + + Sorry about that. Here is some more information about the error that occurred: + + + + Any error reports that you send us will contain information about your client, the gateway you're using and your IP address. + + We need this information to make Nym better for everyone. + + + +If you'd like more information about Test&Earn please look on the Shipyard website. diff --git a/nym-connect-android/src/components/Growth/content/TestAndEarn/Stage0_intro.mdx b/nym-connect-android/src/components/Growth/content/TestAndEarn/Stage0_intro.mdx new file mode 100644 index 0000000000..557a800084 --- /dev/null +++ b/nym-connect-android/src/components/Growth/content/TestAndEarn/Stage0_intro.mdx @@ -0,0 +1,25 @@ +import { Card, CardContent, Link, Typography } from '@mui/material'; +import { TestAndEarnTakePart } from '../../TestAndEarnTakePart'; + +### Test privacy & Earn tokens! + + +Help us stress test the Nym privacy system and have the chance to earn 1000 NYMs per day! + + +All you need to do is: + +1. Make sure you're running NymConnect and it is connected. +2. Note your reference number. +3. NymConnect will ping you a task every day. + +Complete the task, post it on Twitter #PrivacyLovesCompany or Telegram along with your reference number! + +Thank you for being part of the Nym community and helping to build a flourishing and free digital society. #PrivacyLovesCompany and we love you! + + + + + + diff --git a/nym-connect-android/src/components/Growth/content/TestAndEarn/WinnerEntersWalletAddress.mdx b/nym-connect-android/src/components/Growth/content/TestAndEarn/WinnerEntersWalletAddress.mdx new file mode 100644 index 0000000000..d519ce49eb --- /dev/null +++ b/nym-connect-android/src/components/Growth/content/TestAndEarn/WinnerEntersWalletAddress.mdx @@ -0,0 +1,9 @@ +import { TestAndEarnEnterWalletAddress } from '../../TestAndEarnEnterWalletAddress'; + +### 🎉 Congratulations! One more thing... + +We need one more thing from you, and that is your wallet address: + + + +Once you've submitting your wallet address over the mixnet, we will be in touch to arrange sending your tokens to you. \ No newline at end of file diff --git a/nym-connect-android/src/components/Growth/content/TestAndEarnNotAvaialble.mdx b/nym-connect-android/src/components/Growth/content/TestAndEarnNotAvaialble.mdx new file mode 100644 index 0000000000..fc9a0a3c5d --- /dev/null +++ b/nym-connect-android/src/components/Growth/content/TestAndEarnNotAvaialble.mdx @@ -0,0 +1,3 @@ +## 😕 Sorry, Test&Earn is only accessible while you are connected to the mixnet + +Please connect to any service provider and try again once the connection has been established. \ No newline at end of file diff --git a/nym-connect-android/src/components/Growth/content/assets/matrix.webp b/nym-connect-android/src/components/Growth/content/assets/matrix.webp new file mode 100644 index 0000000000..2b1517166c Binary files /dev/null and b/nym-connect-android/src/components/Growth/content/assets/matrix.webp differ diff --git a/nym-connect-android/src/components/Growth/content/assets/winner.webp b/nym-connect-android/src/components/Growth/content/assets/winner.webp new file mode 100644 index 0000000000..3fbecb9f99 Binary files /dev/null and b/nym-connect-android/src/components/Growth/content/assets/winner.webp differ diff --git a/nym-connect-android/src/components/Growth/content/en.yaml b/nym-connect-android/src/components/Growth/content/en.yaml new file mode 100644 index 0000000000..849323e24e --- /dev/null +++ b/nym-connect-android/src/components/Growth/content/en.yaml @@ -0,0 +1,43 @@ +testAndEarn: + mainWindow: + button: + text: + default: Join Test&Earn + entered: Test&Earn + claim: Claim your reward + popup: + disconnected: Test&Earn is only available when connected. Please connect to any service provider. + help: + url: https://shipyard.nymtech.net/test-and-win + popupWindow: + title: NymConnect Test&Earn 🌈 + notifications: + takePart: + title: Thanks for taking part in Ter&Earn + body: Watch out for new tasks 👀 and take part to earn + youAreInDraw: + title: Thanks for completing the task ✨ + body: Post a message on Telegram, Discord or Twitter for a chance to to be selected 🤞 good luck + task: + afterText: Copy your reference number + beforeSocials: "And include it in your post to:" + draw: + next: + header: The next task starts + winner: + claimButton: + text: Claim your reward! + card: + header: You are a top contributor! + text: Congratulations, you have earned the reward for + status: + chip: + Pending: Good luck 🤞 + Winner: Rewarded! + Claimed: Claimed + NoWin: No rewards + text: + Pending: Task completed. Good luck 🤞 + Winner: Well done 🎉 + Claimed: You have claimed the reward 💰 + NoWin: Sorry you were not a top contributor, better luck next time! diff --git a/nym-connect-android/src/components/Growth/context/TestAndEarnContext.tsx b/nym-connect-android/src/components/Growth/context/TestAndEarnContext.tsx new file mode 100644 index 0000000000..a28fd221e2 --- /dev/null +++ b/nym-connect-android/src/components/Growth/context/TestAndEarnContext.tsx @@ -0,0 +1,272 @@ +/* eslint-disable @typescript-eslint/naming-convention */ +import React, { createContext, useCallback, useContext, useMemo, useState } from 'react'; +import { forage } from '@tauri-apps/tauri-forage'; +import { invoke } from '@tauri-apps/api'; +import { ClientId, DrawEntry, Draws, Registration } from './types'; +import { useClientContext } from '../../../context/main'; +import { ConnectionStatusKind } from '../../../types'; + +export type TTestAndEarnContext = { + loadedOnce: boolean; + loading: boolean; + clientDetails?: ClientId; + registration?: Registration; + walletAddress?: string; + draws?: Draws; + isWinnerWithUnclaimedPrize?: boolean; + isEnterWallet?: boolean; + error?: string; + setWalletAddress: (newWalletAddress: string) => void; + clearStorage: () => Promise; + toggleGrowthWindow: (windowTitle?: string) => Promise; + setAndStoreClientId: (newClientId: ClientId) => Promise; + setAndStoreRegistration: (registration: Registration) => Promise; + enterDraw: (drawId: string) => Promise; + claim: (drawId: string, walletAddress: string) => Promise; + refresh: () => Promise; +}; + +const defaultValue: TTestAndEarnContext = { + loadedOnce: false, + loading: true, + setWalletAddress: () => undefined, + clearStorage: async () => undefined, + toggleGrowthWindow: async () => undefined, + setAndStoreRegistration: async () => undefined, + setAndStoreClientId: async () => undefined, + enterDraw: async () => ({} as DrawEntry), + claim: async () => undefined, + refresh: async () => undefined, +}; + +export const TestAndEarnContext = createContext(defaultValue); + +const CLIENT_ID_KEY = 'tne_client_id'; +const REGISTRATION_KEY = 'tne_registration'; + +export const TestAndEarnContextProvider: FCWithChildren = ({ children }) => { + const clientContext = useClientContext(); + const [loadedOnce, setLoadedOnce] = useState(false); + const [loading, setLoading] = useState(true); + const [walletAddress, setWalletAddress] = useState(); + const [registration, setRegistration] = useState(); + const [clientDetails, setClientDetails] = useState(); + const [draws, setDraws] = useState(); + + const setAndStoreClientId = async (newClientId: ClientId) => { + await forage.setItem({ key: CLIENT_ID_KEY, value: newClientId } as any)(); + setClientDetails((prevState) => { + if ( + prevState?.client_id !== newClientId.client_id || + prevState?.client_id_signature !== newClientId.client_id_signature + ) { + console.log('Setting client details'); + return newClientId; + } + console.log('Skipping client details'); + return prevState; + }); + }; + const loadClientDetails = async () => { + const data: ClientId | undefined = await forage.getItem({ key: CLIENT_ID_KEY })(); + if (data) { + try { + setClientDetails((prevState) => { + if (prevState?.client_id !== data.client_id || prevState?.client_id_signature !== data.client_id_signature) { + console.log('Setting client details'); + return data; + } + console.log('Skipping client details'); + return prevState; + }); + } catch (e) { + console.error('Failed to get registration'); + } + } else { + const clientId: ClientId = await invoke('growth_tne_get_client_id'); + await setAndStoreClientId(clientId); + } + }; + + const loadRegistration = async () => { + const data: Registration | undefined = await forage.getItem({ key: REGISTRATION_KEY })(); + if (data) { + try { + setRegistration((prevState) => { + if ( + prevState?.timestamp !== data.timestamp || + prevState.client_id_signature !== data.client_id_signature || + prevState.id !== data.id + ) { + console.log('Setting registration'); + return data; + } + console.log('Skipping registration'); + return prevState; + }); + } catch (e) { + console.error('Failed to get registration'); + } + } + }; + + const loadDraws = React.useCallback(async () => { + setLoading(true); + let clientDetailsForDraws = clientDetails; + try { + if (!clientDetailsForDraws) { + console.log('[loadDraws] client details not set, trying to get...'); + clientDetailsForDraws = await invoke('growth_tne_get_client_id'); + } + + if (!clientDetailsForDraws) { + console.log('[loadDraws] failed to get client details not set, skipping...'); + setLoading(false); + setLoadedOnce(true); + return; + } + + const newDraws: Draws = await invoke('growth_tne_get_draws', { clientDetails: clientDetailsForDraws }); + console.log('[loadDraws] draws = ', newDraws); + + // find the entered draw and keep a reference + const entered = newDraws.draws.find((draw) => draw.draw_id === newDraws.current?.id); + if (newDraws.current) { + newDraws.current.entry = entered; + } + + console.log('[loadDraws] setting draws'); + setDraws(newDraws); + } catch (e) { + console.error('Could not get draws: ', e); + } + setLoading(false); + setLoadedOnce(true); + console.log('[loadDraws] done, loaded once'); + }, [clientDetails]); + + React.useEffect(() => { + loadClientDetails().catch(console.error); + loadRegistration().catch(console.error); + }, []); + + React.useEffect(() => { + if (registration && clientContext.connectionStatus === ConnectionStatusKind.connected) { + setTimeout(() => { + loadDraws().catch(console.error); + }, 1000 * 3); + } + }, [registration?.id, registration?.timestamp, clientContext.connectionStatus]); + + const refresh = React.useCallback(async () => { + console.log('Refreshing...'); + + console.log('Loading client details...'); + await loadClientDetails(); + + console.log('Loading registration...'); + await loadRegistration(); + + console.log('Loading draws...'); + await loadDraws(); + + console.log('Refresh complete.'); + }, [clientDetails]); + + const clearStorage = async () => { + await forage.setItem({ key: REGISTRATION_KEY, value: undefined })(); + }; + + const toggleGrowthWindow = useCallback(async (windowTitle?: string) => { + try { + await invoke('growth_tne_toggle_window', { windowTitle }); + } catch (e) { + console.error('Failed to toggle growth window', e); + } + }, []); + + const setAndStoreRegistration = async (newRegistration: Registration) => { + await forage.setItem({ key: REGISTRATION_KEY, value: newRegistration } as any)(); + setRegistration(newRegistration); + }; + + const enterDraw = async (drawId: string): Promise => { + if (!clientDetails) { + throw new Error('No client details set'); + } + if (!draws) { + throw new Error('No draws set'); + } + + const existingEntry: DrawEntry | undefined = draws.draws.filter((d) => d.draw_id === drawId)[0]; + if (existingEntry) { + throw new Error('Already entered into draw'); + } + + const entry: DrawEntry = await invoke('growth_tne_enter_draw', { clientDetails, drawId }); + console.log('Entered draw', { entry }); + + await loadDraws(); + + return entry; + }; + + const claim = async (drawId: string, newWalletAddress: string) => { + if (!clientDetails) { + throw new Error('No client details set'); + } + if (!draws) { + throw new Error('No draws set'); + } + if (!registration) { + throw new Error('No registration set'); + } + + const registrationId = registration.id; + + const args = { + registrationId, + clientDetails, + drawId, + walletAddress: newWalletAddress, + }; + console.log({ args }); + await invoke('growth_tne_submit_wallet_address', args); + + await loadDraws(); + }; + + const contextValue = useMemo( + () => ({ + loadedOnce, + loading, + clientDetails, + registration, + walletAddress, + draws, + clearStorage, + toggleGrowthWindow, + setWalletAddress, + setAndStoreClientId, + setAndStoreRegistration, + enterDraw, + refresh, + claim, + }), + [ + loadedOnce, + loading, + walletAddress, + registration, + refresh, + draws, + draws?.current?.last_modified, + draws?.current?.entry, + draws?.draws.length, + clientDetails, + ], + ); + return {children}; +}; + +export const useTestAndEarnContext = () => useContext(TestAndEarnContext); diff --git a/nym-connect-android/src/components/Growth/context/mocks/TestAndEarnContext.tsx b/nym-connect-android/src/components/Growth/context/mocks/TestAndEarnContext.tsx new file mode 100644 index 0000000000..b4388ddb72 --- /dev/null +++ b/nym-connect-android/src/components/Growth/context/mocks/TestAndEarnContext.tsx @@ -0,0 +1,262 @@ +/* eslint-disable @typescript-eslint/naming-convention */ +import React from 'react'; +import { DateTime } from 'luxon'; +import { TTestAndEarnContext, TestAndEarnContext } from '../TestAndEarnContext'; +import { DrawEntry, DrawEntryStatus, DrawWithWordOfTheDay } from '../types'; + +const methodDefaults = { + loadedOnce: true, + loading: false, + refresh: async () => undefined, + setAndStoreClientId: async () => undefined, + setAndStoreRegistration: async () => undefined, + clearStorage: async () => undefined, + toggleGrowthWindow: async () => undefined, + setWalletAddress: async () => undefined, + enterDraw: async () => ({} as DrawEntry), + claim: async () => undefined, +}; + +const mockValues_NotRegistered: TTestAndEarnContext = { + ...methodDefaults, +}; + +export const MockTestAndEarnProvider_NotRegistered: FCWithChildren = ({ children }) => ( + {children} +); + +export const testMarkdown = `**Create a sentence including "Nym" and one or more of the following words** *(in any language)*: + +- Privacy +- Pleasure +- Pineapple +- Mix +`; + +const mockValues_Registered: TTestAndEarnContext = { + ...methodDefaults, + registration: { + id: '1234', + client_id_signature: 'signature', + client_id: '5678', + timestamp: '2022-12-12T18:17:37.840Z', + }, +}; + +export const MockTestAndEarnProvider_Registered: FCWithChildren = ({ children }) => ( + {children} +); + +const allDraws: DrawEntry[] = [ + { + draw_id: '1111', + timestamp: DateTime.now().toISO(), + id: 'AAAA', + status: DrawEntryStatus.pending, + }, + { + draw_id: '2222', + timestamp: DateTime.now().toISO(), + id: 'BBBB', + status: DrawEntryStatus.noWin, + }, + { + draw_id: '2222', + timestamp: DateTime.now().toISO(), + id: 'BBBB', + status: DrawEntryStatus.claimed, + }, + { + draw_id: '2222', + timestamp: DateTime.now().toISO(), + id: 'BBBB', + status: DrawEntryStatus.winner, + }, +]; + +const draws: DrawEntry[] = [ + { + draw_id: '1111', + timestamp: DateTime.now().toISO(), + id: 'AAAA', + status: DrawEntryStatus.pending, + }, + { + draw_id: '2222', + timestamp: DateTime.now().toISO(), + id: 'BBBB', + status: DrawEntryStatus.noWin, + }, +]; + +const drawsWithWin: DrawEntry[] = [ + { + draw_id: '1111', + timestamp: DateTime.now().toISO(), + id: 'AAAA', + status: DrawEntryStatus.winner, + }, + { + draw_id: '2222', + timestamp: DateTime.now().toISO(), + id: 'BBBB', + status: DrawEntryStatus.noWin, + }, +]; + +const drawsWithClaim: DrawEntry[] = [ + { + draw_id: '1111', + timestamp: DateTime.now().toISO(), + id: 'AAAA', + status: DrawEntryStatus.claimed, + }, + { + draw_id: '2222', + timestamp: DateTime.now().toISO(), + id: 'BBBB', + status: DrawEntryStatus.noWin, + }, +]; + +const current: DrawWithWordOfTheDay = { + id: '1111', + start_utc: DateTime.now().toISO(), + end_utc: DateTime.now().plus({ day: 1 }).minus({ second: 25 }).toISO(), + last_modified: DateTime.now().toISO(), + word_of_the_day: testMarkdown, +}; + +const mockValues_RegisteredWithAllDrawsAndEntry: TTestAndEarnContext = { + ...mockValues_Registered, + draws: { + current: { + ...current, + }, + draws: allDraws, + }, +}; + +export const MockTestAndEarnProvider_RegisteredWithAllDraws: FCWithChildren = ({ children }) => ( + + {children} + +); + +const mockValues_RegisteredWithDrawsNoCurrent: TTestAndEarnContext = { + ...mockValues_Registered, + draws: { + draws: drawsWithClaim, + }, +}; + +export const MockTestAndEarnProvider_RegisteredWithDrawsNoCurrent: FCWithChildren = ({ children }) => ( + {children} +); + +const mockValues_RegisteredWithDraws: TTestAndEarnContext = { + ...mockValues_Registered, + draws: { + current, + draws, + }, +}; + +export const MockTestAndEarnProvider_RegisteredWithDraws: FCWithChildren = ({ children }) => ( + {children} +); + +const mockValues_RegisteredWithDrawsAndEntry: TTestAndEarnContext = { + ...mockValues_Registered, + draws: { + current: { + ...current, + entry: mockValues_RegisteredWithDraws.draws!.draws[0], + }, + draws, + }, +}; + +export const MockTestAndEarnProvider_RegisteredWithDrawsAndEntry: FCWithChildren = ({ children }) => ( + {children} +); + +const mockValues_RegisteredWithDrawsAndEntryAndWinner: TTestAndEarnContext = { + ...mockValues_Registered, + draws: { + current: { + ...current, + entry: drawsWithWin[0], + }, + draws: drawsWithWin, + }, + isWinnerWithUnclaimedPrize: true, +}; + +export const MockTestAndEarnProvider_RegisteredWithDrawsAndEntryAndWinner = ({ + children, +}: { + children: React.ReactNode; +}) => ( + + {children} + +); + +const mockValues_RegisteredWithDrawsAndEntryAndNoWinner: TTestAndEarnContext = { + ...mockValues_RegisteredWithDrawsAndEntry, + isWinnerWithUnclaimedPrize: false, +}; + +export const MockTestAndEarnProvider_RegisteredWithDrawsAndEntryAndNoWinner = ({ + children, +}: { + children: React.ReactNode; +}) => ( + + {children} + +); + +const mockValues_RegisteredWithDrawsAndEntryAndWinnerCollectWallet: TTestAndEarnContext = { + ...mockValues_Registered, + draws: { + draws: drawsWithWin, + }, +}; + +export const MockTestAndEarnProvider_RegisteredWithDrawsAndEntryAndWinnerCollectWallet = ({ + children, +}: { + children: React.ReactNode; +}) => ( + + {children} + +); + +const mockValues_RegisteredWithDrawsAndEntryAndWinnerClaimed: TTestAndEarnContext = { + ...mockValues_Registered, + draws: { + draws: drawsWithClaim, + }, +}; + +export const MockTestAndEarnProvider_RegisteredWithDrawsAndEntryAndWinnerClaimed = ({ + children, +}: { + children: React.ReactNode; +}) => ( + + {children} + +); + +const mockValues_RegisteredAndError: TTestAndEarnContext = { + ...mockValues_Registered, + error: 'Error message text will go here', +}; + +export const MockTestAndEarnProvider_RegisteredAndError: FCWithChildren = ({ children }) => ( + {children} +); diff --git a/nym-connect-android/src/components/Growth/context/types.ts b/nym-connect-android/src/components/Growth/context/types.ts new file mode 100644 index 0000000000..c7574b282f --- /dev/null +++ b/nym-connect-android/src/components/Growth/context/types.ts @@ -0,0 +1,64 @@ +export interface ClientId { + client_id: string; + client_id_signature: string; +} + +export interface Registration { + id: string; + client_id: string; + client_id_signature: string; + timestamp: string; +} + +export interface DrawEntryPartial { + draw_id: string; + client_id: string; + client_id_signature: string; +} + +export enum DrawEntryStatus { + pending = 'Pending', + winner = 'Winner', + noWin = 'NoWin', + claimed = 'Claimed', +} + +export interface DrawEntry { + id: string; + draw_id: string; + timestamp: string; + status: DrawEntryStatus; +} + +export interface DrawWithWordOfTheDay { + id: string; + start_utc: string; + end_utc: string; + word_of_the_day?: string; + last_modified: string; + entry?: DrawEntry; +} + +export interface ClaimPartial { + draw_id: string; + registration_id: string; + client_id: string; + client_id_signature: string; + wallet_address: string; +} + +export interface Winner { + id: string; + client_id: string; + draw_id: string; + timestamp: string; + winner_reg_id: string; + winner_wallet_address?: string; + winner_claim_timestamp?: string; +} + +export interface Draws { + current?: DrawWithWordOfTheDay; + next?: DrawWithWordOfTheDay; + draws: DrawEntry[]; +} diff --git a/nym-connect-android/src/components/HelpPage.tsx b/nym-connect-android/src/components/HelpPage.tsx new file mode 100644 index 0000000000..788f7264d5 --- /dev/null +++ b/nym-connect-android/src/components/HelpPage.tsx @@ -0,0 +1,33 @@ +import React from 'react'; +import { Stack, Typography } from '@mui/material'; +import { HelpPageActions } from './HelpPageActions'; +import { HelpImage } from './HelpPageImage'; +import { StepIndicator } from './HelpPageStepIndicator'; + +export const HelpPage = ({ + step, + description, + img, + onNext, + onPrev, +}: { + step: number; + description: string; + img: any; + onNext?: () => void; + onPrev?: () => void; +}) => ( + + + + + How to connect guide {step}/4 + + + {description} + + + + + +); diff --git a/nym-connect-android/src/components/HelpPageActions.tsx b/nym-connect-android/src/components/HelpPageActions.tsx new file mode 100644 index 0000000000..17e513120f --- /dev/null +++ b/nym-connect-android/src/components/HelpPageActions.tsx @@ -0,0 +1,20 @@ +import { ArrowBack, ArrowForward } from '@mui/icons-material'; +import { Box, Button } from '@mui/material'; +import React from 'react'; + +export const HelpPageActions = ({ onNext, onPrev }: { onNext?: () => void; onPrev?: () => void }) => ( + + {onPrev ? ( + + ) : ( +
+ )} + {onNext && ( + + )} + +); diff --git a/nym-connect-android/src/components/HelpPageImage.tsx b/nym-connect-android/src/components/HelpPageImage.tsx new file mode 100644 index 0000000000..27a8dcdca3 --- /dev/null +++ b/nym-connect-android/src/components/HelpPageImage.tsx @@ -0,0 +1,5 @@ +import React from 'react'; + +export const HelpImage = ({ img, imageDescription }: { img: any; imageDescription: string }) => ( + {imageDescription} +); diff --git a/nym-connect-android/src/components/HelpPageStepIndicator.tsx b/nym-connect-android/src/components/HelpPageStepIndicator.tsx new file mode 100644 index 0000000000..b5dff42b34 --- /dev/null +++ b/nym-connect-android/src/components/HelpPageStepIndicator.tsx @@ -0,0 +1,15 @@ +import { Box } from '@mui/material'; +import React from 'react'; + +const Step = ({ highlight }: { highlight: boolean }) => ( + +); + +export const StepIndicator = ({ step }: { step: number }) => ( + + + = 2} /> + = 3} /> + = 4} /> + +); diff --git a/nym-connect-android/src/components/InfoModal.tsx b/nym-connect-android/src/components/InfoModal.tsx new file mode 100644 index 0000000000..ed628de7eb --- /dev/null +++ b/nym-connect-android/src/components/InfoModal.tsx @@ -0,0 +1,69 @@ +import { Close, ErrorOutline } from '@mui/icons-material'; +import { Box, IconButton, Modal, Theme, Typography } from '@mui/material'; +import React from 'react'; + +const styles = { + position: 'absolute', + top: '50%', + left: '50%', + transform: 'translate(-50%, -50%)', + width: 200, + bgcolor: '#292E34', + p: 1.5, + borderRadius: 0.5, + height: 'fit-content', + border: (theme: Theme) => `1px solid ${theme.palette.grey[700]}`, +}; + +const ModalTitle = ({ title, withCloseIcon }: { title: string; withCloseIcon: boolean }) => ( + + + + {title} + + +); + +const ModalBody = ({ description, children }: { description: string; children?: React.ReactElement }) => ( + + {children} + + {description} + + +); + +export const InfoModal = ({ + title, + description, + show, + children, + Action, + onClose, +}: { + title: string; + description: string; + show: boolean; + children?: React.ReactElement; + Action?: React.ReactNode; + onClose?: () => void; +}) => ( + + + {onClose && ( + + + + + + )} + + {children} + {Action && ( + + {Action} + + )} + + +); diff --git a/nym-connect-android/src/components/IpAddressAndPort.tsx b/nym-connect-android/src/components/IpAddressAndPort.tsx new file mode 100644 index 0000000000..4e7618ed87 --- /dev/null +++ b/nym-connect-android/src/components/IpAddressAndPort.tsx @@ -0,0 +1,97 @@ +import { Box, Stack, Tooltip, Typography } from '@mui/material'; +import React from 'react'; +import { styled } from '@mui/system'; +import { writeText } from '@tauri-apps/api/clipboard'; +import CheckCircleOutlineIcon from '@mui/icons-material/CheckCircleOutline'; + +const IpAddressAndPortContainer = styled('div')({ + '.hoverAddressCopy:hover': { + cursor: 'pointer', + textDecoration: 'underline', + textDecorationColor: '#FB6E4E', + textDecorationThickness: '2px', + textUnderlineOffset: '4px', + }, +}); + +export const IpAddressAndPort: FCWithChildren<{ + label: string; + ipAddress: string; + port: number; +}> = ({ label, ipAddress, port }) => { + const [ipAddressCopied, setIpAddressCopied] = React.useState(false); + const [portCopied, setPortCopied] = React.useState(false); + + React.useEffect(() => { + if (ipAddressCopied) { + setTimeout(() => setIpAddressCopied(false), 2000); + } + }, [ipAddressCopied]); + + React.useEffect(() => { + if (portCopied) { + setTimeout(() => setPortCopied(false), 2000); + } + }, [portCopied]); + + return ( + + + + {label} + + + Port + + + + + + SOCKS5 proxy hostname copied to the clipboard + + ) : ( + <>Click to copy SOCKS5 proxy hostname + ) + } + > + { + await writeText(`${ipAddress}`); + setIpAddressCopied(true); + }} + > + {ipAddress} + + + + + SOCKS5 proxy port copied to the clipboard + + ) : ( + <>Click to copy SOCKS5 proxy port + ) + } + > + { + await writeText(`${port}`); + setPortCopied(true); + }} + > + {port} + + + + + ); +}; diff --git a/nym-connect-android/src/components/IpAddressAndPortModal.tsx b/nym-connect-android/src/components/IpAddressAndPortModal.tsx new file mode 100644 index 0000000000..8ed6d51e20 --- /dev/null +++ b/nym-connect-android/src/components/IpAddressAndPortModal.tsx @@ -0,0 +1,41 @@ +import React from 'react'; +import { Box, Button, Typography } from '@mui/material'; +import { InfoModal } from './InfoModal'; +import { CopyToClipboard } from './CopyToClipboard'; + +export const IpAddressAndPortModal = ({ + show, + ipAddress, + port, + onClose, +}: { + show: boolean; + ipAddress: string; + port: number; + onClose: () => void; +}) => ( + Done} + > + + + Socks5 address + + + {ipAddress} + + + + + Port + + + {port} + + + + +); diff --git a/nym-connect-android/src/components/LogViewer/index.tsx b/nym-connect-android/src/components/LogViewer/index.tsx new file mode 100644 index 0000000000..f2a9d2e6d0 --- /dev/null +++ b/nym-connect-android/src/components/LogViewer/index.tsx @@ -0,0 +1,97 @@ +import React, { FC, useEffect, useRef, useState } from 'react'; +import type { UnlistenFn } from '@tauri-apps/api/event'; +import { listen } from '@tauri-apps/api/event'; +import { Box, Paper, Chip, Table, TableBody, TableCell, TableContainer, TableHead, TableRow } from '@mui/material'; + +// see https://github.com/tauri-apps/tauri-plugin-log/blob/dev/webview-src/index.ts#L4 +enum LogLevel { + Trace = 1, + Debug, + Info, + Warn, + Error, +} + +const getLogLevelName = (value: LogLevel) => { + switch (value) { + case LogLevel.Trace: + return 'Trace'; + case LogLevel.Debug: + return 'Debug'; + case LogLevel.Info: + return 'Info'; + case LogLevel.Warn: + return 'Warn'; + case LogLevel.Error: + return 'Error'; + default: + return 'Unknown'; + } +}; + +// see https://github.com/tauri-apps/tauri-plugin-log/blob/dev/webview-src/index.ts#L147 +interface RecordPayload { + level: LogLevel; + message: string; +} + +export const LogViewer: FC = () => { + const unlisten = useRef(); + const messages = useRef([]); + const [messageCount, setMessageCount] = useState(0); + + useEffect(() => { + listen('log://log', (event) => { + console.log(event.payload); + const payload = event.payload as RecordPayload; + messages.current.unshift(payload); + setMessageCount((prev) => prev + 1); + }).then((fn) => { + unlisten.current = fn; + }); + + return () => { + if (unlisten.current) { + unlisten.current(); + } + }; + }, []); + + return ( + + + + + + + Severity + Log message + + + + {messages.current.map((m) => ( + + + + + {m.message} + + ))} + +
+
+
+ theme.palette.divider, + }} + > + {messageCount} log entries since opening this window + +
+ ); +}; diff --git a/nym-connect-android/src/components/NeedHelp.tsx b/nym-connect-android/src/components/NeedHelp.tsx new file mode 100644 index 0000000000..12d9587a5f --- /dev/null +++ b/nym-connect-android/src/components/NeedHelp.tsx @@ -0,0 +1,16 @@ +import React from 'react'; +import { Box, Button, Typography } from '@mui/material'; +import HelpOutlineIcon from '@mui/icons-material/HelpOutline'; + +const HELP_URL = 'https://docs.nymtech.net'; + +export const NeedHelp: FCWithChildren = () => ( + + + +); diff --git a/nym-connect-android/src/components/ServiceProviderInfo.tsx b/nym-connect-android/src/components/ServiceProviderInfo.tsx new file mode 100644 index 0000000000..a6289b5c41 --- /dev/null +++ b/nym-connect-android/src/components/ServiceProviderInfo.tsx @@ -0,0 +1,20 @@ +import React from 'react'; +import { Divider, Stack, Typography } from '@mui/material'; +import { ServiceProvider } from 'src/types/directory'; + +export const ServiceProviderInfo = ({ serviceProvider }: { serviceProvider: ServiceProvider }) => ( + + + Connection info + + {serviceProvider.description} + + + Gateway {serviceProvider.gateway} + + + + Provider {serviceProvider.address.slice(0, 35)}... + + +); diff --git a/nym-connect-android/src/components/ServiceProviderPopup.tsx b/nym-connect-android/src/components/ServiceProviderPopup.tsx new file mode 100644 index 0000000000..660fee4134 --- /dev/null +++ b/nym-connect-android/src/components/ServiceProviderPopup.tsx @@ -0,0 +1,37 @@ +import React from 'react'; +import { Autocomplete, Box, Dialog, DialogProps, TextField, Typography } from '@mui/material'; +import { Service, ServiceProvider, Services } from '../types/directory'; + +export const ServiceProviderPopup: FCWithChildren< + DialogProps & { services: Services; onServiceProviderChanged: (sp?: ServiceProvider, s?: Service) => void } +> = ({ services, onServiceProviderChanged, ...dialogProps }) => { + const options = services.flatMap((s) => + s.items.map((sp) => ({ id: `${s.id}-${sp.id}`, title: sp.description, service: s, sp })), + ); + return ( + + + // filterOptions.filter((o) => o.title.toLowerCase().includes(inputValue.toLowerCase())) + // } + options={options} + groupBy={(option) => option.service.description} + getOptionLabel={(option) => option.title} + onChange={(event, value) => onServiceProviderChanged(value?.sp, value?.service)} + isOptionEqualToValue={(option, value) => option.id.toLowerCase() === value?.id.toLowerCase()} + renderOption={(props, option) => ( + img': { mr: 2, flexShrink: 0 } }} {...props} fontSize="small"> + + {option.id} + +

{option.title}

+
+ )} + renderInput={(params) => } + /> +
+ ); +}; diff --git a/nym-connect-android/src/components/ServiceProviderSelector.stories.tsx b/nym-connect-android/src/components/ServiceProviderSelector.stories.tsx new file mode 100644 index 0000000000..c7df76ea4a --- /dev/null +++ b/nym-connect-android/src/components/ServiceProviderSelector.stories.tsx @@ -0,0 +1,342 @@ +import * as React from 'react'; +import { ComponentMeta } from '@storybook/react'; +import { Box } from '@mui/material'; +import { ServiceProviderSelector } from './ServiceProviderSelector'; +import { Services } from '../types/directory'; + +export default { + title: 'Components/Service Provider Selector', + component: ServiceProviderSelector, +} as ComponentMeta; + +const width = 240; + +export const Loading = () => ( + + + +); + +const services: Services = JSON.parse(`[ + { + "id": "keybase", + "description": "Keybase", + "items": [ + { + "id": "nym-keybase", + "description": "Nym Keybase Service Provider", + "address": "Entztfv6Uaz2hpYHQJ6JKoaCTpDL5dja18SuQWVJAmmx.Cvhn9rBJw5Ay9wgHcbgCnVg89MPSV5s2muPV2YF1BXYu@Fo4f4SQLdoyoGkFae5TpVhRVoXCF8UiypLVGtGjujVPf", + "gateway": "Fo4f4SQLdoyoGkFae5TpVhRVoXCF8UiypLVGtGjujVPf" + }, + { + "id": "shipyard-keybase-1", + "description": "Nym Keybase Service Provider", + "address": "D55ksecHzY6vAeqk8MCTzCfj2pqwJeKCKZCUUGnwGnn3.FS42vXS5a6GNTb1qk3aVk5mjSiJCAuawbBVyQZZVfhvt@DfNMqQRy6pPkU8Z5rBsxRwzDUzAMXHPFwMhjF16ScZqn", + "gateway": "DfNMqQRy6pPkU8Z5rBsxRwzDUzAMXHPFwMhjF16ScZqn" + }, + { + "id": "shipyard-keybase-2", + "description": "Nym Keybase Service Provider", + "address": "DFdDtW7LNBATxQ4ef3jNbqs3cRE8b9wDZTCctHCQRULa.4AbKiTNVUwYFWHhy98o5pT9dELiUrkXoJQ9wHqPgf6GV@GJqd3ZxpXWSNxTfx7B1pPtswpetH4LnJdFeLeuY5KUuN", + "gateway": "GJqd3ZxpXWSNxTfx7B1pPtswpetH4LnJdFeLeuY5KUuN" + }, + { + "id": "shipyard-keybase-3", + "description": "Nym Keybase Service Provider", + "address": "6Y1HE1jJ92P9yoHer11TR4A2NdZePrLGaBNFf65MnYGe.FwXoh217odQDWNmViqzNX28fauYrjB3PYLrVvpqnQrX4@5vC8spDvw5VDQ8Zvd9fVvBhbUDv9jABR4cXzd4Kh5vz", + "gateway": "5vC8spDvw5VDQ8Zvd9fVvBhbUDv9jABR4cXzd4Kh5vz" + }, + { + "id": "shipyard-keybase-4", + "description": "Nym Keybase Service Provider", + "address": "3zzhLtWvaJgn755MkRckG5aRnoTZich8ASn395iSsTgj.J1R5VuxXbh2eNHiaRbrwbKGXrrEQcHKLdzf8eg9HTB6q@3B7PsbXFuqq6rerYFLw5HPbQb4UmBqAhfWURRovMmWoj", + "gateway": "3B7PsbXFuqq6rerYFLw5HPbQb4UmBqAhfWURRovMmWoj" + }, + { + "id": "shipyard-keybase-5", + "description": "Nym Keybase Service Provider", + "address": "CHuXdZJYQ8xH7ekgN9gAuVtQ7ZikjjHEZY5BSN7yc5mN.29dFvqicKQQQvoX1vup44mspmc249RH5xgLibWMwTYGT@CfWcDJq8QBz6cVAPCYSaLbaJEhVTmHEmyYgQ6C5GdDW9", + "gateway": "CfWcDJq8QBz6cVAPCYSaLbaJEhVTmHEmyYgQ6C5GdDW9" + } + ] + }, + { + "id": "electrum", + "description": "Electrum Wallet", + "items": [ + { + "id": "nym-electrum", + "description": "Nym Electrum Service Provider", + "address": "DpB3cHAchJiNBQi5FrZx2csXb1mrHkpYh9Wzf8Rjsuko.ANNWrvHqMYuertHGHUrZdBntQhpzfbWekB39qez9U2Vx@2BuMSfMW3zpeAjKXyKLhmY4QW1DXurrtSPEJ6CjX3SEh", + "gateway": "2BuMSfMW3zpeAjKXyKLhmY4QW1DXurrtSPEJ6CjX3SEh" + }, + { + "id": "shipyard-electrum-1", + "description": "Nym Electrum Service Provider", + "address": "8Tb73cFQpXCLpgxEA2VSDru2hHrcZ3KQcyMsGbxcTjBp.4x5tu66k8YkHk4tYac1qwEFbNq5WsKiX5kR51q5KKH88@4WgKhJdmUffz4e1o1ftVAGS3HnG56LiNAxA9dmaekrVd", + "gateway": "4WgKhJdmUffz4e1o1ftVAGS3HnG56LiNAxA9dmaekrVd" + }, + { + "id": "shipyard-electrum-2", + "description": "Nym Electrum Service Provider", + "address": "GR6z31MwCsvxHrnvvVN1Cpasd8aQ1giwQqPTZM9dN7VH.5rEiqakSPDrBtKmvpU8Shnhz6gRM85JLoB7AX4h7PJYr@5Ao1J38frnU9Rx5YVeF5BWExcnDTcW8etNe9W2sRASXD", + "gateway": "5Ao1J38frnU9Rx5YVeF5BWExcnDTcW8etNe9W2sRASXD" + } + ] + }, + { + "id": "telegram", + "description": "Telegram", + "items": [ + { + "id": "shipyard-telegram-2", + "description": "Nym Telegram Service Provider", + "address": "C4w6ewbQtoaZEeoaaNw1xVASChqo4WVjNfuYEUFjZxpc.8F1D7rQXf2jGoj1Ken7PiGDM8HS2Ug79wSoc9nZ1iqh1@62F81C9GrHDRja9WCqozemRFSzFPMecY85MbGwn6efve", + "gateway": "62F81C9GrHDRja9WCqozemRFSzFPMecY85MbGwn6efve" + }, + { + "id": "shipyard-telegram-3", + "description": "Nym Telegram Service Provider", + "address": "DStL3BEUZuQZfbij1KAY3BvJh8rC5jpr9mc6AQ6aTLUu.Ax9foYaKfFgX6g8y393GoNpKkKrnDGFGRZwxDv9R7X6M@FQon7UwF5knbUr2jf6jHhmNLbJnMreck1eUcVH59kxYE", + "gateway": "FQon7UwF5knbUr2jf6jHhmNLbJnMreck1eUcVH59kxYE" + }, + { + "id": "shipyard-telegram-4", + "description": "Nym Telegram Service Provider", + "address": "8gRdGTzsDxYzpasRQhsRg59MCgNNhnfag2oFfwwZPXnB.DtDrGz7ScVm4o7sN4K3CYUJveYgz7fcXELBVLNDfMS9Y@3ojQD6V7skM1bSXJX7fVQvscjmcgptzdixQEaAha2ixh", + "gateway": "3ojQD6V7skM1bSXJX7fVQvscjmcgptzdixQEaAha2ixh" + }, + { + "id": "shipyard-telegram-5", + "description": "Nym Telegram Service Provider", + "address": "AR3oEM6Uvmfs6fyddwSehoBUKCFxz7MdFi4z7aahuHuY.3ZKapg9A3Py1PXhyLbCJr8ZbJsEV6NZdN1WJaGGut5tj@EEyq16v63aotPBCepxUpCgAojrNasZ6Hk1PjpRyBAdEp", + "gateway": "EEyq16v63aotPBCepxUpCgAojrNasZ6Hk1PjpRyBAdEp" + }, + { + "id": "shipyard-telegram-6", + "description": "Nym Telegram Service Provider", + "address": "7n1BYhsXSwcr8Qim8AqZTAodqFia4QG6T7CRc1ihQHpv.7o4trpGqu2LHMUiXc3dddgfGET1CFFcAK9gKYoHoSn5e@BTZNB3bkkEePsT14GN8ofVtM1SJae4YLWjpBerrKYfr", + "gateway": "BTZNB3bkkEePsT14GN8ofVtM1SJae4YLWjpBerrKYfr" + }, + { + "id": "shipyard-telegram-7", + "description": "Nym Telegram Service Provider", + "address": "Gv4TWhUKrvJfqh1jBRPGEQrikNZvZse2kS3ZgN9Z2nAZ.7KGPaaqUEum2C59jLvw7f8Ug8a48YuZdjjZu3t4JES4U@C7J8SwZQqjWqhBryyjJxLt7FacVuPTwAmR2otGy53ayi", + "gateway": "C7J8SwZQqjWqhBryyjJxLt7FacVuPTwAmR2otGy53ayi" + }, + { + "id": "shipyard-telegram-8", + "description": "Nym Telegram Service Provider", + "address": "8Mqgp12cpF6FSXMeqzxgFgQXvTSapyNqGAi5wy7ub4ge.7z7PDsiJGiGxGz4p77v5L5fZhXBJ5qNZ8CgJwYNr6H6J", + "gateway": "3zd3wrCK8Dz5TXrcvk5dG5s9EEdf4Ck1v9VgBPMMFVkR" + }, + { + "id": "shipyard-telegram-9", + "description": "Nym Telegram Service Provider", + "address": "F3N5eiPDZcGFC985Go4Mpv8p9uxFD1L3jRUdrLCbrZLm.EyTxWwwTwYpPrJBmc97GLd1LpUAphjptS5y1ed182bGk@GAjhJcrd6f1edaqUkfWCff6zdHoqo756qYrc2TfPuCXJ", + "gateway": "GAjhJcrd6f1edaqUkfWCff6zdHoqo756qYrc2TfPuCXJ" + }, + { + "id": "shipyard-telegram-10", + "description": "Nym Telegram Service Provider", + "address": "G7y7e1nVBr8fmQSzdeAxXnCmmmJb5k8N3E8LBV31KE5g.GRRUCj6t6cCUUjakmTWzidMLiYA7EdCedKnup8osaBC6@AJad2R9virYEYXEsTcicN5y5tyPoixrhhAGsxoESZVnc", + "gateway": "AJad2R9virYEYXEsTcicN5y5tyPoixrhhAGsxoESZVnc" + }, + { + "id": "shipyard-telegram-11", + "description": "Nym Telegram Service Provider", + "address": "2kq9Z7RyDZtb8kxXjyP3ZT8VMWHg6JXFDChGuuNBk7Hw.F5XYbBaGSoF8qAo8faPcaNRPHEq3Y25BDcwESeabUS9S@HaLyPQrhBTq75dnGeBUdYWeFVA2BBn39MgkhEt3VTMMM", + "gateway": "HaLyPQrhBTq75dnGeBUdYWeFVA2BBn39MgkhEt3VTMMM" + }, + { + "id": "shipyard-telegram-12", + "description": "Nym Telegram Service Provider", + "address": "GegdtpNzYj4QCgpih9Kxv7ZVZxmVdxYHsDkiPsbT71XG.E8xtE8mrapjzFtyuziZSrsScAKhwZMH5wNpKWtKfzJ5Y@9Byd9VAtyYMnbVAcqdoQxJnq76XEg2dbxbiF5Aa5Jj9J", + "gateway": "9Byd9VAtyYMnbVAcqdoQxJnq76XEg2dbxbiF5Aa5Jj9J" + }, + { + "id": "shipyard-telegram-13", + "description": "Nym Telegram Service Provider", + "address": "4SsrDQeEtG3mpeD9nN5CDdGaCsxFvNeYMhoviDzNNB9f.GyqG6iK5rBvhe3HXLR11m6ULpf13ATgYvkkidLmteDLs@5EpkkrMFYAM3XcaztXnZwBWquURHSKsyc9JxUCengDFS", + "gateway": "5EpkkrMFYAM3XcaztXnZwBWquURHSKsyc9JxUCengDFS" + }, + { + "id": "shipyard-telegram-14", + "description": "Nym Telegram Service Provider", + "address": "9JoHRu2RrSD1fjbj9NSTASgjv9Szep7Nhd9L2PywxbBi.AZhAUDNX6iH8BqXyR5c7TJuzpwMEvDXrabNLGuRukvVf@9xJM74FwwHhEKKJHihD21QSZnHM2QBRMoFx9Wst6qNBS", + "gateway": "9xJM74FwwHhEKKJHihD21QSZnHM2QBRMoFx9Wst6qNBS" + }, + { + "id": "shipyard-telegram-15", + "description": "Nym Telegram Service Provider", + "address": "3K174ijjXqCkhMDT9xLcqjS4MXk2QsqZt4PdgNcuUrnn.BNnHnQmWoj6Uo6kkS1QkPqsdHaXrcwyR9F6MnnzDkZJL@C7J8SwZQqjWqhBryyjJxLt7FacVuPTwAmR2otGy53ayi", + "gateway": "C7J8SwZQqjWqhBryyjJxLt7FacVuPTwAmR2otGy53ayi" + }, + { + "id": "shipyard-telegram-16", + "description": "Nym Telegram Service Provider", + "address": "BqX5Q3MEcbTnM39hUswQchLW68SrqbhL8K5ucrLmtP39.AWrVsFoVC9s6KjdpcasATmZPA3GtMsUxcfHpAkuNdtFG@Emswx6KXyjRfq1c2k4d4uD2e6nBSbH1biorCZUei8UNS", + "gateway": "Emswx6KXyjRfq1c2k4d4uD2e6nBSbH1biorCZUei8UNS" + }, + { + "id": "shipyard-telegram-17", + "description": "Nym Telegram Service Provider", + "address": "2tQxccgcqdkuUvLqgiEkEN4rNRZ5QknygnKAFcS4gfoe.EVrY5q5sqDqBUbS3wHsRRZhk2MAw1S17hNoH1Bicyv7n@DAGQxdxwAkwjaLjTw1B9vndia4YyFD15qRgcTQxrmkom", + "gateway": "DAGQxdxwAkwjaLjTw1B9vndia4YyFD15qRgcTQxrmkom" + }, + { + "id": "shipyard-telegram-18", + "description": "Nym Telegram Service Provider", + "address": "8YG1rcEauJA814Nd7hSxjNe2UrRwrGsrXTm1Cmd3gRrU.FxYaYqpNN8PciNsySs3zYPrTB1J8AYUu9DBsM2vVDDfF@7EfEESLo71GUvx3UEW79LgTegHUBPUocUzGyJVv6LHog", + "gateway": "7EfEESLo71GUvx3UEW79LgTegHUBPUocUzGyJVv6LHog" + }, + { + "id": "shipyard-telegram-19", + "description": "Nym Telegram Service Provider", + "address": "HPiXADVFLwLQPNpPtyYefzvYntC6tp9UJ5fJZGfkqvDt.2EUUxmeT3AiaUzAcE5SyXRAk8a2JXBkRz4B8McSdkrST@9ACTkYraCqE9jMb6zb6ne8EDQGGhZw5ykNiq9YRUdHTD", + "gateway": "9ACTkYraCqE9jMb6zb6ne8EDQGGhZw5ykNiq9YRUdHTD" + }, + { + "id": "shipyard-telegram-20", + "description": "Nym Telegram Service Provider", + "address": "2QLnEEnTmf2NRWtcQPWBeRcg7Hej5WSPWRWwtTpEEZWF.BheS78ozc8ngvhsXNNnshdJzpoYsmEvhfn3WKUYF5dRU@C2uyokSPoxhku9GexRxEo1e8KPZ7q6e8FXmK3gtY8kkF", + "gateway": "C2uyokSPoxhku9GexRxEo1e8KPZ7q6e8FXmK3gtY8kkF" + }, + { + "id": "shipyard-telegram-21", + "description": "Nym Telegram Service Provider", + "address": "FuBbnwiANfaXZnn683jBapK5XVm5ttgZSykU3vqPSHoD.94MFGv1VcBLTkRwzBDQUkWjvqtZYVBrJg2Q8JGbizcib@CTqYPY8htdAQMXCzRW9SjZzZuqYwSt2iUh6HPaNgmTvK", + "gateway": "CTqYPY8htdAQMXCzRW9SjZzZuqYwSt2iUh6HPaNgmTvK" + }, + { + "id": "shipyard-telegram-22", + "description": "Nym Telegram Service Provider", + "address": "9EbQx5jQznSVbftFom7sqUSHAACrsfvMhrzhaFt4A3SZ.D1FQCirL4YKwfcmtMGvB5Rugt5sAzGEhdSjJ3bHVQRZ@7Zh1Sz5dXpA6s53CbtcdqhQhLqwf4cLynL7KqHKcjrG4", + "gateway": "7Zh1Sz5dXpA6s53CbtcdqhQhLqwf4cLynL7KqHKcjrG4" + }, + { + "id": "shipyard-telegram-24", + "description": "Nym Telegram Service Provider", + "address": "6Umawwvf551VyB3Ko46NgKLqJdTFJeToCM67mrTmM3G.3A4sesBac4KGuMTFjvYBwLpksMJvbMbteGJQgmm4PV4Y@AnnYnEtBjB2a5sHmeRCnBq43qxyHDf95Bqd7cwQyKNLR", + "gateway": "AnnYnEtBjB2a5sHmeRCnBq43qxyHDf95Bqd7cwQyKNLR" + }, + { + "id": "shipyard-telegram-25", + "description": "Nym Telegram Service Provider", + "address": "CDtxTeoyqq83JpV9G8cR5HRHRdMMaVspQsCwH3Qnajt3.F5EHK9HFcdGrE2hqA7bK9AUmkbihujYDhtNNqHKxW765@BDkeNx7JQm5NsQakst9s8htogZXhpTQedFAgZpvsGCqH", + "gateway": "BDkeNx7JQm5NsQakst9s8htogZXhpTQedFAgZpvsGCqH" + }, + { + "id": "shipyard-telegram-26", + "description": "Nym Telegram Service Provider", + "address": "HukZkLG2DoarQEqaoDLuqW1GFf2NSHDUMGBZiyJGRYJD.9GyU8wPsyzcvRjcyk8hiNpTJbXCmq5F3VoVhFBZYuHR3@GsGEZiDBz8SWfHGaK5SDmhfbTEM55v37WCYYcT9wTSxN", + "gateway": "GsGEZiDBz8SWfHGaK5SDmhfbTEM55v37WCYYcT9wTSxN" + }, + { + "id": "shipyard-telegram-27", + "description": "Nym Telegram Service Provider", + "address": "773y8iMVJiRk4dRbjQzkJVbrei4TwkePNE5WTEttt77d.3Mw47C9XZj3oAzk1iSqC5Y36tbBsjtaTtdgaHM3Zsdma@7fiZtNL1RACQTwGrKLBT9nbY77bfwZnX9rqcWqc53qgv", + "gateway": "7fiZtNL1RACQTwGrKLBT9nbY77bfwZnX9rqcWqc53qgv" + }, + { + "id": "shipyard-telegram-28", + "description": "Nym Telegram Service Provider", + "address": "6jQJEorCu7YiP9HdDaMeHxcNhxeNmZ1kpd836GnqLZX.HsJqEmNTszGecsKqFB37i84nBXxqf4ETgrKmKmBvMGHC@FYnDMQzT49ZGM23gVqpTxfih14V6wuedNXirekmt37zE", + "gateway": "FYnDMQzT49ZGM23gVqpTxfih14V6wuedNXirekmt37zE" + }, + { + "id": "shipyard-telegram-29", + "description": "Nym Telegram Service Provider", + "address": "BiCSyovpFMuSnTvF2TdiuZwrytXDrd9AH47ZMcCxscVC.G9YpdicA9BBNoVHDgjWjgt17wv5WYKWcbE3vPJJVpSJD@GAjhJcrd6f1edaqUkfWCff6zdHoqo756qYrc2TfPuCXJ", + "gateway":"GAjhJcrd6f1edaqUkfWCff6zdHoqo756qYrc2TfPuCXJ" + }, + { + "id": "shipyard-telegram-30", + "description": "Nym Telegram Service Provider", + "address": "AQRRAs9oc8QWXAFBs44YhCKUny7AyLsfLy91pwmGgxuf.CWUKoKA1afSKyw5BnFJJg19UDgnaVATupsFhQpyTEBHJ@EBT8jTD8o4tKng2NXrrcrzVhJiBnKpT1bJy5CMeArt2w", + "gateway": "EBT8jTD8o4tKng2NXrrcrzVhJiBnKpT1bJy5CMeArt2w" + }, + { + "id": "shipyard-telegram-31", + "description": "Nym Telegram Service Provider", + "address": "6YqjAZK3Pr1ngiBLcDkotboB5WiN6k6NPpbXvShH4pR5.9Ss6VW3Xbyi8LuxduNNwnXEv9njHCQ2PLSP1UK6tsoa5@42XCK9dMS9m5XJLzQd2dBuwimk6ndZnczhZaV5VPFkQD", + "gateway": "42XCK9dMS9m5XJLzQd2dBuwimk6ndZnczhZaV5VPFkQD" + }, + { + "id": "shipyard-telegram-32", + "description": "Nym Telegram Service Provider", + "address": "EmYWLeybmj86Vzr62vxuZ3T15jwMNHggzK7sQwid96yp.GyaF9WprSr56cxUdGf5TpcUvAjb2VbAr8CVBrmBUYAaw@GL5wESoz4oSbpBaTki9qB9213FGUQXCiRjbzDkhWwoBC", + "gateway": "GL5wESoz4oSbpBaTki9qB9213FGUQXCiRjbzDkhWwoBC" + }, + { + "id": "shipyard-telegram-33", + "description": "Nym Telegram Service Provider", + "address": "4PDb96cck5btTj6G7rsomqwHJsp4qu8uPvFCbwHfjFUx.C5dKbaoakH7egsZvAueRbwLFbmxnQaVMeSr6QTMpuBAA@58ceEFaLJh6zAo3cirzT1BDQm7D3L5acnQrxGH1D6TAY", + "gateway": "58ceEFaLJh6zAo3cirzT1BDQm7D3L5acnQrxGH1D6TAY" + }, + { + "id": "shipyard-telegram-34", + "description": "Nym Telegram Service Provider", + "address": "BeZbeMf9vcpUf368qDd85dtLwXLj4Ee5bsHMB2fUD8uX.HELVbppkwU1jmzUAUrCEbHeJfVciSeo8VGAkbJSpwxsb@ADdHkiTfkpsSt31zVToWW9j3KikH24aLAAwDKtCYE5jY", + "gateway":"ADdHkiTfkpsSt31zVToWW9j3KikH24aLAAwDKtCYE5jY" + }, + { + "id": "shipyard-telegram-35", + "description": "Nym Telegram Service Provider", + "address": "Bp4JRFyf7GB9L9J95AqMPnz9zbGmPnViA5fDXKeNraLJ.D6CTdcjJVxDmH2UQvzXuPWg9Se9xXYe76uDMypXvhzd7@6UjGEeQZK14C5K2kenycTkqt7qRjEHGLgaQx3FWySo3N", + "gateway": "6UjGEeQZK14C5K2kenycTkqt7qRjEHGLgaQx3FWySo3N" + }, + { + "id": "shipyard-telegram-36", + "description": "Nym Telegram Service Provider", + "address": "91h7io6BGhVjbtC7dbbRcScyTJcTfnMsTQZ6aWMVsrWR.Epb4hANXCp8cGEY3wSgawux991ti9Z5Y1FHTMzAKFa6E@DF4TE7V8kJkttMvnoSVGnRFFRt6WYGxxiC2w1XyPQnHe", + "gateway": "DF4TE7V8kJkttMvnoSVGnRFFRt6WYGxxiC2w1XyPQnHe" + }, + { + "id": "shipyard-telegram-37", + "description": "Nym Telegram Service Provider", + "address": "Cy2wuwKpWZ3iWzKU3ZWL1qqcVfJ5Cq85dU7UHVWwv2gc.9AhC9b2zVcLnXLGriMdxjpsWJpq6iAdCavDi63udbL89@678qVUJ21uwxZBhp3r56z7GRf6gMh3NYDHruTegPtgMf", + "gateway": "678qVUJ21uwxZBhp3r56z7GRf6gMh3NYDHruTegPtgMf" + }, + { + "id": "shipyard-telegram-38", + "description": "Nym Telegram Service Provider", + "address": "GgUeUWW1NRSuquZYeZf3WkppE92EQUHJrFjNZtYU1jow.CSEjwrRi4f4uyw7N6L2LPKw2tB8spcMbFu99wHZzFZSj@77TSuVU8d1oXKbPzjec2xh4i3Wj5WwUyy9Lr36sm8gZm", + "gateway": "77TSuVU8d1oXKbPzjec2xh4i3Wj5WwUyy9Lr36sm8gZm" + }, + { + "id": "shipyard-telegram-39", + "description": "Nym Telegram Service Provider", + "address": "kz4zWwSkYiQxqxXFPNcGUByTPQWXascD9RfYsmSxY7n.ajp3SjbBVBjrU9nXpSQXAXzbb6EHJJyhbY6cc1ajayx@BTZNB3bkkEePsT14GN8ofVtM1SJae4YLWjpBerrKYf", + "gateway": "HyS2UZtZX3kQXdazbdE99DhCjBXjbG61LC9QsmXwbxrU" + } + ] + }, + { + "id": "blockstream", + "description": "Blockstream Green", + "items": [ + { + "id": "nym-blockstream", + "description": "Nym Blockstream Green Service Provider", + "address": "GiRjFWrMxt58pEMuusm4yT3RxoMD1MMPrR9M2N4VWRJP.3CNZBPq4vg7v7qozjGjdPMXcvDmkbWPCgbGCjQVw9n6Z@2xU4CBE6QiiYt6EyBXSALwxkNvM7gqJfjHXaMkjiFmYW", + "gateway": "2xU4CBE6QiiYt6EyBXSALwxkNvM7gqJfjHXaMkjiFmYW" + } + ] + } +]`); +export const Loaded = () => ( + + + +); + +export const ServiceAlreadySelected = () => ( + + console.log('New service provider selected: ', serviceProvider)} + /> + +); diff --git a/nym-connect-android/src/components/ServiceProviderSelector.tsx b/nym-connect-android/src/components/ServiceProviderSelector.tsx new file mode 100644 index 0000000000..741465dac4 --- /dev/null +++ b/nym-connect-android/src/components/ServiceProviderSelector.tsx @@ -0,0 +1,147 @@ +import React, { useEffect, useMemo } from 'react'; +import { Box, CircularProgress, MenuItem, Stack, TextField, Tooltip, Typography } from '@mui/material'; +import { Service, ServiceProvider, Services } from '../types/directory'; +import { useTauriEvents } from '../utils'; + +type ServiceWithRandomSp = { + id: string; + description: string; + sp: ServiceProvider; +}; + +const defaultServiceValue = { id: '', description: '', items: [] }; + +export const ServiceProviderSelector: FCWithChildren<{ + onChange?: (serviceProvider?: ServiceProvider) => void; + services?: Services; + currentSp?: ServiceProvider; +}> = ({ services, currentSp, onChange }) => { + const [service, setService] = React.useState(defaultServiceValue); + const [serviceProvider, setServiceProvider] = React.useState(currentSp); + const [resetTrigger, setResetTrigger] = React.useState(new Date().toISOString()); + + const handleSelectSp = (newServiceProvider?: ServiceProvider) => { + if (newServiceProvider && newServiceProvider !== currentSp) { + setServiceProvider(newServiceProvider); + onChange?.(newServiceProvider); + } + }; + + // when the user clears local storage, reset the selector + useTauriEvents('help://clear-storage', () => { + setService(defaultServiceValue); + setServiceProvider(undefined); + onChange?.(undefined); + setResetTrigger(new Date().toISOString()); + }); + + useEffect(() => { + if (!serviceProvider && currentSp) { + setServiceProvider(currentSp); + } + }, [currentSp]); + + useEffect(() => { + if (services && serviceProvider) { + // retrieve the service corresponding to this service provider + + const match = services.find((s) => + s.items.some( + ({ id, address, gateway }) => + id === serviceProvider.id && address === serviceProvider.address && gateway === serviceProvider.gateway, + ), + ); + + if (match) { + setService(match); + } + } + }, [serviceProvider, services]); + + if (!services) { + return ( + + theme.palette.common.white}> + + Loading services... + + + ); + } + + const servicesWithRandomSp: ServiceWithRandomSp[] = useMemo( + () => + services.map(({ id, items, description }) => ({ + id, + description, + sp: items[Math.floor(Math.random() * items.length)], + })), + [services, resetTrigger], + ); + + if (!service) return null; + + return ( + + + {servicesWithRandomSp.map(({ id, description, sp }) => ( + handleSelectSp(sp)}> + + + {sp.id} + + + {sp.description} + + + Gateway {sp.gateway.slice(0, 10)}... + + + Provider {sp.address.slice(0, 10)}... + + + } + arrow + placement="top" + > + {description} + + + ))} + + + ); +}; diff --git a/nym-connect-android/src/components/ServiceSelector.stories.tsx b/nym-connect-android/src/components/ServiceSelector.stories.tsx new file mode 100644 index 0000000000..56be7c19ee --- /dev/null +++ b/nym-connect-android/src/components/ServiceSelector.stories.tsx @@ -0,0 +1,342 @@ +import * as React from 'react'; +import { ComponentMeta } from '@storybook/react'; +import { Box } from '@mui/material'; +import { ServiceSelector } from './ServiceSelector'; +import { Services } from '../types/directory'; + +export default { + title: 'Components/Service Selector', + component: ServiceSelector, +} as ComponentMeta; + +const width = 240; + +export const Loading = () => ( + + + +); + +const services: Services = JSON.parse(`[ + { + "id": "keybase", + "description": "Keybase", + "items": [ + { + "id": "nym-keybase", + "description": "Nym Keybase Service Provider", + "address": "Entztfv6Uaz2hpYHQJ6JKoaCTpDL5dja18SuQWVJAmmx.Cvhn9rBJw5Ay9wgHcbgCnVg89MPSV5s2muPV2YF1BXYu@Fo4f4SQLdoyoGkFae5TpVhRVoXCF8UiypLVGtGjujVPf", + "gateway": "Fo4f4SQLdoyoGkFae5TpVhRVoXCF8UiypLVGtGjujVPf" + }, + { + "id": "shipyard-keybase-1", + "description": "Nym Keybase Service Provider", + "address": "D55ksecHzY6vAeqk8MCTzCfj2pqwJeKCKZCUUGnwGnn3.FS42vXS5a6GNTb1qk3aVk5mjSiJCAuawbBVyQZZVfhvt@DfNMqQRy6pPkU8Z5rBsxRwzDUzAMXHPFwMhjF16ScZqn", + "gateway": "DfNMqQRy6pPkU8Z5rBsxRwzDUzAMXHPFwMhjF16ScZqn" + }, + { + "id": "shipyard-keybase-2", + "description": "Nym Keybase Service Provider", + "address": "DFdDtW7LNBATxQ4ef3jNbqs3cRE8b9wDZTCctHCQRULa.4AbKiTNVUwYFWHhy98o5pT9dELiUrkXoJQ9wHqPgf6GV@GJqd3ZxpXWSNxTfx7B1pPtswpetH4LnJdFeLeuY5KUuN", + "gateway": "GJqd3ZxpXWSNxTfx7B1pPtswpetH4LnJdFeLeuY5KUuN" + }, + { + "id": "shipyard-keybase-3", + "description": "Nym Keybase Service Provider", + "address": "6Y1HE1jJ92P9yoHer11TR4A2NdZePrLGaBNFf65MnYGe.FwXoh217odQDWNmViqzNX28fauYrjB3PYLrVvpqnQrX4@5vC8spDvw5VDQ8Zvd9fVvBhbUDv9jABR4cXzd4Kh5vz", + "gateway": "5vC8spDvw5VDQ8Zvd9fVvBhbUDv9jABR4cXzd4Kh5vz" + }, + { + "id": "shipyard-keybase-4", + "description": "Nym Keybase Service Provider", + "address": "3zzhLtWvaJgn755MkRckG5aRnoTZich8ASn395iSsTgj.J1R5VuxXbh2eNHiaRbrwbKGXrrEQcHKLdzf8eg9HTB6q@3B7PsbXFuqq6rerYFLw5HPbQb4UmBqAhfWURRovMmWoj", + "gateway": "3B7PsbXFuqq6rerYFLw5HPbQb4UmBqAhfWURRovMmWoj" + }, + { + "id": "shipyard-keybase-5", + "description": "Nym Keybase Service Provider", + "address": "CHuXdZJYQ8xH7ekgN9gAuVtQ7ZikjjHEZY5BSN7yc5mN.29dFvqicKQQQvoX1vup44mspmc249RH5xgLibWMwTYGT@CfWcDJq8QBz6cVAPCYSaLbaJEhVTmHEmyYgQ6C5GdDW9", + "gateway": "CfWcDJq8QBz6cVAPCYSaLbaJEhVTmHEmyYgQ6C5GdDW9" + } + ] + }, + { + "id": "electrum", + "description": "Electrum Wallet", + "items": [ + { + "id": "nym-electrum", + "description": "Nym Electrum Service Provider", + "address": "DpB3cHAchJiNBQi5FrZx2csXb1mrHkpYh9Wzf8Rjsuko.ANNWrvHqMYuertHGHUrZdBntQhpzfbWekB39qez9U2Vx@2BuMSfMW3zpeAjKXyKLhmY4QW1DXurrtSPEJ6CjX3SEh", + "gateway": "2BuMSfMW3zpeAjKXyKLhmY4QW1DXurrtSPEJ6CjX3SEh" + }, + { + "id": "shipyard-electrum-1", + "description": "Nym Electrum Service Provider", + "address": "8Tb73cFQpXCLpgxEA2VSDru2hHrcZ3KQcyMsGbxcTjBp.4x5tu66k8YkHk4tYac1qwEFbNq5WsKiX5kR51q5KKH88@4WgKhJdmUffz4e1o1ftVAGS3HnG56LiNAxA9dmaekrVd", + "gateway": "4WgKhJdmUffz4e1o1ftVAGS3HnG56LiNAxA9dmaekrVd" + }, + { + "id": "shipyard-electrum-2", + "description": "Nym Electrum Service Provider", + "address": "GR6z31MwCsvxHrnvvVN1Cpasd8aQ1giwQqPTZM9dN7VH.5rEiqakSPDrBtKmvpU8Shnhz6gRM85JLoB7AX4h7PJYr@5Ao1J38frnU9Rx5YVeF5BWExcnDTcW8etNe9W2sRASXD", + "gateway": "5Ao1J38frnU9Rx5YVeF5BWExcnDTcW8etNe9W2sRASXD" + } + ] + }, + { + "id": "telegram", + "description": "Telegram", + "items": [ + { + "id": "shipyard-telegram-2", + "description": "Nym Telegram Service Provider", + "address": "C4w6ewbQtoaZEeoaaNw1xVASChqo4WVjNfuYEUFjZxpc.8F1D7rQXf2jGoj1Ken7PiGDM8HS2Ug79wSoc9nZ1iqh1@62F81C9GrHDRja9WCqozemRFSzFPMecY85MbGwn6efve", + "gateway": "62F81C9GrHDRja9WCqozemRFSzFPMecY85MbGwn6efve" + }, + { + "id": "shipyard-telegram-3", + "description": "Nym Telegram Service Provider", + "address": "DStL3BEUZuQZfbij1KAY3BvJh8rC5jpr9mc6AQ6aTLUu.Ax9foYaKfFgX6g8y393GoNpKkKrnDGFGRZwxDv9R7X6M@FQon7UwF5knbUr2jf6jHhmNLbJnMreck1eUcVH59kxYE", + "gateway": "FQon7UwF5knbUr2jf6jHhmNLbJnMreck1eUcVH59kxYE" + }, + { + "id": "shipyard-telegram-4", + "description": "Nym Telegram Service Provider", + "address": "8gRdGTzsDxYzpasRQhsRg59MCgNNhnfag2oFfwwZPXnB.DtDrGz7ScVm4o7sN4K3CYUJveYgz7fcXELBVLNDfMS9Y@3ojQD6V7skM1bSXJX7fVQvscjmcgptzdixQEaAha2ixh", + "gateway": "3ojQD6V7skM1bSXJX7fVQvscjmcgptzdixQEaAha2ixh" + }, + { + "id": "shipyard-telegram-5", + "description": "Nym Telegram Service Provider", + "address": "AR3oEM6Uvmfs6fyddwSehoBUKCFxz7MdFi4z7aahuHuY.3ZKapg9A3Py1PXhyLbCJr8ZbJsEV6NZdN1WJaGGut5tj@EEyq16v63aotPBCepxUpCgAojrNasZ6Hk1PjpRyBAdEp", + "gateway": "EEyq16v63aotPBCepxUpCgAojrNasZ6Hk1PjpRyBAdEp" + }, + { + "id": "shipyard-telegram-6", + "description": "Nym Telegram Service Provider", + "address": "7n1BYhsXSwcr8Qim8AqZTAodqFia4QG6T7CRc1ihQHpv.7o4trpGqu2LHMUiXc3dddgfGET1CFFcAK9gKYoHoSn5e@BTZNB3bkkEePsT14GN8ofVtM1SJae4YLWjpBerrKYfr", + "gateway": "BTZNB3bkkEePsT14GN8ofVtM1SJae4YLWjpBerrKYfr" + }, + { + "id": "shipyard-telegram-7", + "description": "Nym Telegram Service Provider", + "address": "Gv4TWhUKrvJfqh1jBRPGEQrikNZvZse2kS3ZgN9Z2nAZ.7KGPaaqUEum2C59jLvw7f8Ug8a48YuZdjjZu3t4JES4U@C7J8SwZQqjWqhBryyjJxLt7FacVuPTwAmR2otGy53ayi", + "gateway": "C7J8SwZQqjWqhBryyjJxLt7FacVuPTwAmR2otGy53ayi" + }, + { + "id": "shipyard-telegram-8", + "description": "Nym Telegram Service Provider", + "address": "8Mqgp12cpF6FSXMeqzxgFgQXvTSapyNqGAi5wy7ub4ge.7z7PDsiJGiGxGz4p77v5L5fZhXBJ5qNZ8CgJwYNr6H6J", + "gateway": "3zd3wrCK8Dz5TXrcvk5dG5s9EEdf4Ck1v9VgBPMMFVkR" + }, + { + "id": "shipyard-telegram-9", + "description": "Nym Telegram Service Provider", + "address": "F3N5eiPDZcGFC985Go4Mpv8p9uxFD1L3jRUdrLCbrZLm.EyTxWwwTwYpPrJBmc97GLd1LpUAphjptS5y1ed182bGk@GAjhJcrd6f1edaqUkfWCff6zdHoqo756qYrc2TfPuCXJ", + "gateway": "GAjhJcrd6f1edaqUkfWCff6zdHoqo756qYrc2TfPuCXJ" + }, + { + "id": "shipyard-telegram-10", + "description": "Nym Telegram Service Provider", + "address": "G7y7e1nVBr8fmQSzdeAxXnCmmmJb5k8N3E8LBV31KE5g.GRRUCj6t6cCUUjakmTWzidMLiYA7EdCedKnup8osaBC6@AJad2R9virYEYXEsTcicN5y5tyPoixrhhAGsxoESZVnc", + "gateway": "AJad2R9virYEYXEsTcicN5y5tyPoixrhhAGsxoESZVnc" + }, + { + "id": "shipyard-telegram-11", + "description": "Nym Telegram Service Provider", + "address": "2kq9Z7RyDZtb8kxXjyP3ZT8VMWHg6JXFDChGuuNBk7Hw.F5XYbBaGSoF8qAo8faPcaNRPHEq3Y25BDcwESeabUS9S@HaLyPQrhBTq75dnGeBUdYWeFVA2BBn39MgkhEt3VTMMM", + "gateway": "HaLyPQrhBTq75dnGeBUdYWeFVA2BBn39MgkhEt3VTMMM" + }, + { + "id": "shipyard-telegram-12", + "description": "Nym Telegram Service Provider", + "address": "GegdtpNzYj4QCgpih9Kxv7ZVZxmVdxYHsDkiPsbT71XG.E8xtE8mrapjzFtyuziZSrsScAKhwZMH5wNpKWtKfzJ5Y@9Byd9VAtyYMnbVAcqdoQxJnq76XEg2dbxbiF5Aa5Jj9J", + "gateway": "9Byd9VAtyYMnbVAcqdoQxJnq76XEg2dbxbiF5Aa5Jj9J" + }, + { + "id": "shipyard-telegram-13", + "description": "Nym Telegram Service Provider", + "address": "4SsrDQeEtG3mpeD9nN5CDdGaCsxFvNeYMhoviDzNNB9f.GyqG6iK5rBvhe3HXLR11m6ULpf13ATgYvkkidLmteDLs@5EpkkrMFYAM3XcaztXnZwBWquURHSKsyc9JxUCengDFS", + "gateway": "5EpkkrMFYAM3XcaztXnZwBWquURHSKsyc9JxUCengDFS" + }, + { + "id": "shipyard-telegram-14", + "description": "Nym Telegram Service Provider", + "address": "9JoHRu2RrSD1fjbj9NSTASgjv9Szep7Nhd9L2PywxbBi.AZhAUDNX6iH8BqXyR5c7TJuzpwMEvDXrabNLGuRukvVf@9xJM74FwwHhEKKJHihD21QSZnHM2QBRMoFx9Wst6qNBS", + "gateway": "9xJM74FwwHhEKKJHihD21QSZnHM2QBRMoFx9Wst6qNBS" + }, + { + "id": "shipyard-telegram-15", + "description": "Nym Telegram Service Provider", + "address": "3K174ijjXqCkhMDT9xLcqjS4MXk2QsqZt4PdgNcuUrnn.BNnHnQmWoj6Uo6kkS1QkPqsdHaXrcwyR9F6MnnzDkZJL@C7J8SwZQqjWqhBryyjJxLt7FacVuPTwAmR2otGy53ayi", + "gateway": "C7J8SwZQqjWqhBryyjJxLt7FacVuPTwAmR2otGy53ayi" + }, + { + "id": "shipyard-telegram-16", + "description": "Nym Telegram Service Provider", + "address": "BqX5Q3MEcbTnM39hUswQchLW68SrqbhL8K5ucrLmtP39.AWrVsFoVC9s6KjdpcasATmZPA3GtMsUxcfHpAkuNdtFG@Emswx6KXyjRfq1c2k4d4uD2e6nBSbH1biorCZUei8UNS", + "gateway": "Emswx6KXyjRfq1c2k4d4uD2e6nBSbH1biorCZUei8UNS" + }, + { + "id": "shipyard-telegram-17", + "description": "Nym Telegram Service Provider", + "address": "2tQxccgcqdkuUvLqgiEkEN4rNRZ5QknygnKAFcS4gfoe.EVrY5q5sqDqBUbS3wHsRRZhk2MAw1S17hNoH1Bicyv7n@DAGQxdxwAkwjaLjTw1B9vndia4YyFD15qRgcTQxrmkom", + "gateway": "DAGQxdxwAkwjaLjTw1B9vndia4YyFD15qRgcTQxrmkom" + }, + { + "id": "shipyard-telegram-18", + "description": "Nym Telegram Service Provider", + "address": "8YG1rcEauJA814Nd7hSxjNe2UrRwrGsrXTm1Cmd3gRrU.FxYaYqpNN8PciNsySs3zYPrTB1J8AYUu9DBsM2vVDDfF@7EfEESLo71GUvx3UEW79LgTegHUBPUocUzGyJVv6LHog", + "gateway": "7EfEESLo71GUvx3UEW79LgTegHUBPUocUzGyJVv6LHog" + }, + { + "id": "shipyard-telegram-19", + "description": "Nym Telegram Service Provider", + "address": "HPiXADVFLwLQPNpPtyYefzvYntC6tp9UJ5fJZGfkqvDt.2EUUxmeT3AiaUzAcE5SyXRAk8a2JXBkRz4B8McSdkrST@9ACTkYraCqE9jMb6zb6ne8EDQGGhZw5ykNiq9YRUdHTD", + "gateway": "9ACTkYraCqE9jMb6zb6ne8EDQGGhZw5ykNiq9YRUdHTD" + }, + { + "id": "shipyard-telegram-20", + "description": "Nym Telegram Service Provider", + "address": "2QLnEEnTmf2NRWtcQPWBeRcg7Hej5WSPWRWwtTpEEZWF.BheS78ozc8ngvhsXNNnshdJzpoYsmEvhfn3WKUYF5dRU@C2uyokSPoxhku9GexRxEo1e8KPZ7q6e8FXmK3gtY8kkF", + "gateway": "C2uyokSPoxhku9GexRxEo1e8KPZ7q6e8FXmK3gtY8kkF" + }, + { + "id": "shipyard-telegram-21", + "description": "Nym Telegram Service Provider", + "address": "FuBbnwiANfaXZnn683jBapK5XVm5ttgZSykU3vqPSHoD.94MFGv1VcBLTkRwzBDQUkWjvqtZYVBrJg2Q8JGbizcib@CTqYPY8htdAQMXCzRW9SjZzZuqYwSt2iUh6HPaNgmTvK", + "gateway": "CTqYPY8htdAQMXCzRW9SjZzZuqYwSt2iUh6HPaNgmTvK" + }, + { + "id": "shipyard-telegram-22", + "description": "Nym Telegram Service Provider", + "address": "9EbQx5jQznSVbftFom7sqUSHAACrsfvMhrzhaFt4A3SZ.D1FQCirL4YKwfcmtMGvB5Rugt5sAzGEhdSjJ3bHVQRZ@7Zh1Sz5dXpA6s53CbtcdqhQhLqwf4cLynL7KqHKcjrG4", + "gateway": "7Zh1Sz5dXpA6s53CbtcdqhQhLqwf4cLynL7KqHKcjrG4" + }, + { + "id": "shipyard-telegram-24", + "description": "Nym Telegram Service Provider", + "address": "6Umawwvf551VyB3Ko46NgKLqJdTFJeToCM67mrTmM3G.3A4sesBac4KGuMTFjvYBwLpksMJvbMbteGJQgmm4PV4Y@AnnYnEtBjB2a5sHmeRCnBq43qxyHDf95Bqd7cwQyKNLR", + "gateway": "AnnYnEtBjB2a5sHmeRCnBq43qxyHDf95Bqd7cwQyKNLR" + }, + { + "id": "shipyard-telegram-25", + "description": "Nym Telegram Service Provider", + "address": "CDtxTeoyqq83JpV9G8cR5HRHRdMMaVspQsCwH3Qnajt3.F5EHK9HFcdGrE2hqA7bK9AUmkbihujYDhtNNqHKxW765@BDkeNx7JQm5NsQakst9s8htogZXhpTQedFAgZpvsGCqH", + "gateway": "BDkeNx7JQm5NsQakst9s8htogZXhpTQedFAgZpvsGCqH" + }, + { + "id": "shipyard-telegram-26", + "description": "Nym Telegram Service Provider", + "address": "HukZkLG2DoarQEqaoDLuqW1GFf2NSHDUMGBZiyJGRYJD.9GyU8wPsyzcvRjcyk8hiNpTJbXCmq5F3VoVhFBZYuHR3@GsGEZiDBz8SWfHGaK5SDmhfbTEM55v37WCYYcT9wTSxN", + "gateway": "GsGEZiDBz8SWfHGaK5SDmhfbTEM55v37WCYYcT9wTSxN" + }, + { + "id": "shipyard-telegram-27", + "description": "Nym Telegram Service Provider", + "address": "773y8iMVJiRk4dRbjQzkJVbrei4TwkePNE5WTEttt77d.3Mw47C9XZj3oAzk1iSqC5Y36tbBsjtaTtdgaHM3Zsdma@7fiZtNL1RACQTwGrKLBT9nbY77bfwZnX9rqcWqc53qgv", + "gateway": "7fiZtNL1RACQTwGrKLBT9nbY77bfwZnX9rqcWqc53qgv" + }, + { + "id": "shipyard-telegram-28", + "description": "Nym Telegram Service Provider", + "address": "6jQJEorCu7YiP9HdDaMeHxcNhxeNmZ1kpd836GnqLZX.HsJqEmNTszGecsKqFB37i84nBXxqf4ETgrKmKmBvMGHC@FYnDMQzT49ZGM23gVqpTxfih14V6wuedNXirekmt37zE", + "gateway": "FYnDMQzT49ZGM23gVqpTxfih14V6wuedNXirekmt37zE" + }, + { + "id": "shipyard-telegram-29", + "description": "Nym Telegram Service Provider", + "address": "BiCSyovpFMuSnTvF2TdiuZwrytXDrd9AH47ZMcCxscVC.G9YpdicA9BBNoVHDgjWjgt17wv5WYKWcbE3vPJJVpSJD@GAjhJcrd6f1edaqUkfWCff6zdHoqo756qYrc2TfPuCXJ", + "gateway":"GAjhJcrd6f1edaqUkfWCff6zdHoqo756qYrc2TfPuCXJ" + }, + { + "id": "shipyard-telegram-30", + "description": "Nym Telegram Service Provider", + "address": "AQRRAs9oc8QWXAFBs44YhCKUny7AyLsfLy91pwmGgxuf.CWUKoKA1afSKyw5BnFJJg19UDgnaVATupsFhQpyTEBHJ@EBT8jTD8o4tKng2NXrrcrzVhJiBnKpT1bJy5CMeArt2w", + "gateway": "EBT8jTD8o4tKng2NXrrcrzVhJiBnKpT1bJy5CMeArt2w" + }, + { + "id": "shipyard-telegram-31", + "description": "Nym Telegram Service Provider", + "address": "6YqjAZK3Pr1ngiBLcDkotboB5WiN6k6NPpbXvShH4pR5.9Ss6VW3Xbyi8LuxduNNwnXEv9njHCQ2PLSP1UK6tsoa5@42XCK9dMS9m5XJLzQd2dBuwimk6ndZnczhZaV5VPFkQD", + "gateway": "42XCK9dMS9m5XJLzQd2dBuwimk6ndZnczhZaV5VPFkQD" + }, + { + "id": "shipyard-telegram-32", + "description": "Nym Telegram Service Provider", + "address": "EmYWLeybmj86Vzr62vxuZ3T15jwMNHggzK7sQwid96yp.GyaF9WprSr56cxUdGf5TpcUvAjb2VbAr8CVBrmBUYAaw@GL5wESoz4oSbpBaTki9qB9213FGUQXCiRjbzDkhWwoBC", + "gateway": "GL5wESoz4oSbpBaTki9qB9213FGUQXCiRjbzDkhWwoBC" + }, + { + "id": "shipyard-telegram-33", + "description": "Nym Telegram Service Provider", + "address": "4PDb96cck5btTj6G7rsomqwHJsp4qu8uPvFCbwHfjFUx.C5dKbaoakH7egsZvAueRbwLFbmxnQaVMeSr6QTMpuBAA@58ceEFaLJh6zAo3cirzT1BDQm7D3L5acnQrxGH1D6TAY", + "gateway": "58ceEFaLJh6zAo3cirzT1BDQm7D3L5acnQrxGH1D6TAY" + }, + { + "id": "shipyard-telegram-34", + "description": "Nym Telegram Service Provider", + "address": "BeZbeMf9vcpUf368qDd85dtLwXLj4Ee5bsHMB2fUD8uX.HELVbppkwU1jmzUAUrCEbHeJfVciSeo8VGAkbJSpwxsb@ADdHkiTfkpsSt31zVToWW9j3KikH24aLAAwDKtCYE5jY", + "gateway":"ADdHkiTfkpsSt31zVToWW9j3KikH24aLAAwDKtCYE5jY" + }, + { + "id": "shipyard-telegram-35", + "description": "Nym Telegram Service Provider", + "address": "Bp4JRFyf7GB9L9J95AqMPnz9zbGmPnViA5fDXKeNraLJ.D6CTdcjJVxDmH2UQvzXuPWg9Se9xXYe76uDMypXvhzd7@6UjGEeQZK14C5K2kenycTkqt7qRjEHGLgaQx3FWySo3N", + "gateway": "6UjGEeQZK14C5K2kenycTkqt7qRjEHGLgaQx3FWySo3N" + }, + { + "id": "shipyard-telegram-36", + "description": "Nym Telegram Service Provider", + "address": "91h7io6BGhVjbtC7dbbRcScyTJcTfnMsTQZ6aWMVsrWR.Epb4hANXCp8cGEY3wSgawux991ti9Z5Y1FHTMzAKFa6E@DF4TE7V8kJkttMvnoSVGnRFFRt6WYGxxiC2w1XyPQnHe", + "gateway": "DF4TE7V8kJkttMvnoSVGnRFFRt6WYGxxiC2w1XyPQnHe" + }, + { + "id": "shipyard-telegram-37", + "description": "Nym Telegram Service Provider", + "address": "Cy2wuwKpWZ3iWzKU3ZWL1qqcVfJ5Cq85dU7UHVWwv2gc.9AhC9b2zVcLnXLGriMdxjpsWJpq6iAdCavDi63udbL89@678qVUJ21uwxZBhp3r56z7GRf6gMh3NYDHruTegPtgMf", + "gateway": "678qVUJ21uwxZBhp3r56z7GRf6gMh3NYDHruTegPtgMf" + }, + { + "id": "shipyard-telegram-38", + "description": "Nym Telegram Service Provider", + "address": "GgUeUWW1NRSuquZYeZf3WkppE92EQUHJrFjNZtYU1jow.CSEjwrRi4f4uyw7N6L2LPKw2tB8spcMbFu99wHZzFZSj@77TSuVU8d1oXKbPzjec2xh4i3Wj5WwUyy9Lr36sm8gZm", + "gateway": "77TSuVU8d1oXKbPzjec2xh4i3Wj5WwUyy9Lr36sm8gZm" + }, + { + "id": "shipyard-telegram-39", + "description": "Nym Telegram Service Provider", + "address": "kz4zWwSkYiQxqxXFPNcGUByTPQWXascD9RfYsmSxY7n.ajp3SjbBVBjrU9nXpSQXAXzbb6EHJJyhbY6cc1ajayx@BTZNB3bkkEePsT14GN8ofVtM1SJae4YLWjpBerrKYf", + "gateway": "HyS2UZtZX3kQXdazbdE99DhCjBXjbG61LC9QsmXwbxrU" + } + ] + }, + { + "id": "blockstream", + "description": "Blockstream Green", + "items": [ + { + "id": "nym-blockstream", + "description": "Nym Blockstream Green Service Provider", + "address": "GiRjFWrMxt58pEMuusm4yT3RxoMD1MMPrR9M2N4VWRJP.3CNZBPq4vg7v7qozjGjdPMXcvDmkbWPCgbGCjQVw9n6Z@2xU4CBE6QiiYt6EyBXSALwxkNvM7gqJfjHXaMkjiFmYW", + "gateway": "2xU4CBE6QiiYt6EyBXSALwxkNvM7gqJfjHXaMkjiFmYW" + } + ] + } +]`); +export const Loaded = () => ( + + + +); + +export const ServiceAlreadySelected = () => ( + + console.log('New service provider selected: ', serviceProvider)} + /> + +); diff --git a/nym-connect-android/src/components/ServiceSelector.tsx b/nym-connect-android/src/components/ServiceSelector.tsx new file mode 100644 index 0000000000..5eb8361d3c --- /dev/null +++ b/nym-connect-android/src/components/ServiceSelector.tsx @@ -0,0 +1,179 @@ +import React, { useEffect } from 'react'; +import { + Box, + CircularProgress, + Divider, + FormControl, + InputLabel, + MenuItem, + Select, + Stack, + Typography, +} from '@mui/material'; +import { Service, ServiceProvider, Services } from '../types/directory'; +import { useTauriEvents } from '../utils'; +import { ServiceProviderPopup } from './ServiceProviderPopup'; + +export const ServiceSelector: FCWithChildren<{ + onChange?: (serviceProvider?: ServiceProvider) => void; + services?: Services; + currentSp?: ServiceProvider; +}> = ({ services, currentSp, onChange }) => { + const [service, setService] = React.useState(); + const [serviceProvider, setServiceProvider] = React.useState(currentSp); + const [isPopupVisible, setPopupVisible] = React.useState(false); + + const getService = () => { + if (!services || !currentSp) { + return undefined; + } + return services.find((s) => + s.items.some( + ({ id, address, gateway }) => + id === currentSp.id && address === currentSp.address && gateway === currentSp.gateway, + ), + ); + }; + + useEffect(() => { + if (!service && currentSp) { + setServiceProvider(currentSp); + setService(getService()); + } + }, [currentSp, services]); + + /** + * Gets a random service provider from the currently selected service. + * + * If there is no service selected, or it does not have items, `undefined` is returned. + */ + const getRandomServiceProviderForService = (serviceToUse?: Service): ServiceProvider | undefined => { + if (!serviceToUse?.items.length) { + return undefined; + } + return serviceToUse.items[Math.floor(Math.random() * serviceToUse.items.length)]; + }; + + const handleServiceSelected = React.useCallback( + (newService?: Service) => { + console.log(newService?.id, service?.id); + // if the user has chosen a new service, then pick a random service provider + if (newService?.id !== service?.id) { + const newServiceProvider = getRandomServiceProviderForService(newService); + setServiceProvider(newServiceProvider); + onChange?.(newServiceProvider); + setService(newService); + } + }, + [service], + ); + + // clears the display and fire on change (to trigger upstream storage clearance) + const clearServiceProviderAndFireOnChange = () => { + setService(undefined); + setServiceProvider(undefined); + onChange?.(undefined); + }; + + // when the user clears local storage, reset the selector + useTauriEvents('help://clear-storage', () => { + clearServiceProviderAndFireOnChange(); + }); + + const handleAdvancedSpChange = (newServiceProvider?: ServiceProvider, newService?: Service) => { + setPopupVisible(false); + setService(newService); + setServiceProvider(newServiceProvider); + onChange?.(newServiceProvider); + }; + + const handleNewService = (newServiceId?: string) => { + const newService = (services || []).find((s) => s.id === newServiceId); + setService(newService); + }; + + if (!services) { + return ( + + theme.palette.common.white}> + + Loading services... + + + ); + } + + return ( + + + + Select a service + + + + setPopupVisible(false)} + onServiceProviderChanged={handleAdvancedSpChange} + /> + + ); +}; diff --git a/nym-connect-android/src/config.ts b/nym-connect-android/src/config.ts new file mode 100644 index 0000000000..f405ceac15 --- /dev/null +++ b/nym-connect-android/src/config.ts @@ -0,0 +1,3 @@ +export const config = { + DOCS_BASE_URL: 'https://nymtech.net/docs', +}; diff --git a/nym-connect-android/src/context/main.tsx b/nym-connect-android/src/context/main.tsx new file mode 100644 index 0000000000..bba01764e7 --- /dev/null +++ b/nym-connect-android/src/context/main.tsx @@ -0,0 +1,231 @@ +import React, { createContext, useCallback, useContext, useEffect, useMemo, useState, useRef } from 'react'; +import { DateTime } from 'luxon'; +import { invoke } from '@tauri-apps/api'; +import type { UnlistenFn } from '@tauri-apps/api/event'; +import { listen } from '@tauri-apps/api/event'; +import { forage } from '@tauri-apps/tauri-forage'; +import { Error } from 'src/types/error'; +import { TauriEvent } from 'src/types/event'; +import { getVersion } from '@tauri-apps/api/app'; +import { ConnectionStatusKind, GatewayPerformance } from '../types'; +import { ConnectionStatsItem } from '../components/ConnectionStats'; +import { ServiceProvider, Services } from '../types/directory'; + +const TAURI_EVENT_STATUS_CHANGED = 'app:connection-status-changed'; + +type ModeType = 'light' | 'dark'; + +export type TClientContext = { + mode: ModeType; + appVersion?: string; + connectionStatus: ConnectionStatusKind; + connectionStats?: ConnectionStatsItem[]; + connectedSince?: DateTime; + services?: Services; + serviceProvider?: ServiceProvider; + showHelp: boolean; + error?: Error; + gatewayPerformance: GatewayPerformance; + setMode: (mode: ModeType) => void; + clearError: () => void; + handleShowHelp: () => void; + setConnectionStatus: (connectionStatus: ConnectionStatusKind) => void; + setConnectionStats: (connectionStats: ConnectionStatsItem[] | undefined) => void; + setConnectedSince: (connectedSince: DateTime | undefined) => void; + setServiceProvider: (serviceProvider?: ServiceProvider) => void; + + startConnecting: () => Promise; + startDisconnecting: () => Promise; +}; + +export const ClientContext = createContext({} as TClientContext); + +export const ClientContextProvider: FCWithChildren = ({ children }) => { + const [mode, setMode] = useState('dark'); + const [connectionStatus, setConnectionStatus] = useState(ConnectionStatusKind.disconnected); + const [connectionStats, setConnectionStats] = useState(); + const [connectedSince, setConnectedSince] = useState(); + const [services, setServices] = React.useState([]); + const [serviceProvider, setRawServiceProvider] = React.useState(); + const [showHelp, setShowHelp] = useState(false); + const [error, setError] = useState(); + const [appVersion, setAppVersion] = useState(); + const [gatewayPerformance, setGatewayPerformance] = useState('Good'); + + const getAppVersion = async () => { + const version = await getVersion(); + setAppVersion(version); + }; + + const timerId = useRef(); + + useEffect(() => { + invoke('get_services').then((result) => { + setServices(result as Services); + }); + getAppVersion(); + }, []); + + useEffect(() => { + // when mounting, load the connection state (needed for the Growth window, that checks the connection state) + (async () => { + const currentStatus: ConnectionStatusKind = await invoke('get_connection_status'); + setConnectionStatus(currentStatus); + })(); + }, []); + + useEffect(() => { + const unlisten: UnlistenFn[] = []; + + // TODO: fix typings + listen(TAURI_EVENT_STATUS_CHANGED, (event) => { + const { status } = event.payload as any; + console.log(TAURI_EVENT_STATUS_CHANGED, { status, event }); + setConnectionStatus(status); + }) + .then((result) => { + unlisten.push(result); + }) + .catch((e) => console.log(e)); + + listen('socks5-event', (e: TauriEvent) => { + console.log(e); + + setError(e.payload); + }).then((result) => { + unlisten.push(result); + }); + + listen('socks5-status-event', (e: TauriEvent) => { + if (e.payload.message.includes('slow')) { + setGatewayPerformance('Poor'); + + if (timerId.current) { + clearTimeout(timerId.current); + } + + timerId.current = setTimeout(() => { + setGatewayPerformance('Good'); + }, 10000); + } + }).then((result) => { + unlisten.push(result); + }); + + return () => { + unlisten.forEach((unsubscribe) => unsubscribe()); + }; + }, []); + + const startConnecting = useCallback(async () => { + try { + await invoke('start_connecting'); + } catch (e) { + setError({ title: 'Could not connect', message: e as string }); + console.log(e); + } + }, []); + + const startDisconnecting = useCallback(async () => { + try { + await invoke('start_disconnecting'); + setGatewayPerformance('Good'); + } catch (e) { + console.log(e); + } + }, []); + + const setSpInStorage = async (sp: ServiceProvider) => { + await forage.setItem({ + key: 'nym-connect-sp', + value: sp, + } as any)(); + }; + + const setServiceProvider = useCallback(async (newServiceProvider?: ServiceProvider) => { + await invoke('set_gateway', { gateway: newServiceProvider?.gateway }); + await invoke('set_service_provider', { serviceProvider: newServiceProvider?.address }); + if (newServiceProvider) { + await setSpInStorage(newServiceProvider); + } + setRawServiceProvider(newServiceProvider); + }, []); + + const getSpFromStorage = async () => { + try { + const spFromStorage = await forage.getItem({ key: 'nym-connect-sp' })(); + if (spFromStorage) { + setRawServiceProvider(spFromStorage); + setServiceProvider(spFromStorage); + } + } catch (e) { + console.warn(e); + } + }; + + const handleShowHelp = () => setShowHelp((show) => !show); + + const clearError = () => setError(undefined); + + useEffect(() => { + const validityCheck = async () => { + if (services.length > 0 && serviceProvider) { + const isValid = services.some(({ items }) => items.some(({ id }) => id === serviceProvider.id)); + if (!isValid) { + console.warn('invalid SP, cleaning local storage'); + await forage.removeItem({ + key: 'nym-connect-sp', + })(); + setRawServiceProvider(undefined); + } + } + }; + validityCheck(); + }, [services, serviceProvider]); + + useEffect(() => { + getSpFromStorage(); + }, []); + + const contextValue = useMemo( + () => ({ + mode, + appVersion, + setMode, + error, + clearError, + connectionStatus, + setConnectionStatus, + connectionStats, + setConnectionStats, + connectedSince, + setConnectedSince, + startConnecting, + startDisconnecting, + services, + serviceProvider, + setServiceProvider, + showHelp, + handleShowHelp, + gatewayPerformance, + }), + [ + appVersion, + mode, + appVersion, + error, + connectedSince, + showHelp, + connectionStatus, + connectionStats, + connectedSince, + services, + serviceProvider, + gatewayPerformance, + ], + ); + + return {children}; +}; + +export const useClientContext = () => useContext(ClientContext); diff --git a/nym-connect-android/src/context/mocks/main.tsx b/nym-connect-android/src/context/mocks/main.tsx new file mode 100644 index 0000000000..9b4341cc23 --- /dev/null +++ b/nym-connect-android/src/context/mocks/main.tsx @@ -0,0 +1,30 @@ +import React, { useMemo } from 'react'; +import { ConnectionStatusKind } from 'src/types'; +import { ClientContext, TClientContext } from '../main'; + +const mockValues: TClientContext = { + appVersion: 'v1.x.x', + mode: 'dark', + connectionStatus: ConnectionStatusKind.disconnected, + services: [], + showHelp: false, + serviceProvider: { id: '1', description: 'Keybase service provider', gateway: 'abc123', address: '123abc' }, + gatewayPerformance: 'Good', + setMode: () => {}, + clearError: () => {}, + handleShowHelp: () => {}, + setConnectedSince: () => {}, + setConnectionStats: () => {}, + setConnectionStatus: () => {}, + setServiceProvider: () => {}, + startConnecting: async () => {}, + startDisconnecting: async () => {}, +}; + +export const MockProvider: FCWithChildren<{ + children?: React.ReactNode; + connectionStatus?: ConnectionStatusKind; +}> = ({ connectionStatus = ConnectionStatusKind.disconnected, children }) => { + const value = useMemo(() => ({ ...mockValues, connectionStatus }), [connectionStatus]); + return {children}; +}; diff --git a/nym-connect-android/src/fonts/OpenSans-Italic-VariableFont_wdth,wght.ttf b/nym-connect-android/src/fonts/OpenSans-Italic-VariableFont_wdth,wght.ttf new file mode 100644 index 0000000000..0fea34ba24 Binary files /dev/null and b/nym-connect-android/src/fonts/OpenSans-Italic-VariableFont_wdth,wght.ttf differ diff --git a/nym-connect-android/src/fonts/OpenSans-VariableFont_wdth,wght.ttf b/nym-connect-android/src/fonts/OpenSans-VariableFont_wdth,wght.ttf new file mode 100644 index 0000000000..51dd3c3be2 Binary files /dev/null and b/nym-connect-android/src/fonts/OpenSans-VariableFont_wdth,wght.ttf differ diff --git a/nym-connect-android/src/fonts/fonts.css b/nym-connect-android/src/fonts/fonts.css new file mode 100644 index 0000000000..bf875e952d --- /dev/null +++ b/nym-connect-android/src/fonts/fonts.css @@ -0,0 +1,6 @@ +@font-face { + font-family: 'Open Sans'; + src: url('./OpenSans-VariableFont_wdth,wght.ttf') format('truetype-variations'), + url('./OpenSans-Italic-VariableFont_wdth,wght.ttf') format('truetype-variations'); + font-weight: 100 1000; +} \ No newline at end of file diff --git a/nym-connect-android/src/growth.tsx.back b/nym-connect-android/src/growth.tsx.back new file mode 100644 index 0000000000..d90a8bb462 --- /dev/null +++ b/nym-connect-android/src/growth.tsx.back @@ -0,0 +1,23 @@ +import React from 'react'; +import ReactDOM from 'react-dom'; +import { ErrorBoundary } from 'react-error-boundary'; +import { ClientContextProvider } from './context/main'; +import { ErrorFallback } from './components/Error'; +import { NymShipyardTheme } from './theme'; +import { TestAndEarnPopup } from './components/Growth/TestAndEarnPopup'; +import { TestAndEarnContextProvider } from './components/Growth/context/TestAndEarnContext'; + +const root = document.getElementById('root-growth'); + +ReactDOM.render( + + + + + + + + + , + root, +); diff --git a/nym-connect-android/src/index.tsx b/nym-connect-android/src/index.tsx new file mode 100644 index 0000000000..fac9b38622 --- /dev/null +++ b/nym-connect-android/src/index.tsx @@ -0,0 +1,25 @@ +import React from 'react'; +import { createRoot } from 'react-dom/client'; +import { ErrorBoundary } from 'react-error-boundary'; +import { ClientContextProvider } from './context/main'; +import { ErrorFallback } from './components/Error'; +import { NymMixnetTheme } from './theme'; +import { App } from './App'; +import { AppWindowFrame } from './components/AppWindowFrame'; + +const elem = document.getElementById('root'); + +if (elem) { + const root = createRoot(elem); + root.render( + + + + + + + + + , + ); +} diff --git a/nym-connect-android/src/layouts/ConnectedLayout.tsx b/nym-connect-android/src/layouts/ConnectedLayout.tsx new file mode 100644 index 0000000000..04e8359786 --- /dev/null +++ b/nym-connect-android/src/layouts/ConnectedLayout.tsx @@ -0,0 +1,59 @@ +import React from 'react'; +import { Box, Divider, Stack } from '@mui/material'; +import { DateTime } from 'luxon'; +import { IpAddressAndPortModal } from 'src/components/IpAddressAndPortModal'; +import { ConnectionTimer } from 'src/components/ConntectionTimer'; +import { ConnectionStatus } from '../components/ConnectionStatus'; +import { ConnectionStatusKind, GatewayPerformance } from '../types'; +import { ConnectionStatsItem } from '../components/ConnectionStats'; +import { ConnectionButton } from '../components/ConnectionButton'; +import { IpAddressAndPort } from '../components/IpAddressAndPort'; +import { ServiceProvider } from '../types/directory'; + +export const ConnectedLayout: FCWithChildren<{ + status: ConnectionStatusKind; + gatewayPerformance: GatewayPerformance; + stats: ConnectionStatsItem[]; + ipAddress: string; + port: number; + connectedSince?: DateTime; + busy?: boolean; + showInfoModal: boolean; + isError?: boolean; + handleCloseInfoModal: () => void; + onConnectClick?: (status: ConnectionStatusKind) => void; + serviceProvider?: ServiceProvider; +}> = ({ + status, + gatewayPerformance, + showInfoModal, + handleCloseInfoModal, + ipAddress, + port, + connectedSince, + busy, + isError, + serviceProvider, + onConnectClick, +}) => ( + <> + + + + + + + + + {/* */} + + + + + + +); diff --git a/nym-connect-android/src/layouts/DefaultLayout.tsx b/nym-connect-android/src/layouts/DefaultLayout.tsx new file mode 100644 index 0000000000..947f5de948 --- /dev/null +++ b/nym-connect-android/src/layouts/DefaultLayout.tsx @@ -0,0 +1,49 @@ +import React from 'react'; +import { Box, Stack, Typography } from '@mui/material'; +import { ConnectionStatus } from 'src/components/ConnectionStatus'; +import { ConnectionTimer } from 'src/components/ConntectionTimer'; +import { InfoModal } from 'src/components/InfoModal'; +import { Error } from 'src/types/error'; +import { ConnectionButton } from '../components/ConnectionButton'; +import { ServiceSelector } from '../components/ServiceSelector'; +import { useClientContext } from '../context/main'; +import { ConnectionStatusKind } from '../types'; +import { Services } from '../types/directory'; + +export const DefaultLayout: FCWithChildren<{ + error?: Error; + status: ConnectionStatusKind; + services?: Services; + busy?: boolean; + isError?: boolean; + clearError: () => void; + onConnectClick?: (status: ConnectionStatusKind) => void; +}> = ({ status, error, services, busy, isError, onConnectClick, clearError }) => { + const context = useClientContext(); + + return ( + + {error && } + + + + Connect to the Nym
mixnet for privacy. +
+ + This is experimental software. Do not rely on it for strong anonymity (yet). + +
+ + + + + +
+ ); +}; diff --git a/nym-connect-android/src/layouts/HelpGuideLayout.tsx b/nym-connect-android/src/layouts/HelpGuideLayout.tsx new file mode 100644 index 0000000000..d56fd6b0ae --- /dev/null +++ b/nym-connect-android/src/layouts/HelpGuideLayout.tsx @@ -0,0 +1,70 @@ +import React, { useState } from 'react'; +import { HelpPage } from 'src/components/HelpPage'; +import { Button, Link, Stack } from '@mui/material'; +import Image1 from '../assets/help-step-one.png'; +import Image2 from '../assets/help-step-two.png'; +import Image3 from '../assets/help-step-three.png'; +import Image4 from '../assets/help-step-four.png'; + +export const HelpGuideLayout = () => { + const [step, setStep] = useState(0); + + if (step === 1) + return ( + setStep(2)} + /> + ); + + if (step === 2) + return ( + setStep(1)} + onNext={() => setStep(3)} + /> + ); + + if (step === 3) + return ( + setStep(2)} + onNext={() => setStep(4)} + /> + ); + + if (step === 4) + return ( + setStep(3)} + /> + ); + + return ( + + + + + ); +}; diff --git a/nym-connect-android/src/log.tsx.back b/nym-connect-android/src/log.tsx.back new file mode 100644 index 0000000000..cd17271368 --- /dev/null +++ b/nym-connect-android/src/log.tsx.back @@ -0,0 +1,18 @@ +import React from 'react'; +import ReactDOM from 'react-dom'; +import { ErrorBoundary } from 'react-error-boundary'; +import { LogViewer } from './components/LogViewer'; +import { ErrorFallback } from './components/ErrorFallback'; +import { NymMixnetTheme } from './theme'; + +const Log = () => ( + + + + + +); + +const root = document.getElementById('root-log'); + +ReactDOM.render(, root); diff --git a/nym-connect-android/src/stories/AppFlow.stories.tsx b/nym-connect-android/src/stories/AppFlow.stories.tsx new file mode 100644 index 0000000000..f0c8428058 --- /dev/null +++ b/nym-connect-android/src/stories/AppFlow.stories.tsx @@ -0,0 +1,115 @@ +import React from 'react'; +import { ComponentMeta, ComponentStory } from '@storybook/react'; +import { Box } from '@mui/material'; +import { DateTime } from 'luxon'; +import { AppWindowFrame } from '../components/AppWindowFrame'; +import { useClientContext } from '../context/main'; +import { ConnectionStatusKind } from '../types'; +import { DefaultLayout } from '../layouts/DefaultLayout'; +import { ConnectedLayout } from '../layouts/ConnectedLayout'; +import { Services } from '../types/directory'; + +export default { + title: 'App/Flow', + component: AppWindowFrame, +} as ComponentMeta; + +const width = 240; +const height = 575; + +export const Mock: ComponentStory = () => { + const context = useClientContext(); + const [busy, setBusy] = React.useState(); + const services: Services = [ + { + id: 'keybase', + description: 'Keybase', + items: [ + { + id: 'nym-keybase', + description: 'Nym Keybase Service Provider', + address: '1234.5678', + gateway: 'abcedf', + }, + ], + }, + ]; + const handleConnectClick = React.useCallback(() => { + const oldStatus = context.connectionStatus; + if (oldStatus === ConnectionStatusKind.connected || oldStatus === ConnectionStatusKind.disconnected) { + setBusy(true); + + // eslint-disable-next-line default-case + switch (oldStatus) { + case ConnectionStatusKind.disconnected: + context.setConnectionStatus(ConnectionStatusKind.connecting); + break; + case ConnectionStatusKind.connected: + context.setConnectionStatus(ConnectionStatusKind.disconnecting); + break; + } + + setTimeout(() => { + // eslint-disable-next-line default-case + switch (oldStatus) { + case ConnectionStatusKind.disconnected: + context.setConnectedSince(DateTime.now()); + context.setConnectionStatus(ConnectionStatusKind.connected); + break; + case ConnectionStatusKind.connected: + context.setConnectionStatus(ConnectionStatusKind.disconnected); + break; + } + setBusy(false); + }, 5000); + } + }, [context.connectionStatus]); + + if ( + context.connectionStatus === ConnectionStatusKind.disconnected || + context.connectionStatus === ConnectionStatusKind.connecting + ) { + return ( + + + {}} + /> + + + ); + } + + return ( + + undefined} + status={context.connectionStatus} + busy={busy} + onConnectClick={handleConnectClick} + ipAddress="127.0.0.1" + port={1080} + connectedSince={context.connectedSince} + serviceProvider={services[0].items[0]} + stats={[ + { + label: 'in:', + totalBytes: 1024, + rateBytesPerSecond: 1024 * 1024 * 1024 + 10, + }, + { + label: 'out:', + totalBytes: 1024 * 1024 * 1024 * 1024 * 20, + rateBytesPerSecond: 1024 * 1024 + 10, + }, + ]} + /> + + ); +}; diff --git a/nym-connect-android/src/stories/AppWindowFrame.stories.tsx b/nym-connect-android/src/stories/AppWindowFrame.stories.tsx new file mode 100644 index 0000000000..e8fa9a8b6b --- /dev/null +++ b/nym-connect-android/src/stories/AppWindowFrame.stories.tsx @@ -0,0 +1,19 @@ +import React from 'react'; +import { ComponentMeta, ComponentStory } from '@storybook/react'; +import { Box } from '@mui/material'; +import { AppWindowFrame } from '../components/AppWindowFrame'; + +export default { + title: 'App/AppWindowFrame', + component: AppWindowFrame, +} as ComponentMeta; + +export const Default: ComponentStory = () => ( + + + Culpa deserunt cupidatat culpa nisi aute dolore nisi deserunt cillum consequat elit. Nostrud id occaecat + consectetur consectetur excepteur labore consectetur. Laboris tempor consequat qui exercitation adipisicing sunt + cupidatat est. Officia dolore qui eu dolor velit ex ea qui laborum. Mollit ut est sit irure elit ad ut deserunt. + + +); diff --git a/nym-connect-android/src/stories/ConnectedLayout.stories.tsx b/nym-connect-android/src/stories/ConnectedLayout.stories.tsx new file mode 100644 index 0000000000..216e9c38db --- /dev/null +++ b/nym-connect-android/src/stories/ConnectedLayout.stories.tsx @@ -0,0 +1,37 @@ +import React from 'react'; +import { ComponentMeta, ComponentStory } from '@storybook/react'; +import { Box } from '@mui/material'; +import { DateTime } from 'luxon'; +import { ConnectedLayout } from '../layouts/ConnectedLayout'; +import { ConnectionStatusKind } from '../types'; + +export default { + title: 'Layouts/ConnectedLayout', + component: ConnectedLayout, +} as ComponentMeta; + +export const Default: ComponentStory = () => ( + + undefined} + status={ConnectionStatusKind.connected} + connectedSince={DateTime.now()} + ipAddress="127.0.0.1" + port={1080} + stats={[ + { + label: 'in:', + totalBytes: 1024, + rateBytesPerSecond: 1024 * 1024 * 1024 + 10, + }, + { + label: 'out:', + totalBytes: 1024 * 1024 * 1024 * 1024 * 20, + rateBytesPerSecond: 1024 * 1024 + 10, + }, + ]} + /> + +); diff --git a/nym-connect-android/src/stories/ConnectionButton.stories.tsx b/nym-connect-android/src/stories/ConnectionButton.stories.tsx new file mode 100644 index 0000000000..efce95ace4 --- /dev/null +++ b/nym-connect-android/src/stories/ConnectionButton.stories.tsx @@ -0,0 +1,25 @@ +import React from 'react'; +import { ComponentMeta, ComponentStory } from '@storybook/react'; +import { ConnectionButton } from '../components/ConnectionButton'; +import { ConnectionStatusKind } from '../types'; + +export default { + title: 'Components/ConnectionButton', + component: ConnectionButton, +} as ComponentMeta; + +export const Disconnected: ComponentStory = () => ( + +); + +export const Connecting: ComponentStory = () => ( + +); + +export const Connected: ComponentStory = () => ( + +); + +export const Disconnecting: ComponentStory = () => ( + +); diff --git a/nym-connect-android/src/stories/ConnectionStats.stories.tsx b/nym-connect-android/src/stories/ConnectionStats.stories.tsx new file mode 100644 index 0000000000..7faea66c16 --- /dev/null +++ b/nym-connect-android/src/stories/ConnectionStats.stories.tsx @@ -0,0 +1,27 @@ +import React from 'react'; +import { ComponentMeta, ComponentStory } from '@storybook/react'; +import { ConnectionStats } from '../components/ConnectionStats'; + +export default { + title: 'Components/ConnectionStats', + component: ConnectionStats, +} as ComponentMeta; + +const Template: ComponentStory = (args) => ; + +// 👇 Each story then reuses that template +export const Default = Template.bind({}); +Default.args = { + stats: [ + { + label: 'in:', + totalBytes: 1024, + rateBytesPerSecond: 1024 * 1024 * 1024 + 10, + }, + { + label: 'out:', + totalBytes: 1024 * 1024 * 1024 * 1024 * 20, + rateBytesPerSecond: 1024 * 1024 + 10, + }, + ], +}; diff --git a/nym-connect-android/src/stories/ConnectionStatus.stories.tsx b/nym-connect-android/src/stories/ConnectionStatus.stories.tsx new file mode 100644 index 0000000000..d98e262374 --- /dev/null +++ b/nym-connect-android/src/stories/ConnectionStatus.stories.tsx @@ -0,0 +1,26 @@ +import React from 'react'; +import { ComponentMeta, ComponentStory } from '@storybook/react'; +import { DateTime } from 'luxon'; +import { ConnectionStatus } from '../components/ConnectionStatus'; +import { ConnectionStatusKind } from '../types'; + +export default { + title: 'Components/ConnectionStatus', + component: ConnectionStatus, +} as ComponentMeta; + +export const Disconnected: ComponentStory = () => ( + +); + +export const Connecting: ComponentStory = () => ( + +); + +export const Connected: ComponentStory = () => ( + +); + +export const Disconnecting: ComponentStory = () => ( + +); diff --git a/nym-connect-android/src/stories/DefaultLayout.stories.tsx b/nym-connect-android/src/stories/DefaultLayout.stories.tsx new file mode 100644 index 0000000000..ba311a410b --- /dev/null +++ b/nym-connect-android/src/stories/DefaultLayout.stories.tsx @@ -0,0 +1,33 @@ +import React from 'react'; +import { ComponentMeta, ComponentStory } from '@storybook/react'; +import { Box } from '@mui/material'; +import { DefaultLayout } from '../layouts/DefaultLayout'; +import { ConnectionStatusKind } from '../types'; + +export default { + title: 'Layouts/DefaultLayout', + component: DefaultLayout, +} as ComponentMeta; + +export const Default: ComponentStory = () => ( + + {}} error={undefined} /> + +); + +export const WithServices: ComponentStory = () => ( + + {}} + error={undefined} + /> + +); diff --git a/nym-connect-android/src/stories/Introduction.stories.mdx b/nym-connect-android/src/stories/Introduction.stories.mdx new file mode 100644 index 0000000000..eb1d51fc24 --- /dev/null +++ b/nym-connect-android/src/stories/Introduction.stories.mdx @@ -0,0 +1,7 @@ +import { Meta } from '@storybook/addon-docs'; + + + +# nym-connect + +Components for connecting to the Nym mixnet. diff --git a/nym-connect-android/src/stories/IpAddressAndPort.stories.tsx b/nym-connect-android/src/stories/IpAddressAndPort.stories.tsx new file mode 100644 index 0000000000..943b3a40b4 --- /dev/null +++ b/nym-connect-android/src/stories/IpAddressAndPort.stories.tsx @@ -0,0 +1,14 @@ +import React from 'react'; +import { ComponentMeta, ComponentStory } from '@storybook/react'; +import { IpAddressAndPort } from '../components/IpAddressAndPort'; + +export default { + title: 'Components/IpAddressAndPort', + component: IpAddressAndPort, +} as ComponentMeta; + +const Template: ComponentStory = (args) => ; + +// 👇 Each story then reuses that template +export const Default = Template.bind({}); +Default.args = { label: 'Socks5 address', ipAddress: '127.0.0.1', port: 1080 }; diff --git a/nym-connect-android/src/theme/index.tsx b/nym-connect-android/src/theme/index.tsx new file mode 100644 index 0000000000..ad2350053d --- /dev/null +++ b/nym-connect-android/src/theme/index.tsx @@ -0,0 +1,29 @@ +import React from 'react'; +import { createTheme, ThemeProvider } from '@mui/material/styles'; +import { CssBaseline } from '@mui/material'; +import { getDesignTokens } from './theme'; +// eslint-disable-next-line import/no-relative-packages +import '../../../assets/fonts/non-variable/fonts.css'; + +/** + * Provides the theme for Nym Connect by reacting to the light/dark mode choice stored in the app context. + */ +export const NymMixnetTheme: FCWithChildren<{ mode: 'light' | 'dark' }> = ({ children, mode }) => { + const theme = React.useMemo(() => createTheme(getDesignTokens(mode)), [mode]); + return ( + + + {children} + + ); +}; + +export const NymShipyardTheme: FCWithChildren<{ mode?: 'light' | 'dark' }> = ({ children, mode = 'dark' }) => { + const theme = React.useMemo(() => createTheme(getDesignTokens(mode, true)), [mode]); + return ( + + + {children} + + ); +}; diff --git a/nym-connect-android/src/theme/mui-theme.d.ts b/nym-connect-android/src/theme/mui-theme.d.ts new file mode 100644 index 0000000000..6c4d88fe39 --- /dev/null +++ b/nym-connect-android/src/theme/mui-theme.d.ts @@ -0,0 +1,89 @@ +/* eslint-disable no-shadow,@typescript-eslint/no-unused-vars,@typescript-eslint/no-empty-interface */ +import { Theme, ThemeOptions, Palette, PaletteOptions } from '@mui/material/styles'; +import { PaletteMode } from '@mui/material'; + +/** + * If you are unfamiliar with Material UI theming, please read the following first: + * - https://mui.com/customization/theming/ + * - https://mui.com/customization/palette/ + * - https://mui.com/customization/dark-mode/#dark-mode-with-custom-palette + * + * This file adds typings to the theme using Typescript's module augmentation. + * + * Read the following if you are unfamiliar with module augmentation and declaration merging. Then + * look at the recommendations from Material UI docs for implementation: + * - https://www.typescriptlang.org/docs/handbook/declaration-merging.html#module-augmentation + * - https://www.typescriptlang.org/docs/handbook/declaration-merging.html#merging-interfaces + * - https://mui.com/customization/palette/#adding-new-colors + * + * + * IMPORTANT: + * + * The type augmentation must match MUI's definitions. So, notice the use of `interface` rather than + * `type Foo = { ... }` - this is necessary to merge the definitions. + */ + +declare module '@mui/material/styles' { + /** + * This interface defines a palette used across Nym for branding + */ + interface NymPalette { + highlight: string; + success: string; + warning: string; + info: string; + fee: string; + background: { light: string; dark: string }; + text: { + light: string; + dark: string; + }; + shipyard: string; + } + + interface NymPaletteVariant { + mode: PaletteMode; + background: { + main: string; + paper: string; + }; + text: { + main: string; + }; + topNav: { + background: string; + }; + shipyard: { + main: string; + }; + } + + /** + * A palette definition only for the Nym Mixnet that extends the Nym palette + */ + interface NymMixnetPalette { + nymMixnet: {}; + } + + interface NymPaletteAndNymMixnetPalette { + nym: NymPalette & NymMixnetPalette; + } + + type NymPaletteAndNymMixnetPaletteOptions = Partial; + + /** + * Add anything not palette related to the theme here + */ + interface NymTheme {} + + /** + * This augments the definitions of the MUI Theme with the Nym theme, as well as + * a partial `ThemeOptions` type used by `createTheme` + * + * IMPORTANT: only add extensions to the interfaces above, do not modify the lines below + */ + interface Theme extends NymTheme {} + interface ThemeOptions extends Partial {} + interface Palette extends NymPaletteAndNymMixnetPalette {} + interface PaletteOptions extends NymPaletteAndNymMixnetPaletteOptions {} +} diff --git a/nym-connect-android/src/theme/theme.tsx b/nym-connect-android/src/theme/theme.tsx new file mode 100644 index 0000000000..abda87ef72 --- /dev/null +++ b/nym-connect-android/src/theme/theme.tsx @@ -0,0 +1,283 @@ +import { PaletteMode } from '@mui/material'; +import { + createTheme, + NymMixnetPalette, + NymPalette, + NymPaletteVariant, + PaletteOptions, + ThemeOptions, +} from '@mui/material/styles'; + +//----------------------------------------------------------------------------------------------- +// Nym palette type definitions +// + +/** + * The Nym palette. + * + * IMPORTANT: do not export this constant, always use the MUI `useTheme` hook to get the correct + * colours for dark/light mode. + */ +const nymPalette: NymPalette = { + /** emphasises important elements */ + highlight: '#FB6E4E', + success: '#21D073', + info: '#60D7EF', + warning: '#FFE600', + fee: '#967FF0', + background: { light: '#F4F6F8', dark: '#1D2125' }, + text: { + light: '#F2F2F2', + dark: '#1D2125', + }, + shipyard: '#817FFA', +}; + +const darkMode: NymPaletteVariant = { + mode: 'dark', + background: { + main: '#1D2125', + paper: '#242C3D', + }, + text: { + main: '#F2F2F2', + }, + topNav: { + background: '#111826', + }, + shipyard: { + main: '#817FFA', + }, +}; + +const lightMode: NymPaletteVariant = { + mode: 'light', + background: { + main: '#F2F2F2', + paper: '#FFFFFF', + }, + text: { + main: '#1D2125', + }, + topNav: { + background: '#111826', + }, + shipyard: { + main: '#817FFA', + }, +}; + +/** + * Nym palette specific to the Nym Mixnode + * + * IMPORTANT: do not export this constant, always use the MUI `useTheme` hook to get the correct + * colours for dark/light mode. + */ +// eslint-disable-next-line @typescript-eslint/no-unused-vars +const nymMixnetPalette = (variant: NymPaletteVariant): NymMixnetPalette => ({ + nymMixnet: {}, +}); + +//----------------------------------------------------------------------------------------------- +// Nym palettes for light and dark mode +// + +/** + * Map a Nym palette variant onto the MUI palette + */ +const variantToMUIPalette = (variant: NymPaletteVariant): PaletteOptions => ({ + text: { + primary: variant.text.main, + }, + primary: { + main: nymPalette.highlight, + contrastText: '#fff', + }, + secondary: { + main: variant.mode === 'dark' ? nymPalette.background.light : nymPalette.background.dark, + }, + success: { + main: nymPalette.success, + }, + info: { + main: nymPalette.info, + }, + warning: { + main: nymPalette.warning, + }, + background: { + default: variant.background.main, + paper: variant.background.paper, + }, +}); + +/** + * Map a Nym palette variant onto the MUI palette for Shipyard + */ +const variantShipyardToMUIPalette = (variant: NymPaletteVariant): PaletteOptions => ({ + text: { + primary: variant.text.main, + }, + primary: { + main: nymPalette.shipyard, + contrastText: '#fff', + }, + secondary: { + main: variant.mode === 'dark' ? nymPalette.background.light : nymPalette.background.dark, + }, + success: { + main: nymPalette.success, + }, + info: { + main: nymPalette.info, + }, + warning: { + main: nymPalette.warning, + }, + background: { + default: variant.background.main, + paper: variant.background.paper, + }, +}); + +/** + * Returns the Network Explorer palette for light mode. + */ +const createLightModePalette = (): PaletteOptions => ({ + nym: { + ...nymPalette, + ...nymMixnetPalette(lightMode), + }, + ...variantToMUIPalette(lightMode), +}); + +/** + * Returns the Network Explorer palette for dark mode. + */ +const createDarkModePalette = (): PaletteOptions => ({ + nym: { + ...nymPalette, + ...nymMixnetPalette(darkMode), + }, + ...variantToMUIPalette(darkMode), +}); + +/** + * Returns the Shipyard palette for dark mode. + */ +const createShipyardDarkModePalette = (): PaletteOptions => ({ + nym: { + ...nymPalette, + ...nymMixnetPalette(darkMode), + }, + ...variantShipyardToMUIPalette(darkMode), +}); + +/** + * IMPORANT: if you need to get the default MUI theme, use the following + * + * import { createTheme as systemCreateTheme } from '@mui/system'; + * + * // get the MUI system defaults for light mode + * const systemTheme = systemCreateTheme({ palette: { mode: 'light' } }); + * + * + * return { + * // change `primary` to default MUI `success` + * primary: { + * main: systemTheme.palette.success.main, + * }, + * nym: { + * ...nymPalette, + * ...nymMixnetPalette, + * }, + * }; + */ + +//----------------------------------------------------------------------------------------------- +// Nym theme overrides +// + +/** + * Gets the theme options to be passed to `createTheme`. + * + * Based on pattern from https://mui.com/customization/dark-mode/#dark-mode-with-custom-palette. + * + * @param mode The theme mode: 'light' or 'dark' + */ +export const getDesignTokens = (mode: PaletteMode, isShipyard: boolean = false): ThemeOptions => { + let overrides; + if (isShipyard) { + overrides = createShipyardDarkModePalette(); + } else { + overrides = mode === 'light' ? createLightModePalette() : createDarkModePalette(); + } + + // create the palette from user's choice of light or dark mode + const { palette } = createTheme({ + palette: { + mode, + ...overrides, + }, + }); + + // then customise theme and components + return { + palette, + typography: { + fontFamily: [ + 'Open Sans', + 'sans-serif', + 'BlinkMacSystemFont', + 'Roboto', + 'Oxygen', + 'Ubuntu', + 'Helvetica Neue', + ].join(','), + fontSize: 14, + fontWeightRegular: 500, + button: { + textTransform: 'none', + fontWeight: '600', + }, + }, + shape: { + borderRadius: 8, + }, + transitions: { + duration: { + shortest: 150, + shorter: 200, + short: 250, + standard: 300, + complex: 375, + enteringScreen: 225, + leavingScreen: 195, + }, + easing: { + easeIn: 'cubic-bezier(0.4, 0, 1, 1)', + }, + }, + components: { + MuiButton: { + styleOverrides: { + sizeLarge: { + height: 55, + }, + }, + }, + MuiStepIcon: { + styleOverrides: { + root: { + '&.Mui-completed': { + color: nymPalette.success, + }, + '&.Mui-active': { + color: nymPalette.background.dark, + }, + }, + }, + }, + }, + }; +}; diff --git a/nym-connect-android/src/types/connection.ts b/nym-connect-android/src/types/connection.ts new file mode 100644 index 0000000000..8fb5234477 --- /dev/null +++ b/nym-connect-android/src/types/connection.ts @@ -0,0 +1,8 @@ +export enum ConnectionStatusKind { + disconnected = 'disconnected', + disconnecting = 'disconnecting', + connected = 'connected', + connecting = 'connecting', +} + +export type GatewayPerformance = 'Good' | 'Poor' | 'VeryPoor'; diff --git a/nym-connect-android/src/types/directory.ts b/nym-connect-android/src/types/directory.ts new file mode 100644 index 0000000000..55395192a1 --- /dev/null +++ b/nym-connect-android/src/types/directory.ts @@ -0,0 +1,14 @@ +export interface ServiceProvider { + id: string; + description: string; + address: string; + gateway: string; +} + +export interface Service { + id: string; + description: string; + items: ServiceProvider[]; +} + +export type Services = Service[]; diff --git a/nym-connect-android/src/types/error.ts b/nym-connect-android/src/types/error.ts new file mode 100644 index 0000000000..599b959c39 --- /dev/null +++ b/nym-connect-android/src/types/error.ts @@ -0,0 +1,4 @@ +export type Error = { + title: string; + message: string; +}; diff --git a/nym-connect-android/src/types/event.ts b/nym-connect-android/src/types/event.ts new file mode 100644 index 0000000000..66d1e514a6 --- /dev/null +++ b/nym-connect-android/src/types/event.ts @@ -0,0 +1,6 @@ +export type TauriEvent = { + payload: { + title: string; + message: string; + }; +}; diff --git a/nym-connect-android/src/types/index.ts b/nym-connect-android/src/types/index.ts new file mode 100644 index 0000000000..6bc150d410 --- /dev/null +++ b/nym-connect-android/src/types/index.ts @@ -0,0 +1,2 @@ +export * from './rust'; +export * from './connection'; diff --git a/nym-connect-android/src/types/rust/index.ts b/nym-connect-android/src/types/rust/index.ts new file mode 100644 index 0000000000..f8efcfe235 --- /dev/null +++ b/nym-connect-android/src/types/rust/index.ts @@ -0,0 +1 @@ +export * from './stateparams'; diff --git a/nym-connect-android/src/types/rust/stateparams.ts b/nym-connect-android/src/types/rust/stateparams.ts new file mode 100644 index 0000000000..b7d7032420 --- /dev/null +++ b/nym-connect-android/src/types/rust/stateparams.ts @@ -0,0 +1,7 @@ +/* eslint-disable camelcase */ +export interface TauriContractStateParams { + minimum_mixnode_pledge: string; + minimum_gateway_pledge: string; + mixnode_rewarded_set_size: number; + mixnode_active_set_size: number; +} diff --git a/nym-connect-android/src/typings/FC.d.ts b/nym-connect-android/src/typings/FC.d.ts new file mode 100644 index 0000000000..08ebdfa298 --- /dev/null +++ b/nym-connect-android/src/typings/FC.d.ts @@ -0,0 +1 @@ +declare type FCWithChildren

= React.FC>; diff --git a/nym-connect-android/src/typings/jpeg.d.ts b/nym-connect-android/src/typings/jpeg.d.ts new file mode 100644 index 0000000000..af2ed72913 --- /dev/null +++ b/nym-connect-android/src/typings/jpeg.d.ts @@ -0,0 +1,9 @@ +declare module '*.jpeg' { + const value: any; + export default value; +} + +declare module '*.jpg' { + const value: any; + export default value; +} diff --git a/nym-connect-android/src/typings/json.d.ts b/nym-connect-android/src/typings/json.d.ts new file mode 100644 index 0000000000..b72dd46ee5 --- /dev/null +++ b/nym-connect-android/src/typings/json.d.ts @@ -0,0 +1,4 @@ +declare module '*.json' { + const content: any; + export default content; +} diff --git a/nym-connect-android/src/typings/png.d.ts b/nym-connect-android/src/typings/png.d.ts new file mode 100644 index 0000000000..dd84df40a4 --- /dev/null +++ b/nym-connect-android/src/typings/png.d.ts @@ -0,0 +1,4 @@ +declare module '*.png' { + const content: any; + export default content; +} diff --git a/nym-connect-android/src/typings/svg.d.ts b/nym-connect-android/src/typings/svg.d.ts new file mode 100644 index 0000000000..091d25e210 --- /dev/null +++ b/nym-connect-android/src/typings/svg.d.ts @@ -0,0 +1,4 @@ +declare module '*.svg' { + const content: any; + export default content; +} diff --git a/nym-connect-android/src/typings/webp.d.ts b/nym-connect-android/src/typings/webp.d.ts new file mode 100644 index 0000000000..d594a17f53 --- /dev/null +++ b/nym-connect-android/src/typings/webp.d.ts @@ -0,0 +1,4 @@ +declare module '*.webp' { + const value: any; + export default value; +} diff --git a/nym-connect-android/src/typings/yaml.d.ts b/nym-connect-android/src/typings/yaml.d.ts new file mode 100644 index 0000000000..4c36d21ec0 --- /dev/null +++ b/nym-connect-android/src/typings/yaml.d.ts @@ -0,0 +1,9 @@ +declare module '*.yml' { + const content: { [key: string]: any }; + export default content; +} + +declare module '*.yaml' { + const content: { [key: string]: any }; + export default content; +} diff --git a/nym-connect-android/src/utils/index.ts b/nym-connect-android/src/utils/index.ts new file mode 100644 index 0000000000..9dc9fd09e3 --- /dev/null +++ b/nym-connect-android/src/utils/index.ts @@ -0,0 +1,19 @@ +import { useEffect, useRef } from 'react'; +import { EventName, listen, UnlistenFn, EventCallback } from '@tauri-apps/api/event'; + +export const useTauriEvents = (event: EventName, handler: EventCallback) => { + const unlisten = useRef(); + + // list for events to clear local storage + useEffect(() => { + listen(event, handler).then((fn) => { + unlisten.current = fn; + }); + + return () => { + if (unlisten.current) { + unlisten.current(); + } + }; + }, []); +}; diff --git a/nym-connect-android/tsconfig.eslint.json b/nym-connect-android/tsconfig.eslint.json new file mode 100644 index 0000000000..836be28a14 --- /dev/null +++ b/nym-connect-android/tsconfig.eslint.json @@ -0,0 +1,20 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "jsx": "react-jsx", + "noEmit": true + }, + "include": [ + ".storybook/*.js", + "webpack.*.js", + "src/**/*.js", + "src/**/*.jsx", + "src/**/*.ts", + "src/**/*.tsx", + "src/**/*.stories.*", + ], + "exclude": [ + "node_modules", + "dist" + ] +} diff --git a/nym-connect-android/tsconfig.json b/nym-connect-android/tsconfig.json new file mode 100644 index 0000000000..aabda0e53a --- /dev/null +++ b/nym-connect-android/tsconfig.json @@ -0,0 +1,23 @@ +{ + "compilerOptions": { + "target": "es5", + "lib": ["dom", "dom.iterable", "esnext"], + "allowJs": true, + "esModuleInterop": true, + "allowSyntheticDefaultImports": true, + "strict": true, + "noFallthroughCasesInSwitch": true, + "skipLibCheck": true, + "module": "esnext", + "moduleResolution": "node", + "resolveJsonModule": true, + "isolatedModules": false, + "jsx": "react-jsx", + "sourceMap": false, + "baseUrl": ".", + "paths": { + "@assets/*": ["../assets/*"] + } + }, + "exclude": ["node_modules", "dist", "jest.config.js", "webpack.config.js", "webpack.prod.js", "webpack.common.js", "target"] +} diff --git a/nym-connect-android/webpack.common.js b/nym-connect-android/webpack.common.js new file mode 100644 index 0000000000..fe651a96fa --- /dev/null +++ b/nym-connect-android/webpack.common.js @@ -0,0 +1,59 @@ +const path = require('path'); +const { mergeWithRules } = require('webpack-merge'); +const { webpackCommon } = require('@nymproject/webpack'); + +const entry = { + app: path.resolve(__dirname, 'src/index.tsx'), + // growth: path.resolve(__dirname, 'src/growth.tsx'), + // log: path.resolve(__dirname, 'src/log.tsx'), +}; + +module.exports = mergeWithRules({ + module: { + rules: { + test: 'match', + use: 'replace', + }, + }, +})( + webpackCommon(__dirname, [ + { filename: 'index.html', chunks: ['app'], template: path.resolve(__dirname, 'public/index.html') }, + // { filename: 'log.html', chunks: ['log'], template: path.resolve(__dirname, 'public/log.html') }, + // { filename: 'growth.html', chunks: ['growth'], template: path.resolve(__dirname, 'public/growth.html') }, + ]), + { + module: { + rules: [ + // { + // test: /\.mdx?$/, + // use: [ + // { + // loader: '@mdx-js/loader', + // /** @type {import('@mdx-js/loader').Options} */ + // options: {}, + // }, + // ], + // }, + { + test: /\.ya?ml$/, + type: 'asset/resource', + use: [ + { + loader: 'yaml-loader', + options: { + asJSON: true, + }, + }, + ], + }, + ], + }, + entry, + output: { + clean: true, + path: path.resolve(__dirname, 'dist'), + filename: '[name].bundle.js', + publicPath: '/', + }, + }, +); diff --git a/nym-connect-android/webpack.dev.js b/nym-connect-android/webpack.dev.js new file mode 100644 index 0000000000..00fba5e0ce --- /dev/null +++ b/nym-connect-android/webpack.dev.js @@ -0,0 +1,68 @@ +const { mergeWithRules } = require('webpack-merge'); +const webpack = require('webpack'); +// const ReactRefreshWebpackPlugin = require('@pmmmwh/react-refresh-webpack-plugin'); +// const ReactRefreshTypeScript = require('react-refresh-typescript'); +const commonConfig = require('./webpack.common'); + +module.exports = mergeWithRules({ + module: { + rules: { + test: 'match', + use: 'replace', + }, + }, +})(commonConfig, { + mode: 'development', + devtool: false, + module: { + rules: [ + { + test: /\.tsx?$/, + use: 'ts-loader', + exclude: /node_modules/, + options: { + // getCustomTransformers: () => ({ + // before: [ReactRefreshTypeScript()], + // }), + // `ts-loader` does not work with HMR unless `transpileOnly` is used. + // If you need type checking, `ForkTsCheckerWebpackPlugin` is an alternative. + transpileOnly: true, + }, + }, + ], + }, + plugins: [ + // new ReactRefreshWebpackPlugin(), + // this can be included automatically by the dev server, however build mode fails if missing + // new webpack.HotModuleReplacementPlugin(), + ], + + // recommended for faster rebuild + optimization: { + // runtimeChunk: true, + removeAvailableModules: false, + removeEmptyChunks: false, + splitChunks: false, + }, + + cache: { + type: 'filesystem', + buildDependencies: { + // restart on config change + config: ['./webpack.dev.js'], + }, + }, + + devServer: { + port: 9000, + // compress: true, + historyApiFallback: true, + // disable this because on android it makes reloading infinity loop + hot: false, + host: 'local-ipv4', + allowedHosts: 'all', + // client: { + // overlay: false, + // }, + }, +}); diff --git a/nym-connect-android/webpack.prod.js b/nym-connect-android/webpack.prod.js new file mode 100644 index 0000000000..0e9061e25a --- /dev/null +++ b/nym-connect-android/webpack.prod.js @@ -0,0 +1,17 @@ +const path = require('path'); +const { default: merge } = require('webpack-merge'); +const common = require('./webpack.common'); + +const entry = { + app: path.resolve(__dirname, 'src/index.tsx'), + growth: path.resolve(__dirname, 'src/growth.tsx'), + log: path.resolve(__dirname, 'src/log.tsx'), +}; + +module.exports = merge(common, { + mode: 'production', + node: { + __dirname: false, + }, + entry, +}); diff --git a/nym-wallet/Cargo.lock b/nym-wallet/Cargo.lock index acce120ca4..02bf5970f0 100644 --- a/nym-wallet/Cargo.lock +++ b/nym-wallet/Cargo.lock @@ -2941,7 +2941,7 @@ dependencies = [ [[package]] name = "nym_wallet" -version = "1.1.7" +version = "1.1.8" dependencies = [ "aes-gcm", "argon2 0.3.4", @@ -5484,7 +5484,7 @@ checksum = "49874b5167b65d7193b8aba1567f5c7d93d001cafc34600cee003eda787e483f" [[package]] name = "vesting-contract" -version = "1.1.2" +version = "1.1.3" dependencies = [ "contracts-common", "cosmwasm-std", diff --git a/package.json b/package.json index d205ce90da..e990f5e28c 100644 --- a/package.json +++ b/package.json @@ -8,6 +8,7 @@ "ts-packages/*", "nym-wallet", "nym-connect", + "nym-connect-android", "explorer", "types", "clients/validator" diff --git a/sdk/rust/nym-sdk/src/mixnet/client.rs b/sdk/rust/nym-sdk/src/mixnet/client.rs index 4de5f04a81..c38ad4bea8 100644 --- a/sdk/rust/nym-sdk/src/mixnet/client.rs +++ b/sdk/rust/nym-sdk/src/mixnet/client.rs @@ -4,8 +4,8 @@ use client_connections::TransmissionLane; use client_core::{ client::{ base_client::{ - non_wasm_helpers, BaseClientBuilder, ClientInput, ClientOutput, ClientState, - CredentialsToggle, + helpers::setup_empty_reply_surb_backend, non_wasm_helpers, BaseClientBuilder, + ClientInput, ClientOutput, ClientState, CredentialsToggle, }, inbound_messages::InputMessage, key_manager::KeyManager, @@ -348,10 +348,7 @@ impl MixnetClient { paths: Option, ) -> Result> { let config = config.unwrap_or_default(); - - let reply_storage_backend = - non_wasm_helpers::setup_empty_reply_surb_backend(&config.debug_config); - + let reply_storage_backend = setup_empty_reply_surb_backend(&config.debug_config); MixnetClient::builder_with_custom_storage(Some(config), paths, reply_storage_backend) } diff --git a/yarn.lock b/yarn.lock index 07c6d1bd97..7e5a8fdd0f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4357,51 +4357,101 @@ resolved "https://registry.yarnpkg.com/@tauri-apps/api/-/api-1.2.0.tgz#1f196b3e012971227f41b98214c846430a4eb477" integrity sha512-lsI54KI6HGf7VImuf/T9pnoejfgkNoXveP14pVV7XarrQ46rOejIVJLFqHI9sRReJMGdh2YuCoI3cc/yCWCsrw== +"@tauri-apps/api@^2.0.0-alpha.0": + version "2.0.0-alpha.0" + resolved "https://registry.yarnpkg.com/@tauri-apps/api/-/api-2.0.0-alpha.0.tgz#901abbaf3b9515ba0437716ac0383de549e4e3bc" + integrity sha512-PQdy1Ao6JwKwW2/C11nP+IqnrWHB7+UgbM71zbzA1W3+1yyd9Zg+K7rzZ7f3yhvD7kdxmXUN3KgSfGeiDFzZ2A== + "@tauri-apps/cli-darwin-arm64@1.2.3": version "1.2.3" resolved "https://registry.yarnpkg.com/@tauri-apps/cli-darwin-arm64/-/cli-darwin-arm64-1.2.3.tgz#dae9142e683c00199f4d7e088f22b564b08b9cac" integrity sha512-phJN3fN8FtZZwqXg08bcxfq1+X1JSDglLvRxOxB7VWPq+O5SuB8uLyssjJsu+PIhyZZnIhTGdjhzLSFhSXfLsw== +"@tauri-apps/cli-darwin-arm64@2.0.0-alpha.1": + version "2.0.0-alpha.1" + resolved "https://registry.yarnpkg.com/@tauri-apps/cli-darwin-arm64/-/cli-darwin-arm64-2.0.0-alpha.1.tgz#f555cedc5573f99c697136c335c1a81b1c195498" + integrity sha512-FCkki6IHWfMmRKhBVGctcg0C+ZLsNSrItNd2+qSpUgCgxQep67kClWG1kzkydMBrIgg9lc4TzzK/bSPcXTEstg== + "@tauri-apps/cli-darwin-x64@1.2.3": version "1.2.3" resolved "https://registry.yarnpkg.com/@tauri-apps/cli-darwin-x64/-/cli-darwin-x64-1.2.3.tgz#c6f84a11a1a7800e3e8e22c8fa5b95d0b3d1f802" integrity sha512-jFZ/y6z8z6v4yliIbXKBXA7BJgtZVMsITmEXSuD6s5+eCOpDhQxbRkr6CA+FFfr+/r96rWSDSgDenDQuSvPAKw== +"@tauri-apps/cli-darwin-x64@2.0.0-alpha.1": + version "2.0.0-alpha.1" + resolved "https://registry.yarnpkg.com/@tauri-apps/cli-darwin-x64/-/cli-darwin-x64-2.0.0-alpha.1.tgz#80604120cf9c85df48882711721759878c4bacef" + integrity sha512-cmnJzgWgCvau8iH8ZjQKmmjIaEWaLNDTIhOe8z21//GsR3Zd5TbyoPuNtH2Kysn05ijJQHOKbqyfV20C7cQCBQ== + "@tauri-apps/cli-linux-arm-gnueabihf@1.2.3": version "1.2.3" resolved "https://registry.yarnpkg.com/@tauri-apps/cli-linux-arm-gnueabihf/-/cli-linux-arm-gnueabihf-1.2.3.tgz#ecccec4c255ab32903fb36e1c746ed7b4eff0d1d" integrity sha512-C7h5vqAwXzY0kRGSU00Fj8PudiDWFCiQqqUNI1N+fhCILrzWZB9TPBwdx33ZfXKt/U4+emdIoo/N34v3TiAOmQ== +"@tauri-apps/cli-linux-arm-gnueabihf@2.0.0-alpha.1": + version "2.0.0-alpha.1" + resolved "https://registry.yarnpkg.com/@tauri-apps/cli-linux-arm-gnueabihf/-/cli-linux-arm-gnueabihf-2.0.0-alpha.1.tgz#c50ece6db88c013ab9ea8f2ac020d63765b9b4ec" + integrity sha512-IVJLTOV/kHAFFthPww1Jz67KerbYjf9Oe1TQFEqzhEKgFgpYMx+WGhm//VIDeoc2MYc9Ku4AnJkCSHpyG0OosQ== + "@tauri-apps/cli-linux-arm64-gnu@1.2.3": version "1.2.3" resolved "https://registry.yarnpkg.com/@tauri-apps/cli-linux-arm64-gnu/-/cli-linux-arm64-gnu-1.2.3.tgz#c3915de83a8fbe6f406eaa0b524a17c091a9a2cd" integrity sha512-buf1c8sdkuUzVDkGPQpyUdAIIdn5r0UgXU6+H5fGPq/Xzt5K69JzXaeo6fHsZEZghbV0hOK+taKV4J0m30UUMQ== +"@tauri-apps/cli-linux-arm64-gnu@2.0.0-alpha.1": + version "2.0.0-alpha.1" + resolved "https://registry.yarnpkg.com/@tauri-apps/cli-linux-arm64-gnu/-/cli-linux-arm64-gnu-2.0.0-alpha.1.tgz#edddb4dd1e327617aa42ceda8b5402c5d12212a7" + integrity sha512-q7gArWxc77KkhHy5vbdrwYdxtE3zXoc9MR1Y4ueKadpXxIjvT68CC+AJ1OM2hBggnWRzD0aJ0H/Y+4Da/Y9jYA== + "@tauri-apps/cli-linux-arm64-musl@1.2.3": version "1.2.3" resolved "https://registry.yarnpkg.com/@tauri-apps/cli-linux-arm64-musl/-/cli-linux-arm64-musl-1.2.3.tgz#40f9f7cf0b4088964661fd412eff7310cb4ac605" integrity sha512-x88wPS9W5xAyk392vc4uNHcKBBvCp0wf4H9JFMF9OBwB7vfd59LbQCFcPSu8f0BI7bPrOsyHqspWHuFL8ojQEA== +"@tauri-apps/cli-linux-arm64-musl@2.0.0-alpha.1": + version "2.0.0-alpha.1" + resolved "https://registry.yarnpkg.com/@tauri-apps/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.0.0-alpha.1.tgz#bdaa2fc08c5dd675fa9d09fb0a6c308357e01c2c" + integrity sha512-gdP6w1on1V80zTwMJQ1S/ucYqIvhdjpUMCswlCw33uQS/CMeYhdJYt1aSIDACBWo/IwkCF3MLeeFkK1QLwJLmg== + "@tauri-apps/cli-linux-x64-gnu@1.2.3": version "1.2.3" resolved "https://registry.yarnpkg.com/@tauri-apps/cli-linux-x64-gnu/-/cli-linux-x64-gnu-1.2.3.tgz#0b3e4c1fda6205dbe872f4b69506669476f60591" integrity sha512-ZMz1jxEVe0B4/7NJnlPHmwmSIuwiD6ViXKs8F+OWWz2Y4jn5TGxWKFg7DLx5OwQTRvEIZxxT7lXHi5CuTNAxKg== +"@tauri-apps/cli-linux-x64-gnu@2.0.0-alpha.1": + version "2.0.0-alpha.1" + resolved "https://registry.yarnpkg.com/@tauri-apps/cli-linux-x64-gnu/-/cli-linux-x64-gnu-2.0.0-alpha.1.tgz#ddb4fc39c0d3bd47d9ad581ecefff6a1e2e518fa" + integrity sha512-ZvSrO52Fa4pVbR7u0BWrroDMsmtRayry3bYnGMkPX/USplKiGG0IXnwcmgaMK0QaXMDSumoSxuINixV5QqjPZA== + "@tauri-apps/cli-linux-x64-musl@1.2.3": version "1.2.3" resolved "https://registry.yarnpkg.com/@tauri-apps/cli-linux-x64-musl/-/cli-linux-x64-musl-1.2.3.tgz#edcf8f53da50337a2e763d4fda750ef56124036c" integrity sha512-B/az59EjJhdbZDzawEVox0LQu2ZHCZlk8rJf85AMIktIUoAZPFbwyiUv7/zjzA/sY6Nb58OSJgaPL2/IBy7E0A== +"@tauri-apps/cli-linux-x64-musl@2.0.0-alpha.1": + version "2.0.0-alpha.1" + resolved "https://registry.yarnpkg.com/@tauri-apps/cli-linux-x64-musl/-/cli-linux-x64-musl-2.0.0-alpha.1.tgz#2ce270c2cc1f9ea6f8882df8106c2f5bfbff0c9a" + integrity sha512-KtIrVtK9gDcWCNHs3kST6UWrHbnNNbKzVcVC4dgX59zBPta3TrjGs4nAIkbHPyX8hbWx+6IuuqvRnp9KTCfE+A== + "@tauri-apps/cli-win32-ia32-msvc@1.2.3": version "1.2.3" resolved "https://registry.yarnpkg.com/@tauri-apps/cli-win32-ia32-msvc/-/cli-win32-ia32-msvc-1.2.3.tgz#0592d3e4eee4685674579ba897eef1469c6f1cfe" integrity sha512-ypdO1OdC5ugNJAKO2m3sb1nsd+0TSvMS9Tr5qN/ZSMvtSduaNwrcZ3D7G/iOIanrqu/Nl8t3LYlgPZGBKlw7Ng== +"@tauri-apps/cli-win32-ia32-msvc@2.0.0-alpha.1": + version "2.0.0-alpha.1" + resolved "https://registry.yarnpkg.com/@tauri-apps/cli-win32-ia32-msvc/-/cli-win32-ia32-msvc-2.0.0-alpha.1.tgz#959563af5e3c5b2e53cca303feb7dac3e6f844a1" + integrity sha512-NqI3wMO/ASnmwNW9DObzVfT4ldSEa1WzZ++JJxwBKqyriWdSUmCLx0KqttxlgGALqeECrd/BKIQijg7mt4mpxw== + "@tauri-apps/cli-win32-x64-msvc@1.2.3": version "1.2.3" resolved "https://registry.yarnpkg.com/@tauri-apps/cli-win32-x64-msvc/-/cli-win32-x64-msvc-1.2.3.tgz#89f0cc36e11e56564161602cd6add155cc7b0dfb" integrity sha512-CsbHQ+XhnV/2csOBBDVfH16cdK00gNyNYUW68isedmqcn8j+s0e9cQ1xXIqi+Hue3awp8g3ImYN5KPepf3UExw== +"@tauri-apps/cli-win32-x64-msvc@2.0.0-alpha.1": + version "2.0.0-alpha.1" + resolved "https://registry.yarnpkg.com/@tauri-apps/cli-win32-x64-msvc/-/cli-win32-x64-msvc-2.0.0-alpha.1.tgz#03f47d60abc77c0269d8c2728696d3aa64926f98" + integrity sha512-cvAkOWtY9G07nbrhjmyqobZxj/kQxzZXscCXvqORx+i0GXuG8n7MR54GJQKQeQpnMNNBLi+YcQtWBLOYRqweAw== + "@tauri-apps/cli@^1.0.5", "@tauri-apps/cli@^1.2.2": version "1.2.3" resolved "https://registry.yarnpkg.com/@tauri-apps/cli/-/cli-1.2.3.tgz#957f8a3a370f306e9e1ea5a891cb30aed91af64e" @@ -4417,6 +4467,21 @@ "@tauri-apps/cli-win32-ia32-msvc" "1.2.3" "@tauri-apps/cli-win32-x64-msvc" "1.2.3" +"@tauri-apps/cli@^2.0.0-alpha.1": + version "2.0.0-alpha.1" + resolved "https://registry.yarnpkg.com/@tauri-apps/cli/-/cli-2.0.0-alpha.1.tgz#395c2fe670a487cb2f718c7b4014f2d08cfaa7f3" + integrity sha512-My83jBHU7BZpbaUVZkVHoYIqqLvhVBDxi/m5NWoAu81DP/kSjPQs5D87oWhWtOnuM6pJT5s+letKNPs9y4KM2Q== + optionalDependencies: + "@tauri-apps/cli-darwin-arm64" "2.0.0-alpha.1" + "@tauri-apps/cli-darwin-x64" "2.0.0-alpha.1" + "@tauri-apps/cli-linux-arm-gnueabihf" "2.0.0-alpha.1" + "@tauri-apps/cli-linux-arm64-gnu" "2.0.0-alpha.1" + "@tauri-apps/cli-linux-arm64-musl" "2.0.0-alpha.1" + "@tauri-apps/cli-linux-x64-gnu" "2.0.0-alpha.1" + "@tauri-apps/cli-linux-x64-musl" "2.0.0-alpha.1" + "@tauri-apps/cli-win32-ia32-msvc" "2.0.0-alpha.1" + "@tauri-apps/cli-win32-x64-msvc" "2.0.0-alpha.1" + "@tauri-apps/tauri-forage@^1.0.0-beta.2": version "1.0.0-beta.2" resolved "https://registry.yarnpkg.com/@tauri-apps/tauri-forage/-/tauri-forage-1.0.0-beta.2.tgz#c11b438ba32731dc7b4c8689d9fda4130baadc07" @@ -9301,11 +9366,9 @@ estree-walker@^2.0.2: integrity sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w== estree-walker@^3.0.0: - version "3.0.3" - resolved "https://registry.yarnpkg.com/estree-walker/-/estree-walker-3.0.3.tgz#67c3e549ec402a487b4fc193d1953a524752340d" - integrity sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g== - dependencies: - "@types/estree" "^1.0.0" + version "3.0.2" + resolved "https://registry.yarnpkg.com/estree-walker/-/estree-walker-3.0.2.tgz#2d17ff18067683aaf97aed83fe0d300603db9b67" + integrity sha512-C03BvXCQIH/po+PNPONx/zSM9ziPr9weX8xNhYb/IJtdJ9z+L4z9VKPTB+UTHdmhnIopA2kc419ueyVyHVktwA== esutils@^2.0.2: version "2.0.3" @@ -13141,9 +13204,9 @@ mdast-util-to-hast@10.0.1: unist-util-visit "^2.0.0" mdast-util-to-hast@^12.1.0: - version "12.2.6" - resolved "https://registry.yarnpkg.com/mdast-util-to-hast/-/mdast-util-to-hast-12.2.6.tgz#fb90830f427de8d2a7518753e560435531b97ee6" - integrity sha512-Kfo1JNUsi6r6CI7ZOJ6yt/IEKMjMK4nNjQ8C+yc8YBbIlDSp9dmj0zY90ryiu6Vy4CVGv0zi1H4ZoIaYVV8cwA== + version "12.2.5" + resolved "https://registry.yarnpkg.com/mdast-util-to-hast/-/mdast-util-to-hast-12.2.5.tgz#91532ebd929a7def21585034f7901eb367d2d272" + integrity sha512-EFNhT35ZR/VZ85/EedDdCNTq0oFM+NM/+qBomVGQ0+Lcg0nhI8xIwmdCzNMlVlCJNXRprpobtKP/IUh8cfz6zQ== dependencies: "@types/hast" "^2.0.0" "@types/mdast" "^3.0.0"