From 0c7e291de6777dd221961aa93e1dfdb08da88f27 Mon Sep 17 00:00:00 2001 From: Pierre Date: Fri, 19 Jul 2024 15:28:51 +0200 Subject: [PATCH] feat(vpnx): add desktop notifications (#710) * prepare for connection updates handling * emit connection failed event * display connection error details * add desktop notifications support * fix lint * add app-name to notification --- nym-vpn-x/eslint.config.js | 6 + nym-vpn-x/src-tauri/Cargo.lock | 626 +++++++++++++++++- nym-vpn-x/src-tauri/Cargo.toml | 2 +- nym-vpn-x/src-tauri/src/db.rs | 2 + nym-vpn-x/src-tauri/src/events.rs | 24 +- nym-vpn-x/src-tauri/src/grpc/client.rs | 31 +- nym-vpn-x/src-tauri/src/main.rs | 20 +- nym-vpn-x/src-tauri/src/vpn_status.rs | 52 +- nym-vpn-x/src-tauri/tauri.conf.json | 5 +- nym-vpn-x/src/helpers.ts | 5 + nym-vpn-x/src/hooks/index.ts | 1 + nym-vpn-x/src/hooks/useNotify.ts | 69 ++ nym-vpn-x/src/i18n/config.ts | 3 + nym-vpn-x/src/i18n/en/common.json | 1 + nym-vpn-x/src/i18n/en/notifications.json | 7 + nym-vpn-x/src/i18n/en/settings.json | 3 + nym-vpn-x/src/pages/MainLayout.tsx | 44 +- nym-vpn-x/src/pages/home/ConnectionStatus.tsx | 6 + .../src/pages/home/NetworkModeSelect.tsx | 4 +- nym-vpn-x/src/pages/settings/Settings.tsx | 37 +- nym-vpn-x/src/pages/settings/index.ts | 1 + .../settings/notifications/Notifications.tsx | 68 ++ .../src/pages/settings/notifications/index.ts | 1 + nym-vpn-x/src/router.tsx | 7 + nym-vpn-x/src/state/init.ts | 14 + nym-vpn-x/src/state/main.ts | 16 +- nym-vpn-x/src/state/useTauriEvents.ts | 15 +- nym-vpn-x/src/types/app-state.ts | 5 + nym-vpn-x/src/types/tauri-ipc.ts | 3 +- nym-vpn-x/src/ui/Button.tsx | 2 +- nym-vpn-x/src/ui/TopBar.tsx | 7 + proto/nym/vpn.proto | 2 +- 32 files changed, 1018 insertions(+), 71 deletions(-) create mode 100644 nym-vpn-x/src/hooks/useNotify.ts create mode 100644 nym-vpn-x/src/i18n/en/notifications.json create mode 100644 nym-vpn-x/src/pages/settings/notifications/Notifications.tsx create mode 100644 nym-vpn-x/src/pages/settings/notifications/index.ts diff --git a/nym-vpn-x/eslint.config.js b/nym-vpn-x/eslint.config.js index 057fc53701..94f370ae0d 100644 --- a/nym-vpn-x/eslint.config.js +++ b/nym-vpn-x/eslint.config.js @@ -65,6 +65,12 @@ export default [ '@typescript-eslint/restrict-template-expressions': 0, '@typescript-eslint/use-unknown-in-catch-callback-variable': 'error', '@typescript-eslint/consistent-type-definitions': ['error', 'type'], + '@typescript-eslint/no-misused-promises': [ + 'error', + { + checksVoidReturn: false, + }, + ], // TODO enable these rules once ESLint 9 ready // 'import/first': 'error', // 'import/order': [ diff --git a/nym-vpn-x/src-tauri/Cargo.lock b/nym-vpn-x/src-tauri/Cargo.lock index 5e5a314524..0939db9b0f 100644 --- a/nym-vpn-x/src-tauri/Cargo.lock +++ b/nym-vpn-x/src-tauri/Cargo.lock @@ -172,6 +172,133 @@ version = "0.7.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "96d30a06541fbafbc7f82ed10c06164cfbd2c401138f6addd8404629c4b16711" +[[package]] +name = "async-broadcast" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20cd0e2e25ea8e5f7e9df04578dc6cf5c83577fd09b1a46aaf5c85e1c33f2a7e" +dependencies = [ + "event-listener 5.3.1", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-channel" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89b47800b0be77592da0afd425cc03468052844aff33b84e33cc696f64e77b6a" +dependencies = [ + "concurrent-queue", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-executor" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7ebdfa2ebdab6b1760375fa7d6f382b9f486eac35fc994625a00e89280bdbb7" +dependencies = [ + "async-task", + "concurrent-queue", + "fastrand", + "futures-lite", + "slab", +] + +[[package]] +name = "async-fs" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebcd09b382f40fcd159c2d695175b2ae620ffa5f3bd6f664131efff4e8b9e04a" +dependencies = [ + "async-lock", + "blocking", + "futures-lite", +] + +[[package]] +name = "async-io" +version = "2.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d6baa8f0178795da0e71bc42c9e5d13261aac7ee549853162e66a241ba17964" +dependencies = [ + "async-lock", + "cfg-if", + "concurrent-queue", + "futures-io", + "futures-lite", + "parking", + "polling", + "rustix", + "slab", + "tracing", + "windows-sys 0.52.0", +] + +[[package]] +name = "async-lock" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff6e472cdea888a4bd64f342f09b3f50e1886d32afe8df3d663c01140b811b18" +dependencies = [ + "event-listener 5.3.1", + "event-listener-strategy", + "pin-project-lite", +] + +[[package]] +name = "async-process" +version = "2.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7eda79bbd84e29c2b308d1dc099d7de8dcc7035e48f4bf5dc4a531a44ff5e2a" +dependencies = [ + "async-channel", + "async-io", + "async-lock", + "async-signal", + "async-task", + "blocking", + "cfg-if", + "event-listener 5.3.1", + "futures-lite", + "rustix", + "tracing", + "windows-sys 0.52.0", +] + +[[package]] +name = "async-recursion" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.58", +] + +[[package]] +name = "async-signal" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfb3634b73397aa844481f814fad23bbf07fdb0eabec10f2eb95e58944b1ec32" +dependencies = [ + "async-io", + "async-lock", + "atomic-waker", + "cfg-if", + "futures-core", + "futures-io", + "rustix", + "signal-hook-registry", + "slab", + "windows-sys 0.52.0", +] + [[package]] name = "async-stream" version = "0.3.5" @@ -194,6 +321,12 @@ dependencies = [ "syn 2.0.58", ] +[[package]] +name = "async-task" +version = "4.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" + [[package]] name = "async-trait" version = "0.1.80" @@ -479,6 +612,19 @@ dependencies = [ "generic-array 0.14.7", ] +[[package]] +name = "blocking" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "703f41c54fc768e63e091340b424302bb1c29ef4aa0c7f10fe849dfb114d29ea" +dependencies = [ + "async-channel", + "async-task", + "futures-io", + "futures-lite", + "piper", +] + [[package]] name = "bls12_381" version = "0.8.0" @@ -780,6 +926,12 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" +[[package]] +name = "cfg_aliases" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd16c4719339c4530435d38e511904438d07cce7950afa3718a84ac36c10e89e" + [[package]] name = "chacha" version = "0.3.0" @@ -942,6 +1094,15 @@ dependencies = [ "memchr", ] +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + [[package]] name = "const-oid" version = "0.9.6" @@ -1796,6 +1957,12 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "endi" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3d8a32ae18130a3c84dd492d4215c3d913c3b07c6b63c2eb3eb7ff1101ab7bf" + [[package]] name = "enum-as-inner" version = "0.6.0" @@ -1808,6 +1975,27 @@ dependencies = [ "syn 2.0.58", ] +[[package]] +name = "enumflags2" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d232db7f5956f3f14313dc2f87985c58bd2c695ce124c8cdd984e08e15ac133d" +dependencies = [ + "enumflags2_derive", + "serde", +] + +[[package]] +name = "enumflags2_derive" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de0d48a183585823424a4ce1aa132d174a6a81bd540895822eb4c8373a8e49e8" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.58", +] + [[package]] name = "env_logger" version = "0.7.1" @@ -1843,6 +2031,27 @@ version = "2.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0206175f82b8d6bf6652ff7d71a1e27fd2e4efde587fd368662814d6ec1d9ce0" +[[package]] +name = "event-listener" +version = "5.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6032be9bd27023a771701cc49f9f053c751055f71efb2e0ae5c15809093675ba" +dependencies = [ + "concurrent-queue", + "parking", + "pin-project-lite", +] + +[[package]] +name = "event-listener-strategy" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f214dc438f977e6d4e3500aaa277f5ad94ca83fbbd9b1a15713ce2344ccc5a1" +dependencies = [ + "event-listener 5.3.1", + "pin-project-lite", +] + [[package]] name = "eyre" version = "0.6.12" @@ -1890,7 +2099,7 @@ version = "0.3.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "38e2275cc4e4fc009b0669731a1e5ab7ebf11f469eaede2bab9309a5b4d6057f" dependencies = [ - "memoffset", + "memoffset 0.9.1", "rustc_version", ] @@ -2071,6 +2280,19 @@ version = "0.3.30" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a44623e20b9681a318efdd71c299b6b222ed6f231972bfe2f224ebad6311f0c1" +[[package]] +name = "futures-lite" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52527eb5074e35e9339c6b4e8d12600c7128b68fb25dcb9fa9dec18f7c25f3a5" +dependencies = [ + "fastrand", + "futures-core", + "futures-io", + "parking", + "pin-project-lite", +] + [[package]] name = "futures-macro" version = "0.3.30" @@ -2354,7 +2576,7 @@ checksum = "10c6ae9f6fa26f4fb2ac16b528d138d971ead56141de489f8111e259b9df3c4a" dependencies = [ "anyhow", "heck 0.4.1", - "proc-macro-crate", + "proc-macro-crate 1.3.1", "proc-macro-error", "proc-macro2", "quote", @@ -2506,7 +2728,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "684c0456c086e8e7e9af73ec5b84e35938df394712054550e81558d21c44ab0d" dependencies = [ "anyhow", - "proc-macro-crate", + "proc-macro-crate 1.3.1", "proc-macro-error", "proc-macro2", "quote", @@ -2632,6 +2854,12 @@ version = "0.3.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d231dfb89cfffdbc30e7fc41579ed6066ad03abda9e567ccafae602b97ec5024" +[[package]] +name = "hermit-abi" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fbf6a919d6cf397374f7dfeeea91d974c7c0a7221d0d0f4f20d859d329e53fcc" + [[package]] name = "hex" version = "0.4.3" @@ -3446,6 +3674,19 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4" +[[package]] +name = "mac-notification-sys" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51fca4d74ff9dbaac16a01b924bc3693fa2bba0862c2c633abc73f9a8ea21f64" +dependencies = [ + "cc", + "dirs-next", + "objc-foundation", + "objc_id", + "time", +] + [[package]] name = "malloc_buf" version = "0.0.6" @@ -3502,6 +3743,15 @@ version = "2.7.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6c8640c5d730cb13ebd907d8d04b52f55ac9a2eec55b440c8892f40d56c76c1d" +[[package]] +name = "memoffset" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5de893c32cde5f383baa4c04c5d6dbdd735cfd4a794b0debdb2bb1b421da5ff4" +dependencies = [ + "autocfg", +] + [[package]] name = "memoffset" version = "0.9.1" @@ -3601,6 +3851,31 @@ version = "1.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" +[[package]] +name = "nix" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "598beaf3cc6fdd9a5dfb1630c2800c7acd31df7aaf0f565796fba2b53ca1af1b" +dependencies = [ + "bitflags 1.3.2", + "cfg-if", + "libc", + "memoffset 0.7.1", +] + +[[package]] +name = "nix" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab2156c4fce2f8df6c499cc1c763e4394b7482525bf2a9701c9d79d215f519e4" +dependencies = [ + "bitflags 2.5.0", + "cfg-if", + "cfg_aliases", + "libc", + "memoffset 0.9.1", +] + [[package]] name = "nodrop" version = "0.1.14" @@ -3617,6 +3892,19 @@ dependencies = [ "minimal-lexical", ] +[[package]] +name = "notify-rust" +version = "4.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5312f837191c317644f313f7b2b39f9cb1496570c74f7c17152dd3961219551f" +dependencies = [ + "log", + "mac-notification-sys", + "serde", + "tauri-winrt-notification", + "zbus", +] + [[package]] name = "nu-ansi-term" version = "0.46.0" @@ -3699,7 +3987,7 @@ version = "0.5.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dcbff9bc912032c62bf65ef1d5aea88983b420f4f839db1e9b0c281a25c9c799" dependencies = [ - "proc-macro-crate", + "proc-macro-crate 1.3.1", "proc-macro2", "quote", "syn 1.0.109", @@ -4807,6 +5095,17 @@ dependencies = [ "objc_exception", ] +[[package]] +name = "objc-foundation" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1add1b659e36c9607c7aab864a76c7a4c2760cd0cd2e120f3fb8b952c7e22bf9" +dependencies = [ + "block", + "objc", + "objc_id", +] + [[package]] name = "objc_exception" version = "0.1.2" @@ -4912,6 +5211,16 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" +[[package]] +name = "ordered-stream" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aa2b01e1d916879f73a53d01d1d6cee68adbb31d6d9177a8cfce093cced1d50" +dependencies = [ + "futures-core", + "pin-project-lite", +] + [[package]] name = "os_info" version = "3.8.2" @@ -4977,6 +5286,12 @@ dependencies = [ "winapi", ] +[[package]] +name = "parking" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb813b8af86854136c6922af0598d719255ecb2179515e6e7730d468f05c9cae" + [[package]] name = "parking_lot" version = "0.11.2" @@ -5302,6 +5617,17 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" +[[package]] +name = "piper" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae1d5c74c9876f070d3e8fd503d748c7d974c3e48da8f41350fa5222ef9b4391" +dependencies = [ + "atomic-waker", + "fastrand", + "futures-io", +] + [[package]] name = "pkcs8" version = "0.10.2" @@ -5351,6 +5677,21 @@ dependencies = [ "miniz_oxide", ] +[[package]] +name = "polling" +version = "3.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3ed00ed3fbf728b5816498ecd316d1716eecaced9c0c8d2c5a6740ca214985b" +dependencies = [ + "cfg-if", + "concurrent-queue", + "hermit-abi 0.4.0", + "pin-project-lite", + "rustix", + "tracing", + "windows-sys 0.52.0", +] + [[package]] name = "poly1305" version = "0.8.0" @@ -5434,6 +5775,15 @@ dependencies = [ "toml_edit 0.19.15", ] +[[package]] +name = "proc-macro-crate" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d37c51ca738a55da99dc0c4a34860fd675453b8b36209178c2249bb13651284" +dependencies = [ + "toml_edit 0.21.1", +] + [[package]] name = "proc-macro-error" version = "1.0.4" @@ -5882,6 +6232,30 @@ dependencies = [ "subtle 2.5.0", ] +[[package]] +name = "rfd" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0149778bd99b6959285b0933288206090c50e2327f47a9c463bfdbf45c8823ea" +dependencies = [ + "block", + "dispatch", + "glib-sys", + "gobject-sys", + "gtk-sys", + "js-sys", + "lazy_static", + "log", + "objc", + "objc-foundation", + "objc_id", + "raw-window-handle", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "windows 0.37.0", +] + [[package]] name = "ring" version = "0.16.20" @@ -6620,7 +6994,7 @@ dependencies = [ "crossbeam-queue", "dotenvy", "either", - "event-listener", + "event-listener 2.5.3", "flume", "futures-channel", "futures-core", @@ -6697,6 +7071,12 @@ dependencies = [ "loom", ] +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + [[package]] name = "string_cache" version = "0.8.7" @@ -6987,6 +7367,8 @@ dependencies = [ "heck 0.5.0", "http 0.2.12", "ignore", + "nix 0.26.4", + "notify-rust", "objc", "once_cell", "open", @@ -6995,6 +7377,7 @@ dependencies = [ "rand 0.8.5", "raw-window-handle", "regex", + "rfd", "semver 1.0.22", "serde", "serde_json", @@ -7157,6 +7540,17 @@ dependencies = [ "toml 0.7.8", ] +[[package]] +name = "tauri-winrt-notification" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f89f5fb70d6f62381f5d9b2ba9008196150b40b75f3068eb24faeddf1c686871" +dependencies = [ + "quick-xml", + "windows 0.56.0", + "windows-version", +] + [[package]] name = "tempfile" version = "3.10.1" @@ -7559,6 +7953,17 @@ dependencies = [ "winnow 0.5.40", ] +[[package]] +name = "toml_edit" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a8534fd7f78b5405e860340ad6575217ce99f38d4d5c8f2442cb5ecb50090e1" +dependencies = [ + "indexmap 2.2.6", + "toml_datetime", + "winnow 0.5.40", +] + [[package]] name = "toml_edit" version = "0.22.9" @@ -7819,6 +8224,17 @@ version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed646292ffc8188ef8ea4d1e0e0150fb15a5c2e12ad9b8fc191ae7a8a7f3c4b9" +[[package]] +name = "uds_windows" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89daebc3e6fd160ac4aa9fc8b3bf71e1f74fbf92367ae71fb83a037e8bf164b9" +dependencies = [ + "memoffset 0.9.1", + "tempfile", + "winapi", +] + [[package]] name = "unicode-bidi" version = "0.3.15" @@ -8280,6 +8696,19 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" +[[package]] +name = "windows" +version = "0.37.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57b543186b344cc61c85b5aab0d2e3adf4e0f99bc076eff9aa5927bcc0b8a647" +dependencies = [ + "windows_aarch64_msvc 0.37.0", + "windows_i686_gnu 0.37.0", + "windows_i686_msvc 0.37.0", + "windows_x86_64_gnu 0.37.0", + "windows_x86_64_msvc 0.37.0", +] + [[package]] name = "windows" version = "0.39.0" @@ -8303,6 +8732,16 @@ dependencies = [ "windows-targets 0.48.5", ] +[[package]] +name = "windows" +version = "0.56.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1de69df01bdf1ead2f4ac895dc77c9351aefff65b2f3db429a343f9cbf05e132" +dependencies = [ + "windows-core 0.56.0", + "windows-targets 0.52.5", +] + [[package]] name = "windows" version = "0.57.0" @@ -8332,6 +8771,18 @@ dependencies = [ "windows-targets 0.52.5", ] +[[package]] +name = "windows-core" +version = "0.56.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4698e52ed2d08f8658ab0c39512a7c00ee5fe2688c65f8c0a4f06750d729f2a6" +dependencies = [ + "windows-implement 0.56.0", + "windows-interface 0.56.0", + "windows-result", + "windows-targets 0.52.5", +] + [[package]] name = "windows-core" version = "0.57.0" @@ -8339,7 +8790,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d2ed2439a290666cd67ecce2b0ffaad89c2a56b976b736e6ece670297897832d" dependencies = [ "windows-implement 0.57.0", - "windows-interface", + "windows-interface 0.57.0", "windows-result", "windows-targets 0.52.5", ] @@ -8354,6 +8805,17 @@ dependencies = [ "windows-tokens", ] +[[package]] +name = "windows-implement" +version = "0.56.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6fc35f58ecd95a9b71c4f2329b911016e6bec66b3f2e6a4aad86bd2e99e2f9b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.58", +] + [[package]] name = "windows-implement" version = "0.57.0" @@ -8365,6 +8827,17 @@ dependencies = [ "syn 2.0.58", ] +[[package]] +name = "windows-interface" +version = "0.56.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08990546bf4edef8f431fa6326e032865f27138718c587dc21bc0265bbcb57cc" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.58", +] + [[package]] name = "windows-interface" version = "0.57.0" @@ -8512,6 +8985,12 @@ version = "0.52.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7088eed71e8b8dda258ecc8bac5fb1153c5cffaf2578fc8ff5d61e23578d3263" +[[package]] +name = "windows_aarch64_msvc" +version = "0.37.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2623277cb2d1c216ba3b578c0f3cf9cdebeddb6e66b1b218bb33596ea7769c3a" + [[package]] name = "windows_aarch64_msvc" version = "0.39.0" @@ -8536,6 +9015,12 @@ version = "0.52.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9985fd1504e250c615ca5f281c3f7a6da76213ebd5ccc9561496568a2752afb6" +[[package]] +name = "windows_i686_gnu" +version = "0.37.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3925fd0b0b804730d44d4b6278c50f9699703ec49bcd628020f46f4ba07d9e1" + [[package]] name = "windows_i686_gnu" version = "0.39.0" @@ -8566,6 +9051,12 @@ version = "0.52.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "87f4261229030a858f36b459e748ae97545d6f1ec60e5e0d6a3d32e0dc232ee9" +[[package]] +name = "windows_i686_msvc" +version = "0.37.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce907ac74fe331b524c1298683efbf598bb031bc84d5e274db2083696d07c57c" + [[package]] name = "windows_i686_msvc" version = "0.39.0" @@ -8590,6 +9081,12 @@ version = "0.52.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "db3c2bf3d13d5b658be73463284eaf12830ac9a26a90c717b7f771dfe97487bf" +[[package]] +name = "windows_x86_64_gnu" +version = "0.37.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2babfba0828f2e6b32457d5341427dcbb577ceef556273229959ac23a10af33d" + [[package]] name = "windows_x86_64_gnu" version = "0.39.0" @@ -8632,6 +9129,12 @@ version = "0.52.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "852298e482cd67c356ddd9570386e2862b5673c85bd5f88df9ab6802b334c596" +[[package]] +name = "windows_x86_64_msvc" +version = "0.37.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4dd6dc7df2d84cf7b33822ed5b86318fb1781948e9663bacd047fc9dd52259d" + [[package]] name = "windows_x86_64_msvc" version = "0.39.0" @@ -8785,12 +9288,86 @@ dependencies = [ "rustix", ] +[[package]] +name = "xdg-home" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca91dcf8f93db085f3a0a29358cd0b9d670915468f4290e8b85d118a34211ab8" +dependencies = [ + "libc", + "windows-sys 0.52.0", +] + [[package]] name = "yansi" version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09041cd90cf85f7f8b2df60c646f853b7f535ce68f85244eb6731cf89fa498ec" +[[package]] +name = "zbus" +version = "4.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9ff46f2a25abd690ed072054733e0bc3157e3d4c45f41bd183dce09c2ff8ab9" +dependencies = [ + "async-broadcast", + "async-executor", + "async-fs", + "async-io", + "async-lock", + "async-process", + "async-recursion", + "async-task", + "async-trait", + "blocking", + "derivative", + "enumflags2", + "event-listener 5.3.1", + "futures-core", + "futures-sink", + "futures-util", + "hex", + "nix 0.28.0", + "ordered-stream", + "rand 0.8.5", + "serde", + "serde_repr", + "sha1", + "static_assertions", + "tracing", + "uds_windows", + "windows-sys 0.52.0", + "xdg-home", + "zbus_macros", + "zbus_names", + "zvariant", +] + +[[package]] +name = "zbus_macros" +version = "4.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e0e3852c93dcdb49c9462afe67a2a468f7bd464150d866e861eaf06208633e0" +dependencies = [ + "proc-macro-crate 3.1.0", + "proc-macro2", + "quote", + "regex", + "syn 1.0.109", + "zvariant_utils", +] + +[[package]] +name = "zbus_names" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b9b1fef7d021261cc16cba64c351d291b715febe0fa10dc3a443ac5a5022e6c" +dependencies = [ + "serde", + "static_assertions", + "zvariant", +] + [[package]] name = "zerocopy" version = "0.7.32" @@ -8858,3 +9435,40 @@ dependencies = [ "cc", "pkg-config", ] + +[[package]] +name = "zvariant" +version = "4.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c1b3ca6db667bfada0f1ebfc94b2b1759ba25472ee5373d4551bb892616389a" +dependencies = [ + "endi", + "enumflags2", + "serde", + "static_assertions", + "zvariant_derive", +] + +[[package]] +name = "zvariant_derive" +version = "4.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7a4b236063316163b69039f77ce3117accb41a09567fd24c168e43491e521bc" +dependencies = [ + "proc-macro-crate 3.1.0", + "proc-macro2", + "quote", + "syn 1.0.109", + "zvariant_utils", +] + +[[package]] +name = "zvariant_utils" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00bedb16a193cc12451873fee2a1bc6550225acece0e36f333e68326c73c8172" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] diff --git a/nym-vpn-x/src-tauri/Cargo.toml b/nym-vpn-x/src-tauri/Cargo.toml index 9effff33d4..308d797b26 100644 --- a/nym-vpn-x/src-tauri/Cargo.toml +++ b/nym-vpn-x/src-tauri/Cargo.toml @@ -13,7 +13,7 @@ tauri-build = { version = "1.5", features = [] } build-info-build = "0.0.37" [dependencies] -tauri = { version = "1.7.1", features = [ "system-tray", "window-set-size", "os-all", "process-all", "shell-open"] } +tauri = { version = "1.7.1", features = [ "notification-all", "system-tray", "window-set-size", "os-all", "process-all", "shell-open"] } tokio = { version = "1.33", features = ["rt", "sync", "time", "fs"] } serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" diff --git a/nym-vpn-x/src-tauri/src/db.rs b/nym-vpn-x/src-tauri/src/db.rs index 292bd4622a..7e01860a78 100644 --- a/nym-vpn-x/src-tauri/src/db.rs +++ b/nym-vpn-x/src-tauri/src/db.rs @@ -48,6 +48,8 @@ pub enum Key { WelcomeScreenSeen, #[strum(serialize = "credential_expiry")] CredentialExpiry, + #[strum(serialize = "desktop_notifications")] + DesktopNotifications, } impl Display for Key { diff --git a/nym-vpn-x/src-tauri/src/events.rs b/nym-vpn-x/src-tauri/src/events.rs index 867262deba..5aa66cb158 100644 --- a/nym-vpn-x/src-tauri/src/events.rs +++ b/nym-vpn-x/src-tauri/src/events.rs @@ -19,6 +19,24 @@ pub struct ProgressEventPayload { pub key: ConnectProgressMsg, } +#[derive(Clone, serde::Serialize, TS)] +#[ts(export)] +#[serde(tag = "type")] +pub enum ConnectionEvent { + Update(ConnectionEventPayload), + Failed(Option), +} + +impl ConnectionEvent { + pub fn update( + state: ConnectionState, + error: Option, + start_time: Option, + ) -> Self { + Self::Update(ConnectionEventPayload::new(state, error, start_time)) + } +} + #[derive(Clone, serde::Serialize, TS)] #[ts(export)] pub struct ConnectionEventPayload { @@ -58,7 +76,7 @@ impl AppHandleEventEmitter for tauri::AppHandle { debug!("sending event [{}]: Connecting", EVENT_CONNECTION_STATE); self.emit_all( EVENT_CONNECTION_STATE, - ConnectionEventPayload::new(ConnectionState::Connecting, None, None), + ConnectionEvent::update(ConnectionState::Connecting, None, None), ) .ok(); } @@ -67,7 +85,7 @@ impl AppHandleEventEmitter for tauri::AppHandle { debug!("sending event [{}]: Disconnecting", EVENT_CONNECTION_STATE); self.emit_all( EVENT_CONNECTION_STATE, - ConnectionEventPayload::new(ConnectionState::Disconnecting, None, None), + ConnectionEvent::update(ConnectionState::Disconnecting, None, None), ) .ok(); } @@ -76,7 +94,7 @@ impl AppHandleEventEmitter for tauri::AppHandle { debug!("sending event [{}]: Disconnected", EVENT_CONNECTION_STATE); self.emit_all( EVENT_CONNECTION_STATE, - ConnectionEventPayload::new(ConnectionState::Disconnected, error, None), + ConnectionEvent::update(ConnectionState::Disconnected, error, None), ) .ok(); } diff --git a/nym-vpn-x/src-tauri/src/grpc/client.rs b/nym-vpn-x/src-tauri/src/grpc/client.rs index 8c101c4d25..5e98c0d090 100644 --- a/nym-vpn-x/src-tauri/src/grpc/client.rs +++ b/nym-vpn-x/src-tauri/src/grpc/client.rs @@ -3,7 +3,8 @@ use std::path::PathBuf; use anyhow::Result; use nym_vpn_proto::{ health_check_response::ServingStatus, health_client::HealthClient, - nym_vpnd_client::NymVpndClient, DisconnectRequest, HealthCheckRequest, StatusRequest, + nym_vpnd_client::NymVpndClient, ConnectionStatus, DisconnectRequest, HealthCheckRequest, + StatusRequest, }; use nym_vpn_proto::{ ConnectRequest, Dns, Empty, EntryNode, ExitNode, ImportUserCredentialRequest, @@ -17,7 +18,7 @@ use time::OffsetDateTime; use tokio::sync::mpsc; use tonic::transport::Endpoint as TonicEndpoint; use tonic::{transport::Channel, Request}; -use tracing::{debug, error, info, instrument, trace, warn}; +use tracing::{debug, error, info, instrument, warn}; use ts_rs::TS; use crate::cli::Cli; @@ -168,11 +169,13 @@ impl GrpcClient { }) }); + let status = res.status(); vpn_status::update( app, - ConnectionState::from(res.status()), + ConnectionState::from(status), res.error.map(BackendError::from), connection_time, + status == ConnectionStatus::ConnectionFailed, ) .await?; Ok(()) @@ -215,11 +218,13 @@ impl GrpcClient { if let Some(e) = state.error.as_ref() { warn!("vpn status error: {}", e.message); } + let status = state.status(); vpn_status::update( app, ConnectionState::from(state.status()), state.error.map(BackendError::from), None, + status == ConnectionStatus::ConnectionFailed, ) .await?; } @@ -227,9 +232,9 @@ impl GrpcClient { Ok(()) } - /// Watch VPN status updates + /// Watch VPN connection status updates #[instrument(skip_all)] - pub async fn watch_vpn_status(&self) -> Result<()> { + pub async fn watch_vpn_connection_updates(&self, app: &AppHandle) -> Result<()> { let mut vpnd = self.vpnd().await?; let request = Request::new(Empty {}); @@ -249,26 +254,18 @@ impl GrpcClient { tx.send(update).await.unwrap(); } Ok(None) => { - warn!("watch vpn status stream closed by the server"); + warn!("watch vpn connection status stream closed by the server"); return; } Err(e) => { - warn!("watch vpn status stream get a grpc error: {}", e); + warn!("watch vpn connection status stream get a grpc error: {}", e); } } } }); - while let Some(status) = rx.recv().await { - // TODO handle status updates - debug!( - "vpn status update {:?}, {:?}", - status.kind(), - status.message - ); - if !status.details.is_empty() { - trace!("vpn status details: {:?}", status.details); - } + while let Some(update) = rx.recv().await { + vpn_status::connection_update(app, update).await?; } Ok(()) diff --git a/nym-vpn-x/src-tauri/src/main.rs b/nym-vpn-x/src-tauri/src/main.rs index 96b93e7dad..af7832664f 100644 --- a/nym-vpn-x/src-tauri/src/main.rs +++ b/nym-vpn-x/src-tauri/src/main.rs @@ -171,25 +171,35 @@ async fn main() -> Result<()> { let handle = app.handle(); let c_grpc = grpc.clone(); tokio::spawn(async move { - info!("starting vpnd health watch"); + info!("starting vpnd health spy"); loop { c_grpc.watch(&handle).await.ok(); sleep(VPND_RETRY_INTERVAL).await; - debug!("vpnd health watch retry"); + debug!("vpnd health spy retry"); } }); let handle = app.handle(); let c_grpc = grpc.clone(); tokio::spawn(async move { - info!("starting vpn status watch"); + info!("starting vpn status spy"); loop { if c_grpc.refresh_vpn_status(&handle).await.is_ok() { c_grpc.watch_vpn_state(&handle).await.ok(); - c_grpc.watch_vpn_status().await.ok(); } sleep(VPND_RETRY_INTERVAL).await; - debug!("vpn status watch retry"); + debug!("vpn status spy retry"); + } + }); + + let handle = app.handle(); + let c_grpc = grpc.clone(); + tokio::spawn(async move { + info!("starting vpn connection updates spy"); + loop { + c_grpc.watch_vpn_connection_updates(&handle).await.ok(); + sleep(VPND_RETRY_INTERVAL).await; + debug!("vpn connection updates spy retry"); } }); diff --git a/nym-vpn-x/src-tauri/src/vpn_status.rs b/nym-vpn-x/src-tauri/src/vpn_status.rs index b931315a43..f503c48383 100644 --- a/nym-vpn-x/src-tauri/src/vpn_status.rs +++ b/nym-vpn-x/src-tauri/src/vpn_status.rs @@ -1,7 +1,9 @@ use crate::error::BackendError; -use crate::events::{ConnectionEventPayload, EVENT_CONNECTION_STATE}; +use crate::events::{ConnectionEvent, EVENT_CONNECTION_STATE}; use crate::states::{app::ConnectionState, SharedAppState}; use anyhow::Result; +use nym_vpn_proto::connection_status_update::StatusType; +use nym_vpn_proto::ConnectionStatusUpdate; use tauri::Manager; use time::OffsetDateTime; use tracing::{info, instrument, trace, warn}; @@ -12,10 +14,19 @@ pub async fn update( status: ConnectionState, error: Option, connection_time: Option, + failed: bool, ) -> Result<()> { let state = app.state::(); trace!("vpn status: {:?}", status); + if failed { + app.emit_all( + EVENT_CONNECTION_STATE, + ConnectionEvent::Failed(error.clone()), + ) + .ok(); + } + let mut app_state = state.lock().await; let current_state = app_state.state.clone(); app_state.state = status.clone(); @@ -38,7 +49,7 @@ pub async fn update( drop(app_state); app.emit_all( EVENT_CONNECTION_STATE, - ConnectionEventPayload::new( + ConnectionEvent::update( ConnectionState::Connected, error, Some(t.unix_timestamp()), @@ -54,7 +65,7 @@ pub async fn update( drop(app_state); app.emit_all( EVENT_CONNECTION_STATE, - ConnectionEventPayload::new(ConnectionState::Disconnected, error, None), + ConnectionEvent::update(ConnectionState::Disconnected, error, None), ) .ok(); } @@ -62,7 +73,7 @@ pub async fn update( info!("vpn status → [Connecting]"); app.emit_all( EVENT_CONNECTION_STATE, - ConnectionEventPayload::new(ConnectionState::Connecting, error, None), + ConnectionEvent::update(ConnectionState::Connecting, error, None), ) .ok(); } @@ -70,7 +81,7 @@ pub async fn update( info!("vpn status → [Disconnecting]"); app.emit_all( EVENT_CONNECTION_STATE, - ConnectionEventPayload::new(ConnectionState::Disconnecting, error, None), + ConnectionEvent::update(ConnectionState::Disconnecting, error, None), ) .ok(); } @@ -78,10 +89,39 @@ pub async fn update( warn!("vpn status → [Unknown]"); app.emit_all( EVENT_CONNECTION_STATE, - ConnectionEventPayload::new(ConnectionState::Unknown, error, None), + ConnectionEvent::update(ConnectionState::Unknown, error, None), ) .ok(); } } Ok(()) } + +#[instrument(skip_all)] +pub async fn connection_update( + _app: &tauri::AppHandle, + update: ConnectionStatusUpdate, +) -> Result<()> { + trace!("{:?}, {}", update.kind(), update.message); + if !update.details.is_empty() { + trace!("details: {:?}", update.details); + } + // TODO handle updates + match update.kind() { + StatusType::Unspecified => {} + StatusType::Unknown => {} + StatusType::EntryGatewayConnectionEstablished => {} + StatusType::ExitRouterConnectionEstablished => {} + StatusType::TunnelEndToEndConnectionEstablished => {} + StatusType::EntryGatewayNotRoutingMixnetMessages => {} + StatusType::ExitRouterNotRespondingToIpv4Ping => {} + StatusType::ExitRouterNotRespondingToIpv6Ping => {} + StatusType::ExitRouterNotRoutingIpv4Traffic => {} + StatusType::ExitRouterNotRoutingIpv6Traffic => {} + StatusType::ConnectionOkIpv4 => {} + StatusType::ConnectionOkIpv6 => {} + StatusType::RemainingBandwidth => {} + StatusType::NoBandwidth => {} + } + Ok(()) +} diff --git a/nym-vpn-x/src-tauri/tauri.conf.json b/nym-vpn-x/src-tauri/tauri.conf.json index fbe11048e0..6928a24d3a 100644 --- a/nym-vpn-x/src-tauri/tauri.conf.json +++ b/nym-vpn-x/src-tauri/tauri.conf.json @@ -20,7 +20,10 @@ "process": { "all": true }, "shell": { "all": false, "open": true }, "os": { "all": true }, - "window": { "setSize": true } + "window": { "setSize": true }, + "notification": { + "all": true + } }, "bundle": { "active": true, diff --git a/nym-vpn-x/src/helpers.ts b/nym-vpn-x/src/helpers.ts index 273d9ae171..889e3e1601 100644 --- a/nym-vpn-x/src/helpers.ts +++ b/nym-vpn-x/src/helpers.ts @@ -6,3 +6,8 @@ export function sleep(ms: number) { export function capFirst(string: string) { return string.charAt(0).toUpperCase() + string.slice(1); } + +// Given a set of strings, return the strings concatenated by a white space +export function setToString(obj: Record): string { + return Object.values(obj).reduce((prev, s) => `${prev} ${s}`, ''); +} diff --git a/nym-vpn-x/src/hooks/index.ts b/nym-vpn-x/src/hooks/index.ts index 5eabf9d36b..3f5347679f 100644 --- a/nym-vpn-x/src/hooks/index.ts +++ b/nym-vpn-x/src/hooks/index.ts @@ -1,2 +1,3 @@ export { default as useThrottle } from './useThrottle'; export { default as useI18nError } from './useI18nError'; +export { default as useNotify } from './useNotify'; diff --git a/nym-vpn-x/src/hooks/useNotify.ts b/nym-vpn-x/src/hooks/useNotify.ts new file mode 100644 index 0000000000..63d6ec0e44 --- /dev/null +++ b/nym-vpn-x/src/hooks/useNotify.ts @@ -0,0 +1,69 @@ +import { + isPermissionGranted, + sendNotification, +} from '@tauri-apps/api/notification'; +import { appWindow } from '@tauri-apps/api/window'; +import { useCallback } from 'react'; +import { useLocation } from 'react-router-dom'; +import { useMainState } from '../contexts'; + +/** + * Hook to send desktop notifications + * + * @returns The `notify` function + */ +function useNotify() { + const { desktopNotifications } = useMainState(); + const location = useLocation(); + + /** + * Sends desktop notifications. Also checks if the permission is granted + * and desktop notifications are enabled. + * + * @param body - The text to display in the notification + * @param title - The title of the notification (optional) + * @param force - Whether to send the notification even if the app is focused and visible + * @param locationPath - The pathname of a location, if the notification should + * only be sent when the user is **not** on a specific screen + */ + const notify = useCallback( + async ( + body: string, + title: string | null, + force = false, + locationPath?: string, + ) => { + if (!desktopNotifications) { + return; + } + + if (!force) { + const windowFocused = await appWindow.isFocused(); + const windowVisible = await appWindow.isVisible(); + const onRightScreen = locationPath + ? location.pathname === locationPath + : true; + if (windowFocused && windowVisible && onRightScreen) { + return; + } + } + + const granted = await isPermissionGranted(); + if (!granted) { + console.warn('Desktop notifications permission not granted'); + return; + } + + if (title) { + sendNotification({ title, body }); + } else { + sendNotification(body); + } + }, + [desktopNotifications, location], + ); + + return { notify }; +} + +export default useNotify; diff --git a/nym-vpn-x/src/i18n/config.ts b/nym-vpn-x/src/i18n/config.ts index 1773b99adc..9539980506 100644 --- a/nym-vpn-x/src/i18n/config.ts +++ b/nym-vpn-x/src/i18n/config.ts @@ -11,6 +11,7 @@ import licenses from './en/licenses.json'; import errors from './en/errors.json'; import welcome from './en/welcome.json'; import glossary from './en/glossary.json'; +import notifications from './en/notifications.json'; export const defaultNS = 'common'; export const resources = { @@ -26,6 +27,7 @@ export const resources = { errors, welcome, glossary, + notifications, }, } as const; @@ -46,6 +48,7 @@ i18n.use(initReactI18next).init({ 'errors', 'welcome', 'glossary', + 'notifications', ], interpolation: { diff --git a/nym-vpn-x/src/i18n/en/common.json b/nym-vpn-x/src/i18n/en/common.json index 438a584fba..e62fcdf74a 100644 --- a/nym-vpn-x/src/i18n/en/common.json +++ b/nym-vpn-x/src/i18n/en/common.json @@ -9,6 +9,7 @@ "last-hop-selection": "Last hop selection", "settings": "Settings", "display-theme": "Display", + "notifications": "Notifications", "logs": "Logs", "feedback": "Feedback", "legal": "Legal", diff --git a/nym-vpn-x/src/i18n/en/notifications.json b/nym-vpn-x/src/i18n/en/notifications.json new file mode 100644 index 0000000000..1a71cff569 --- /dev/null +++ b/nym-vpn-x/src/i18n/en/notifications.json @@ -0,0 +1,7 @@ +{ + "vpn-tunnel-state": { + "connected": "VPN tunnel connected", + "disconnected": "VPN tunnel disconnected", + "failed": "VPN tunnel connection failed!" + } +} diff --git a/nym-vpn-x/src/i18n/en/settings.json b/nym-vpn-x/src/i18n/en/settings.json index ff6987ca2c..e44fd90adb 100644 --- a/nym-vpn-x/src/i18n/en/settings.json +++ b/nym-vpn-x/src/i18n/en/settings.json @@ -14,6 +14,9 @@ "title": "Anonymous error reports", "desc": "applies on app restart" }, + "notifications": { + "title": "Desktop notifications" + }, "monitoring-alert": "You must restart the app for the change to take effect.", "feedback": { "title": "Feedback", diff --git a/nym-vpn-x/src/pages/MainLayout.tsx b/nym-vpn-x/src/pages/MainLayout.tsx index b6c8c9d33c..0e93610326 100644 --- a/nym-vpn-x/src/pages/MainLayout.tsx +++ b/nym-vpn-x/src/pages/MainLayout.tsx @@ -1,7 +1,13 @@ +import { useCallback, useEffect } from 'react'; +import { useTranslation } from 'react-i18next'; import { Outlet, useLocation } from 'react-router-dom'; import clsx from 'clsx'; +import { listen } from '@tauri-apps/api/event'; +import { AppName, ConnectionEvent } from '../constants'; import { useMainState } from '../contexts'; +import { useNotify } from '../hooks'; import { routes } from '../router'; +import { ConnectionEvent as ConnectionEventData } from '../types'; import { DaemonDot, Notifications, TopBar } from '../ui'; type MainLayoutProps = { @@ -15,8 +21,44 @@ function MainLayout({ noNotifications, noDaemonDot, }: MainLayoutProps) { - const location = useLocation(); const { daemonStatus } = useMainState(); + const { notify } = useNotify(); + + const location = useLocation(); + const { t } = useTranslation('notifications'); + + const registerStateListener = useCallback(() => { + return listen(ConnectionEvent, (event) => { + if (event.payload.type === 'Failed') { + notify(t('vpn-tunnel-state.failed'), AppName, false, routes.root); + return; + } + + switch (event.payload.state) { + case 'Connected': + notify(t('vpn-tunnel-state.connected'), AppName, false, routes.root); + break; + case 'Disconnected': + notify( + t('vpn-tunnel-state.disconnected'), + AppName, + false, + routes.root, + ); + break; + default: + break; + } + }); + }, [notify, t]); + + useEffect(() => { + const unlistenState = registerStateListener(); + + return () => { + unlistenState.then((f) => f()); + }; + }, [registerStateListener]); return (
{state.error.key ? tE(state.error.key) : state.error.message}

+ {state.error.data && ( +

+ {setToString(state.error.data)} +

+ )} )}
diff --git a/nym-vpn-x/src/pages/home/NetworkModeSelect.tsx b/nym-vpn-x/src/pages/home/NetworkModeSelect.tsx index b11f905e3b..6a43e9941f 100644 --- a/nym-vpn-x/src/pages/home/NetworkModeSelect.tsx +++ b/nym-vpn-x/src/pages/home/NetworkModeSelect.tsx @@ -126,9 +126,7 @@ function NetworkModeSelect() { { - handleNetworkModeChange(mode); - }} + onChange={handleNetworkModeChange} radioIcons={false} /> diff --git a/nym-vpn-x/src/pages/settings/Settings.tsx b/nym-vpn-x/src/pages/settings/Settings.tsx index a773061760..fcdfce9f8b 100644 --- a/nym-vpn-x/src/pages/settings/Settings.tsx +++ b/nym-vpn-x/src/pages/settings/Settings.tsx @@ -122,11 +122,31 @@ function Settings() { }, ]} /> - navigate(routes.display)} - leadingIcon="contrast" - trailingIcon="arrow_right" + navigate(routes.display), + leadingIcon: 'contrast', + trailing: ( + + ), + }, + { + title: t('notifications', { ns: 'common' }), + leadingIcon: 'notifications', + onClick: () => navigate(routes.notifications), + trailing: ( + + ), + }, + ]} /> navigate(routes.legal)} trailingIcon="arrow_right" /> - { - exit(); - }} - /> +
Version {version}
diff --git a/nym-vpn-x/src/pages/settings/index.ts b/nym-vpn-x/src/pages/settings/index.ts index 7472a03594..e27cadcff1 100644 --- a/nym-vpn-x/src/pages/settings/index.ts +++ b/nym-vpn-x/src/pages/settings/index.ts @@ -1,6 +1,7 @@ export { default as Settings } from './Settings'; export { default as SettingsRouteIndex } from './SettingsRouteIndex'; export * from './display'; +export * from './notifications'; export * from './feedback'; export * from './legal'; export * from './support'; diff --git a/nym-vpn-x/src/pages/settings/notifications/Notifications.tsx b/nym-vpn-x/src/pages/settings/notifications/Notifications.tsx new file mode 100644 index 0000000000..271da55ff7 --- /dev/null +++ b/nym-vpn-x/src/pages/settings/notifications/Notifications.tsx @@ -0,0 +1,68 @@ +import { useEffect } from 'react'; +import { useTranslation } from 'react-i18next'; +import { + isPermissionGranted, + requestPermission, +} from '@tauri-apps/api/notification'; +import { useMainDispatch, useMainState } from '../../../contexts'; +import { kvSet } from '../../../kvStore'; +import { StateDispatch } from '../../../types'; +import { PageAnim, SettingsMenuCard, Switch } from '../../../ui'; + +function Notifications() { + const { desktopNotifications } = useMainState(); + const dispatch = useMainDispatch() as StateDispatch; + const { t } = useTranslation('settings'); + + useEffect(() => { + const checkPermission = async () => { + const granted = await isPermissionGranted(); + if (desktopNotifications && !granted) { + const permission = await requestPermission(); + dispatch({ + type: 'set-desktop-notifications', + enabled: permission === 'granted', + }); + kvSet('DesktopNotifications', permission === 'granted'); + } + }; + + checkPermission(); + }, [desktopNotifications, dispatch]); + + const handleNotificationsChange = async () => { + let enabled = !desktopNotifications; + const granted = await isPermissionGranted(); + + if (enabled && !granted) { + const permission = await requestPermission(); + enabled = permission === 'granted'; + } + + if (enabled !== desktopNotifications) { + dispatch({ + type: 'set-desktop-notifications', + enabled: enabled, + }); + kvSet('DesktopNotifications', enabled); + } + }; + + return ( + + + } + /> + + ); +} + +export default Notifications; diff --git a/nym-vpn-x/src/pages/settings/notifications/index.ts b/nym-vpn-x/src/pages/settings/notifications/index.ts new file mode 100644 index 0000000000..66b4fdbc06 --- /dev/null +++ b/nym-vpn-x/src/pages/settings/notifications/index.ts @@ -0,0 +1 @@ +export { default as Notifications } from './Notifications'; diff --git a/nym-vpn-x/src/router.tsx b/nym-vpn-x/src/router.tsx index 5e6da63ced..bad38b7c93 100644 --- a/nym-vpn-x/src/router.tsx +++ b/nym-vpn-x/src/router.tsx @@ -12,6 +12,7 @@ import { LicenseList, MainLayout, NodeLocation, + Notifications, Settings, SettingsRouteIndex, Support, @@ -26,6 +27,7 @@ export const routes = { credential: '/credential', settings: '/settings', display: '/settings/display', + notifications: '/settings/notifications', logs: '/settings/logs', feedback: '/settings/feedback', feedbackSend: '/settings/feedback/send', @@ -75,6 +77,11 @@ const router = createRouterFn([ element: , errorElement: , }, + { + path: routes.notifications, + element: , + errorElement: , + }, { path: routes.feedback, element: , diff --git a/nym-vpn-x/src/state/init.ts b/nym-vpn-x/src/state/init.ts index ede6889eed..742bb48aef 100644 --- a/nym-vpn-x/src/state/init.ts +++ b/nym-vpn-x/src/state/init.ts @@ -177,6 +177,19 @@ export async function initFirstBatch(dispatch: StateDispatch) { }, }; + const getDesktopNotificationsRq: TauriReq< + () => Promise + > = { + name: 'getDesktopNotificationsRq', + request: () => kvGet('DesktopNotifications'), + onFulfilled: (enabled) => { + dispatch({ + type: 'set-desktop-notifications', + enabled: enabled || false, + }); + }, + }; + const getRootFontSizeRq: TauriReq<() => Promise> = { name: 'getRootFontSize', request: () => kvGet('UiRootFontSize'), @@ -279,6 +292,7 @@ export async function initFirstBatch(dispatch: StateDispatch) { getWindowSizeRq, getWindowPositionRq, getOsRq, + getDesktopNotificationsRq, ]); } diff --git a/nym-vpn-x/src/state/main.ts b/nym-vpn-x/src/state/main.ts index 4cf5ad0623..bb66068601 100644 --- a/nym-vpn-x/src/state/main.ts +++ b/nym-vpn-x/src/state/main.ts @@ -39,6 +39,7 @@ export type StateAction = | { type: 'set-disconnected' } | { type: 'set-auto-connect'; autoConnect: boolean } | { type: 'set-monitoring'; monitoring: boolean } + | { type: 'set-desktop-notifications'; enabled: boolean } | { type: 'reset' } | { type: 'set-ui-theme'; theme: UiTheme } | { type: 'set-theme-mode'; mode: ThemeMode } @@ -80,6 +81,7 @@ export const initialState: AppState = { progressMessages: [], autoConnect: false, monitoring: false, + desktopNotifications: true, // TODO ⚠ these should be set to 'Fastest' when the backend is ready entryNodeLocation: DefaultNodeCountry, // TODO ⚠ these should be set to 'Fastest' when the backend is ready @@ -150,6 +152,11 @@ export function reducer(state: AppState, action: StateAction): AppState { ...state, monitoring: action.monitoring, }; + case 'set-desktop-notifications': + return { + ...state, + desktopNotifications: action.enabled, + }; case 'set-country-list': if (action.payload.hop === 'entry') { return { @@ -173,9 +180,6 @@ export function reducer(state: AppState, action: StateAction): AppState { exitCountriesLoading: action.payload.loading, }; case 'change-connection-state': { - console.log( - `__REDUCER [change-connection-state] changing connection state to ${action.state}`, - ); if (action.state === state.state) { return state; } @@ -187,9 +191,6 @@ export function reducer(state: AppState, action: StateAction): AppState { }; } case 'connect': { - console.log( - `__REDUCER [connect] changing connection state to Connecting`, - ); return { ...state, state: 'Connecting', loading: true }; } case 'disconnect': { @@ -201,9 +202,6 @@ export function reducer(state: AppState, action: StateAction): AppState { version: action.version, }; case 'set-connected': { - console.log( - `__REDUCER [set-connected] changing connection state to Connected`, - ); return { ...state, state: 'Connected', diff --git a/nym-vpn-x/src/state/useTauriEvents.ts b/nym-vpn-x/src/state/useTauriEvents.ts index 72984694ee..ab338a6292 100644 --- a/nym-vpn-x/src/state/useTauriEvents.ts +++ b/nym-vpn-x/src/state/useTauriEvents.ts @@ -11,7 +11,7 @@ import { kvSet } from '../kvStore'; import { AppState, BackendError, - ConnectionEventPayload, + ConnectionEvent as ConnectionEventData, DaemonStatus, ProgressEventPayload, StateDispatch, @@ -33,7 +33,7 @@ function handleError(dispatch: StateDispatch, error?: BackendError | null) { export function useTauriEvents(dispatch: StateDispatch, state: AppState) { const registerDaemonListener = useCallback(() => { return listen(DaemonEvent, (event) => { - console.log(`received event ${event.event}, status: ${event.payload}`); + console.log(`received event [${event.event}], status: ${event.payload}`); dispatch({ type: 'set-daemon-status', status: event.payload, @@ -42,9 +42,14 @@ export function useTauriEvents(dispatch: StateDispatch, state: AppState) { }, [dispatch]); const registerStateListener = useCallback(() => { - return listen(ConnectionEvent, (event) => { + return listen(ConnectionEvent, (event) => { + if (event.payload.type === 'Failed') { + console.log(`received event [${event.event}], connection failed`); + handleError(dispatch, event.payload); + return; + } console.log( - `received event ${event.event}, state: ${event.payload.state}`, + `received event [${event.event}], state: ${event.payload.state}`, ); switch (event.payload.state) { case 'Connected': @@ -78,7 +83,7 @@ export function useTauriEvents(dispatch: StateDispatch, state: AppState) { const registerProgressListener = useCallback(() => { return listen(ProgressEvent, (event) => { console.log( - `received event ${event.event}, message: ${event.payload.key}`, + `received event [${event.event}], message: ${event.payload.key}`, ); dispatch({ type: 'new-progress-message', diff --git a/nym-vpn-x/src/types/app-state.ts b/nym-vpn-x/src/types/app-state.ts index 5e9cedf6be..5cb6393f31 100644 --- a/nym-vpn-x/src/types/app-state.ts +++ b/nym-vpn-x/src/types/app-state.ts @@ -64,6 +64,7 @@ export type AppState = { entrySelector: boolean; autoConnect: boolean; monitoring: boolean; + desktopNotifications: boolean; entryNodeLocation: NodeLocation; exitNodeLocation: NodeLocation; fastestNodeLocation: Country; @@ -84,6 +85,10 @@ export type AppState = { os: OsType; }; +export type ConnectionEvent = + | ({ type: 'Update' } & ConnectionEventPayload) + | ({ type: 'Failed' } & (BackendError | null)); + export type ConnectionEventPayload = { state: ConnectionState; error?: BackendError | null; diff --git a/nym-vpn-x/src/types/tauri-ipc.ts b/nym-vpn-x/src/types/tauri-ipc.ts index 478f159c68..d5f4566cc1 100644 --- a/nym-vpn-x/src/types/tauri-ipc.ts +++ b/nym-vpn-x/src/types/tauri-ipc.ts @@ -24,7 +24,8 @@ export type DbKey = | 'WindowSize' | 'WindowPosition' | 'WelcomeScreenSeen' - | 'CredentialExpiry'; + | 'CredentialExpiry' + | 'DesktopNotifications'; export type BkdErrorKey = | 'UnknownError' diff --git a/nym-vpn-x/src/ui/Button.tsx b/nym-vpn-x/src/ui/Button.tsx index d27766f43a..0d0701c098 100644 --- a/nym-vpn-x/src/ui/Button.tsx +++ b/nym-vpn-x/src/ui/Button.tsx @@ -19,7 +19,7 @@ function Spinner() { className={clsx([ 'loader', os === 'linux' ? 'h-[32px] w-[32px]' : 'h-[22px] w-[22px] border-4', - 'border:white dark:border-black border-b-transparent dark:border-b-transparent', + 'border:white dark:border-[#2c2b2e] border-b-transparent dark:border-b-transparent', ])} > ); diff --git a/nym-vpn-x/src/ui/TopBar.tsx b/nym-vpn-x/src/ui/TopBar.tsx index 0fca671389..b0b77bd7b0 100644 --- a/nym-vpn-x/src/ui/TopBar.tsx +++ b/nym-vpn-x/src/ui/TopBar.tsx @@ -87,6 +87,13 @@ export default function TopBar() { navigate(-1); }, }, + '/settings/notifications': { + title: t('notifications'), + leftIcon: 'arrow_back', + handleLeftNav: () => { + navigate(-1); + }, + }, '/settings/logs': { title: t('logs'), leftIcon: 'arrow_back', diff --git a/proto/nym/vpn.proto b/proto/nym/vpn.proto index 9446f204fd..d4d452d124 100644 --- a/proto/nym/vpn.proto +++ b/proto/nym/vpn.proto @@ -109,7 +109,7 @@ message ConnectionStatusUpdate { // NOTE: currently not implemented by vpnd EXIT_ROUTER_CONNECTION_ESTABLISHED = 3; - // End-to-end tunnel establised and operational + // End-to-end tunnel established and operational TUNNEL_END_TO_END_CONNECTION_ESTABLISHED = 4; // Entry gateway not routing our mixnet messages