From 2ba1e89fe8ae1aadc3fc3b6a4a7a12257ded6d95 Mon Sep 17 00:00:00 2001 From: Patrick Pacher Date: Tue, 23 Jan 2024 18:08:04 +0100 Subject: [PATCH] Add portapi support and multiple improvements to the tauri app --- tauri-app/src-tauri/Cargo.lock | 165 +++++++++- tauri-app/src-tauri/Cargo.toml | 8 +- tauri-app/src-tauri/src/common/mod.rs | 2 +- tauri-app/src-tauri/src/main.rs | 290 ++++++++++++++---- tauri-app/src-tauri/src/portapi/client.rs | 151 +++++++++ tauri-app/src-tauri/src/portapi/message.rs | 228 ++++++++++++++ tauri-app/src-tauri/src/portapi/mod.rs | 4 + .../src-tauri/src/portapi/notification.rs | 80 +++++ tauri-app/src-tauri/src/portapi/types.rs | 172 +++++++++++ 9 files changed, 1031 insertions(+), 69 deletions(-) create mode 100644 tauri-app/src-tauri/src/portapi/client.rs create mode 100644 tauri-app/src-tauri/src/portapi/message.rs create mode 100644 tauri-app/src-tauri/src/portapi/mod.rs create mode 100644 tauri-app/src-tauri/src/portapi/notification.rs create mode 100644 tauri-app/src-tauri/src/portapi/types.rs diff --git a/tauri-app/src-tauri/Cargo.lock b/tauri-app/src-tauri/Cargo.lock index 9b84c525..cd017d20 100644 --- a/tauri-app/src-tauri/Cargo.lock +++ b/tauri-app/src-tauri/Cargo.lock @@ -141,20 +141,24 @@ checksum = "a4668cab20f66d8d020e1fbc0ebe47217433c1b6c8f2040faf858554e394ace6" name = "app" version = "0.1.0" dependencies = [ + "assert_matches", "cached", "dataurl", "dirs", + "futures-util", "gdk-pixbuf", "gdk-pixbuf-sys", "gio-sys 0.18.1", "glib 0.18.4", "glib-sys 0.18.1", "gtk-sys", + "http 1.0.0", "lazy_static", "notify-rust", "rust-ini", "serde", "serde_json", + "sha", "tauri", "tauri-build", "tauri-plugin-cli", @@ -165,6 +169,8 @@ dependencies = [ "tauri-plugin-shell", "tauri-plugin-single-instance", "tokio", + "tokio-websockets", + "url", "uuid", ] @@ -199,6 +205,12 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "23b62fc65de8e4e7f52534fb52b0f3ed04746ae267519eef2a83941e8085068b" +[[package]] +name = "assert_matches" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b34d609dfbaf33d6889b2b7106d3ca345eacad44200913df5ba02bfd31d2ba9" + [[package]] name = "async-broadcast" version = "0.5.1" @@ -521,6 +533,12 @@ dependencies = [ "alloc-stdlib", ] +[[package]] +name = "bswap" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3acc5ce9c60e68df21b877f13f908ef95c89f01cb6c656cf76ba95f10bc72f5" + [[package]] name = "bumpalo" version = "3.14.0" @@ -1895,7 +1913,7 @@ dependencies = [ "futures-core", "futures-sink", "futures-util", - "http", + "http 0.2.11", "indexmap 2.1.0", "slab", "tokio", @@ -1971,6 +1989,17 @@ dependencies = [ "itoa 1.0.10", ] +[[package]] +name = "http" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b32afd38673a8016f7c9ae69e5af41a58f81b1d31689040f2f1959594ce194ea" +dependencies = [ + "bytes", + "fnv", + "itoa 1.0.10", +] + [[package]] name = "http-body" version = "0.4.6" @@ -1978,7 +2007,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7ceab25649e9960c0311ea418d17bee82c0dcec1bd053b5f9a66e265a693bed2" dependencies = [ "bytes", - "http", + "http 0.2.11", "pin-project-lite", ] @@ -2005,7 +2034,7 @@ dependencies = [ "futures-core", "futures-util", "h2", - "http", + "http 0.2.11", "http-body", "httparse", "httpdate", @@ -3320,7 +3349,7 @@ dependencies = [ "futures-core", "futures-util", "h2", - "http", + "http 0.2.11", "http-body", "hyper", "ipnet", @@ -3368,6 +3397,20 @@ dependencies = [ "windows-sys 0.48.0", ] +[[package]] +name = "ring" +version = "0.17.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "688c63d65483050968b2a8937f7995f443e27041a0f7700aa59b0822aedebb74" +dependencies = [ + "cc", + "getrandom 0.2.11", + "libc", + "spin", + "untrusted", + "windows-sys 0.48.0", +] + [[package]] name = "rust-argon2" version = "0.8.3" @@ -3432,6 +3475,36 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "rustls" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe6b63262c9fcac8659abfaa96cac103d28166d3ff3eaf8f412e19f3ae9e5a48" +dependencies = [ + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7673e0aa20ee4937c6aacfc12bb8341cfbf054cdd21df6bec5fd0629fe9339b" + +[[package]] +name = "rustls-webpki" +version = "0.102.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de2635c8bc2b88d367767c5de8ea1d8db9af3f6219eba28442242d9ab81d1b89" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + [[package]] name = "rustversion" version = "1.0.14" @@ -3621,6 +3694,15 @@ dependencies = [ "stable_deref_trait", ] +[[package]] +name = "sha" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4208d5a903276a9f3b797afdf6c5bc12a8da1344b053b100abf3565ecc80cb7e" +dependencies = [ + "bswap", +] + [[package]] name = "sha1" version = "0.10.6" @@ -3744,6 +3826,12 @@ dependencies = [ "system-deps", ] +[[package]] +name = "spin" +version = "0.9.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" + [[package]] name = "stable_deref_trait" version = "1.2.0" @@ -3809,6 +3897,12 @@ version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "73473c0e59e6d5812c5dfe2a064a6444949f089e20eec9a2e5506596494e4623" +[[package]] +name = "subtle" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81cdd64d312baedb58e21336b31bc043b77e01cc99033ce76ef539f78e965ebc" + [[package]] name = "swift-rs" version = "1.0.6" @@ -3959,7 +4053,7 @@ dependencies = [ "glob", "gtk", "heck", - "http", + "http 0.2.11", "ico", "infer", "jni", @@ -4191,7 +4285,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "64a989e58af6e554dbac798a0a8d112faafc1509bcfab626466181e0724f09c5" dependencies = [ "gtk", - "http", + "http 0.2.11", "jni", "raw-window-handle", "serde", @@ -4210,7 +4304,7 @@ checksum = "5a9f181a6f5f982204ae293c19f37ba90116b8ec0bfd0a08c7a7ba67200cd9e3" dependencies = [ "cocoa", "gtk", - "http", + "http 0.2.11", "jni", "percent-encoding", "raw-window-handle", @@ -4418,9 +4512,32 @@ dependencies = [ "num_cpus", "pin-project-lite", "socket2 0.5.5", + "tokio-macros", "windows-sys 0.48.0", ] +[[package]] +name = "tokio-macros" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b8a1e28f2deaa14e508979454cb3a223b10b938b45af148bc0986de36f1923b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.41", +] + +[[package]] +name = "tokio-rustls" +version = "0.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "775e0c0f0adb3a2f22a00c4745d728b479985fc15ee7ca6a2608388c5569860f" +dependencies = [ + "rustls", + "rustls-pki-types", + "tokio", +] + [[package]] name = "tokio-util" version = "0.7.10" @@ -4435,6 +4552,26 @@ dependencies = [ "tracing", ] +[[package]] +name = "tokio-websockets" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b069bad86dda43d908b4221fe04fe49d2ed8e0a24d319a5c6a8d250e76fe15b" +dependencies = [ + "base64 0.21.5", + "bytes", + "futures-core", + "futures-sink", + "http 1.0.0", + "httparse", + "rand 0.8.5", + "ring", + "tokio", + "tokio-rustls", + "tokio-util", + "tracing", +] + [[package]] name = "toml" version = "0.7.8" @@ -4646,6 +4783,12 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e51733f11c9c4f72aa0c160008246859e340b00807569a0da0e7a1079b27ba85" +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + [[package]] name = "url" version = "2.5.0" @@ -5311,7 +5454,7 @@ dependencies = [ "gdkx11", "gtk", "html5ever", - "http", + "http 0.2.11", "javascriptcore-rs", "jni", "kuchikiki", @@ -5479,6 +5622,12 @@ dependencies = [ "syn 2.0.41", ] +[[package]] +name = "zeroize" +version = "1.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "525b4ec142c6b68a2d10f01f7bbf6755599ca3f81ea53b8431b7dd348f5fdb2d" + [[package]] name = "zvariant" version = "3.15.0" diff --git a/tauri-app/src-tauri/Cargo.toml b/tauri-app/src-tauri/Cargo.toml index be0f53c9..f7312784 100644 --- a/tauri-app/src-tauri/Cargo.toml +++ b/tauri-app/src-tauri/Cargo.toml @@ -25,6 +25,7 @@ tauri-plugin-os = "2.0.0-alpha" tauri-plugin-single-instance = "2.0.0-alpha" tauri-plugin-cli = "2.0.0-alpha" tauri-plugin-notification = "2.0.0-alpha" +futures-util = "0.3" dirs = "1.0" rust-ini = "0.20.0" @@ -37,9 +38,14 @@ gdk-pixbuf = "0.18.3" gdk-pixbuf-sys = "0.18.0" uuid = "1.6.1" lazy_static = "1.4.0" -tokio = "1.35.0" +tokio = { version = "1.35.0", features = ["macros"] } cached = "0.46.1" notify-rust = "4.10.0" +assert_matches = "1.5.0" +tokio-websockets = { version = "0.5.0", features = ["client", "ring", "rand"] } +sha = "1.0.3" +http = "1.0.0" +url = "2.5.0" [features] # this feature is used for production builds or when `devPath` points to the filesystem and the built-in dev server is disabled. diff --git a/tauri-app/src-tauri/src/common/mod.rs b/tauri-app/src-tauri/src/common/mod.rs index 9eb75d76..ca7b9c06 100644 --- a/tauri-app/src-tauri/src/common/mod.rs +++ b/tauri-app/src-tauri/src/common/mod.rs @@ -1,2 +1,2 @@ pub mod xdg_desktop; -pub mod service_manager; +pub mod service_manager; \ No newline at end of file diff --git a/tauri-app/src-tauri/src/main.rs b/tauri-app/src-tauri/src/main.rs index 0730e32f..7f475b16 100644 --- a/tauri-app/src-tauri/src/main.rs +++ b/tauri-app/src-tauri/src/main.rs @@ -1,17 +1,30 @@ // Prevents additional console window on Windows in release, DO NOT REMOVE!! #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] + mod common; +mod portapi; use common::service_manager::*; +use portapi::client::PortAPI; +use serde_json::json; use tauri::{ - menu::{MenuBuilder, MenuItemBuilder, MenuItemKind, CheckMenuItem, CheckMenuItemBuilder, PredefinedMenuItem}, + menu::{CheckMenuItemBuilder, MenuBuilder, MenuItemBuilder, PredefinedMenuItem}, tray::{ClickType, TrayIconBuilder}, - AppHandle, Manager, RunEvent, Window, WindowEvent, Icon, + AppHandle, Icon, Manager, RunEvent, Window, WindowEvent, }; use tauri_plugin_cli::CliExt; use tauri_plugin_dialog::DialogExt; -use tauri_plugin_notification::{NotificationExt, ActionType}; +use tauri_plugin_notification::NotificationExt; + +use crate::portapi::message::*; +use crate::portapi::types::*; + +use std::sync::{Arc, atomic::{AtomicBool, Ordering}}; + +lazy_static! { + static ref PM_REACHABLE: Arc = Arc::new(AtomicBool::new(false)); +} #[macro_use] extern crate lazy_static; @@ -63,71 +76,78 @@ fn get_app_info( Ok(cloned) } -fn open_or_create_window(app: &AppHandle) -> Result<()> { - if let Some(window) = app.get_window("main") { +fn open_or_create_window(app: &AppHandle) -> Result { + let window = if let Some(window) = app.get_window("main") { let _ = window.show(); let _ = window.unminimize(); let _ = window.set_focus(); + + window } else { - let _ = tauri::WindowBuilder::new(app, "main", tauri::WindowUrl::App("index.html".into())) + let mut res = tauri::WindowBuilder::new(app, "main", tauri::WindowUrl::App("index.html".into())) .build()?; - } - Ok(()) + // Immediately navigate to the Portmaster UI if we're connected. + if PM_REACHABLE.load(Ordering::Relaxed) { + res.navigate("http://127.0.0.1:817".parse::().unwrap()); + } + + res + }; + + Ok(window) } fn setup_tray_menu(app: &mut tauri::App) -> core::result::Result<(), Box> { // Tray menu - let close_btn = MenuItemBuilder::with_id("close", "Exit") - .build(app); + let close_btn = MenuItemBuilder::with_id("close", "Exit").build(app); let open_btn = MenuItemBuilder::with_id("open", "Open").build(app); let spn = CheckMenuItemBuilder::with_id("spn", "SPN").build(app); - let menu = MenuBuilder::new(app) - .items(&[&spn, - &PredefinedMenuItem::separator(app), - &open_btn, - &close_btn + .items(&[ + &spn, + &PredefinedMenuItem::separator(app), + &open_btn, + &close_btn, ]) .build()?; TrayIconBuilder::new() - .icon(Icon::Raw(include_bytes!("../../../notifier/icons/icons/pm_light_512.ico").into())) + .icon(Icon::Raw( + include_bytes!("../../../notifier/icons/icons/pm_light_512.ico").into(), + )) .menu(&menu) - .on_menu_event(move |app, event| { - match event.id().as_ref() { - "close" => { - println!("showing dialog"); - - let handle = app.clone(); - app.dialog() - .message("This does not stop the Portmaster system service") - .title("Do you really want to quit the user interface") - .ok_button_label("Yes, exit") - .cancel_button_label("No") - .show(move |answer| { + .on_menu_event(move |app, event| match event.id().as_ref() { + "close" => { + println!("showing dialog"); + + let handle = app.clone(); + app.dialog() + .message("This does not stop the Portmaster system service") + .title("Do you really want to quit the user interface") + .ok_button_label("Yes, exit") + .cancel_button_label("No") + .show(move |answer| { if answer { - let _ = handle.emit("exit-requested", ""); - handle.exit(0); + let _ = handle.emit("exit-requested", ""); + handle.exit(0); } - }); - } - "open" => { - match open_or_create_window(app) { - Ok(_) => {} - Err(err) => { - eprintln!("Failed to open or create window: {:?}", err); - } - } - } - other => { - eprintln!("unknown menu event id: {}", other); + }); + } + "open" => match open_or_create_window(app) { + Ok(_) => {} + Err(err) => { + eprintln!("Failed to open or create window: {:?}", err); } + }, + other => { + eprintln!("unknown menu event id: {}", other); } }) - .on_tray_icon_event(|tray, event| { // not supported on linux + .on_tray_icon_event(|tray, event| { + // not supported on linux if event.click_type == ClickType::Left { let _ = open_or_create_window(tray.app_handle()); } @@ -137,6 +157,143 @@ fn setup_tray_menu(app: &mut tauri::App) -> core::result::Result<(), Box Some((key, payload)), + Response::New(key, payload) => Some((key, payload)), + Response::Update(key, payload) => Some((key, payload)), + _ => None, + }; + + if let Some((key, payload)) = res { + match payload.parse::() { + Ok(n) => { + // Skip if this one should not be shown using the system notifications + if !n.show_on_system { + return; + } + + // Skip if this action has already been acted on + if n.selected_action_id != "" { + return; + } + + // TODO(ppacher): keep a reference of open notifications and close them + // if the user reacted inside the UI: + + let mut notif = notify_rust::Notification::new(); + notif.body(&n.message); + notif.timeout(notify_rust::Timeout::Never); // TODO(ppacher): use n.expires to calculate the timeout. + notif.summary(&n.title); + notif.icon("portmaster"); + + for action in n.actions { + notif.action(&action.id, &action.text); + } + + let cli_clone = cli.clone(); + tauri::async_runtime::spawn(async move { + let res = notif.show(); + match res { + Ok(handle) => { + handle.wait_for_action(|action| { + match action { + "__closed" => { + // timeout + } + + value => { + let value = value.to_string().clone(); + + tauri::async_runtime::spawn(async move { + let _ = cli_clone + .request(Request::Update( + key, + portapi::message::Payload::JSON( + json!({ + "SelectedActionID": value + }) + .to_string(), + ), + )) + .await; + }); + } + } + }) + } + Err(err) => { + eprintln!("failed to display notification: {}", err); + } + } + }); + } + Err(err) => match err { + ParseError::JSON(err) => { + eprintln!("failed to parse notification: {}", err); + } + _ => { + eprintln!("unknown error when parsing notifications payload"); + } + }, + } + } + } + } +} + +fn start_websocket_thread(app: &tauri::AppHandle, handle_notifications: bool) { + let app = app.clone(); + + tauri::async_runtime::spawn(async move { + loop { + #[cfg(debug_assertions)] + println!("Trying to connect to websocket endpoint"); + + let api = portapi::client::connect("ws://127.0.0.1:817/api/database/v1").await; + + match api { + Ok(cli) => { + eprintln!("Successfully connected to portmaster"); + PM_REACHABLE.store(true, Ordering::Relaxed); + + let _ = app.emit("portapi::connected", ""); + + // Start the notification handle if desired + if handle_notifications { + let cli = cli.clone(); + tauri::async_runtime::spawn(async move { + notification_handler(cli).await; + }); + } + + while !cli.is_closed() { + let _ = tokio::time::sleep(tokio::time::Duration::from_secs(1)).await; + } + + PM_REACHABLE.store(false, Ordering::Relaxed); + + eprintln!("lost connection to portmaster, retrying ....") + } + Err(err) => { + eprintln!("failed to create portapi client: {}", err); + + // sleep and retry + tokio::time::sleep(tokio::time::Duration::from_secs(2)).await; + } + } + } + }); +} + fn main() { let systemd = SystemdServiceManager {}; @@ -182,20 +339,23 @@ fn main() { // incase the tauri app would have been started again. let handle = app.handle().clone(); app.listen_global("single-instance", move |_event| { - match handle.get_window("main") { - Some(window) => { - let _ = window.unminimize(); - let _ = window.show(); - let _ = window.set_focus(); - } + let _ = open_or_create_window(&handle); + }); - None => { - let _ = open_or_create_window(&handle); + // Load the UI from portmaster if portapi::connected event is emitted + // and we're not yet on the correct page. + // Note that we do not create the window here if it does not exist (i.e. we're minimized to tray.) + let handle = app.handle().clone(); + app.listen_global("portapi::connected", move |_event| { + if let Some(mut window) = handle.get_window("main") { + if !(window.url().host_str() == Some("127.0.0.1") && window.url().port() == Some(817)) { + window.navigate("http://127.0.0.1:817".parse::().unwrap()); } } }); let mut background = false; + let mut handle_notifications = false; match app.cli().matches() { Ok(matches) => { @@ -209,6 +369,15 @@ fn main() { None => {} } } + + if let Some(nf_flag) = matches.args.get("with-notifications") { + match nf_flag.value.as_bool() { + Some(v) => { + handle_notifications = v; + } + None => {} + } + } } Err(_) => {} }; @@ -219,14 +388,16 @@ fn main() { #[cfg(debug_assertions)] app.get_window("main").unwrap().open_devtools(); } else { - let _ = app.notification() - .builder() - .action_type_id("test") - .body("Portmaster User Interface is running in the background") - .icon("portmaster") - .show(); + let _ = app + .notification() + .builder() + .body("Portmaster User Interface is running in the background") + .icon("portmaster") + .show(); } + start_websocket_thread(app.handle(), handle_notifications); + Ok(()) }) .any_thread() @@ -258,10 +429,11 @@ fn main() { } _ => {} } - } + }, + RunEvent::ExitRequested { api, .. } => { api.prevent_exit(); } _ => {} - }) + }); } diff --git a/tauri-app/src-tauri/src/portapi/client.rs b/tauri-app/src-tauri/src/portapi/client.rs new file mode 100644 index 00000000..a68f261d --- /dev/null +++ b/tauri-app/src-tauri/src/portapi/client.rs @@ -0,0 +1,151 @@ +use std::{collections::HashMap, ops::Deref, sync::{atomic::{AtomicUsize, Ordering, AtomicBool}, Arc}}; +use futures_util::{SinkExt, StreamExt}; +use tokio::sync::{RwLock, mpsc::{Sender, Receiver, channel}}; +use tokio::task::JoinHandle; +use tokio_websockets::{ClientBuilder, Error}; +use http::Uri; + +use super::types::*; +use super::message::*; + +struct Command { + msg: Message, + response: Sender +} + +#[derive(Clone)] +pub struct PortAPI{ + dispatch: Sender, +} + +type SubscriberMap = RwLock>>; + +pub async fn connect(uri: &str) -> Result { + let parsed = match uri.parse::() { + Ok(u) => u, + Err(e) => { + return Err(Error::NoUriConfigured) // TODO(ppacher): fix the return error type. + } + }; + + let (mut client, _) = ClientBuilder::from_uri(parsed).connect().await?; + let (tx, mut dispatch) = channel::(64); + + tokio::spawn(async move { + let subscribers: SubscriberMap = RwLock::new(HashMap::new()); + let next_id = AtomicUsize::new(0); + + loop { + tokio::select! { + Some(msg) = client.next() => { + match msg { + Err(err) => { + eprintln!("failed to receive frame from websocket: {}", err); + + return; + }, + Ok(msg) => { + let text = unsafe { + std::str::from_utf8_unchecked(msg.as_payload()) + }; + + #[cfg(debug_assertions)] + eprintln!("Received websocket frame: {}", text); + + match text.parse::() { + Ok(msg) => { + let id = msg.id; + let map = subscribers + .read() + .await; + + if let Some(sub) = map.get(&id) { + let res: Result = msg.try_into(); + match res { + Ok(response) => { + if let Err(_) = sub.send(response).await { + // The receiver side has been closed already, + // drop the read lock and remove the subscriber + // from our hashmap + drop(map); + + subscribers + .write() + .await + .remove(&id); + + #[cfg(debug_assertions)] + eprintln!("subscriber for command {} closed read side", id); + } + }, + Err(err) => { + eprintln!("invalid command: {}", err); + } + } + } + }, + Err(err) => { + eprintln!("failed to deserizalize message: {}", err) + } + } + } + } + + }, + + Some(mut cmd) = dispatch.recv() => { + let id = next_id.fetch_add(1, Ordering::Relaxed); + cmd.msg.id = id; + let blob: String = cmd.msg.into(); + + #[cfg(debug_assertions)] + eprintln!("Sending websocket frame: {}", blob); + + match client.send(tokio_websockets::Message::text(blob)).await { + Ok(_) => { + subscribers + .write() + .await + .insert(id, cmd.response); + }, + Err(err) => { + eprintln!("failed to dispatch command: {}", err); + + // TODO(ppacher): we should send some error to cmd.response here. + // Otherwise, the sender of cmd might get stuck waiting for responses + // if they don't check for PortAPI.is_closed(). + + return + } + } + } + } + } + }); + + Ok(PortAPI { dispatch: tx }) +} + + +impl PortAPI { + pub async fn request(&self, r: Request) -> std::result::Result, DeserializeError> { + self.request_buffer(r, 64).await + } + + pub async fn request_buffer(&self, r: Request, buffer: usize) -> std::result::Result, DeserializeError> { + let (tx, rx) = channel(buffer); + + let msg: Message = r.try_into()?; + + let _ = self.dispatch.send(Command{ + response: tx, + msg: msg, + }).await; + + Ok(rx) + } + + pub fn is_closed(&self) -> bool { + self.dispatch.is_closed() + } +} \ No newline at end of file diff --git a/tauri-app/src-tauri/src/portapi/message.rs b/tauri-app/src-tauri/src/portapi/message.rs new file mode 100644 index 00000000..c1ec3369 --- /dev/null +++ b/tauri-app/src-tauri/src/portapi/message.rs @@ -0,0 +1,228 @@ +#[derive(Debug)] +pub enum DeserializeError { + MissingID, + InvalidID, + MissingCommand, + MissingKey, + MissingPayload, + InvalidPayload(serde_json::Error), + UnknownCommand, +} + +impl std::fmt::Display for DeserializeError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + DeserializeError::InvalidID => write!(f, "invalid id"), + DeserializeError::MissingID => write!(f, "missing id"), + DeserializeError::MissingCommand => write!(f, "missing command"), + DeserializeError::MissingKey => write!(f, "missing key"), + DeserializeError::MissingPayload => write!(f, "missing payload"), + DeserializeError::InvalidPayload(err) => write!(f, "invalid payload: {}", err.to_string()), + DeserializeError::UnknownCommand => write!(f, "unknown command"), + } + } +} + +#[derive(PartialEq, Debug, Clone)] +pub enum Payload { + JSON(String), + UNKNOWN(String), +} + +#[derive(Debug)] +pub enum ParseError { + JSON(serde_json::Error), + UNKNOWN +} + +impl std::convert::From for ParseError { + fn from(value: serde_json::Error) -> Self { + ParseError::JSON(value) + } +} + +impl Payload { + pub fn parse<'a, T>(self: &'a Self) -> std::result::Result + where + T: serde::de::Deserialize<'a> { + + match self { + Payload::JSON(blob) => Ok(serde_json::from_str::(blob.as_str())?), + Payload::UNKNOWN(_) => Err(ParseError::UNKNOWN), + } + } +} + +impl std::convert::From for Payload { + fn from(value: String) -> Payload { + let mut chars = value.chars(); + let first = chars.next(); + let rest = chars.as_str().to_string(); + + match first { + Some(c) => match c { + 'J' => Payload::JSON(rest), + _ => Payload::UNKNOWN(value), + }, + None => Payload::UNKNOWN("".to_string()) + } + } +} + +impl std::fmt::Display for Payload { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Payload::JSON(payload) => { + write!(f, "J{}", payload) + }, + Payload::UNKNOWN(payload) => { + write!(f, "{}", payload) + } + } + } +} + +#[derive(PartialEq, Debug, Clone)] +pub struct Message { + pub id: usize, + pub cmd: String, + pub key: Option, + pub payload: Option, +} + +impl std::convert::From for String { + fn from(value: Message) -> Self { + let mut result = "".to_owned(); + + result.push_str(value.id.to_string().as_str()); + result.push_str("|"); + result.push_str(&value.cmd); + + if let Some(key) = value.key { + result.push_str("|"); + result.push_str(key.as_str()); + } + + if let Some(payload) = value.payload { + result.push_str("|"); + result.push_str(payload.to_string().as_str()) + } + + result + } +} + +impl std::str::FromStr for Message { + type Err = DeserializeError; + + fn from_str(line: &str) -> Result { + let parts = line.split("|").collect::>(); + + let id = match parts.get(0) { + Some(s) => match (*s).parse::() { + Ok(id) => Ok(id), + Err(_) => Err(DeserializeError::InvalidID), + }, + None => Err(DeserializeError::MissingID), + }?; + + let cmd = match parts.get(1) { + Some(s) => Ok(*s), + None => Err(DeserializeError::MissingCommand), + }? + .to_string(); + + let key = parts.get(2) + .and_then(|key| Some(key.to_string())); + + let payload : Option = parts.get(3) + .and_then(|p| Some(p.to_string().into())); + + return Ok(Message { + id, + cmd, + key, + payload: payload + }); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde::Deserialize; + + + #[derive(Debug, PartialEq, Deserialize)] + struct Test { + a: i64, + s: String, + } + + #[test] + fn payload_to_string() { + let p = Payload::JSON("{}".to_string()); + assert_eq!(p.to_string(), "J{}"); + + let p = Payload::UNKNOWN("some unknown content".to_string()); + assert_eq!(p.to_string(), "some unknown content"); + } + + #[test] + fn payload_from_string() { + let p: Payload = "J{}".to_string().into(); + assert_eq!(p, Payload::JSON("{}".to_string())); + + let p: Payload = "some unknown content".to_string().into(); + assert_eq!(p, Payload::UNKNOWN("some unknown content".to_string())); + } + + #[test] + fn payload_parse() { + let p: Payload = "J{\"a\": 100, \"s\": \"string\"}".to_string().into(); + + let t: Test = p.parse() + .expect("Expected payload parsing to work"); + + assert_eq!(t, Test{ + a: 100, + s: "string".to_string(), + }); + } + + #[test] + fn parse_message() { + let m = "10|insert|some:key|J{}".parse::() + .expect("Expected message to parse"); + + assert_eq!(m, Message{ + id: 10, + cmd: "insert".to_string(), + key: Some("some:key".to_string()), + payload: Some(Payload::JSON("{}".to_string())), + }); + + let m = "1|done".parse::() + .expect("Expected message to parse"); + + assert_eq!(m, Message{ + id: 1, + cmd: "done".to_string(), + key: None, + payload: None + }); + + let m = "".parse::() + .expect_err("Expected parsing to fail"); + if let DeserializeError::InvalidID = m {} else { + panic!("unexpected error value: {}", m) + } + + let m = "1".parse::() + .expect_err("Expected parsing to fail"); + + if let DeserializeError::MissingCommand = m {} else { + panic!("unexpected error value: {}", m) + } + } +} diff --git a/tauri-app/src-tauri/src/portapi/mod.rs b/tauri-app/src-tauri/src/portapi/mod.rs new file mode 100644 index 00000000..b7d7006a --- /dev/null +++ b/tauri-app/src-tauri/src/portapi/mod.rs @@ -0,0 +1,4 @@ +pub mod client; +pub mod message; +pub mod types; +pub mod notification; \ No newline at end of file diff --git a/tauri-app/src-tauri/src/portapi/notification.rs b/tauri-app/src-tauri/src/portapi/notification.rs new file mode 100644 index 00000000..92d9ead8 --- /dev/null +++ b/tauri-app/src-tauri/src/portapi/notification.rs @@ -0,0 +1,80 @@ +use serde::*; + +#[derive(Serialize, Deserialize, Debug, PartialEq)] +pub struct Notification { + #[serde(alias = "EventID")] + pub event_id: String, + + #[serde(alias = "GUID")] + pub guid: String, + + #[serde(alias = "Type")] + pub notification_type: NotificationType, + + #[serde(alias = "Message")] + pub message: String, + + #[serde(alias = "Title")] + pub title: String, + #[serde(alias = "Category")] + pub category: String, + + #[serde(alias = "EventData")] + pub data: serde_json::Value, + + #[serde(alias = "Expires")] + pub expires: u64, + + #[serde(alias = "State")] + pub state: String, + + #[serde(alias = "AvailableActions")] + pub actions: Vec, + + #[serde(alias = "SelectedActionID")] + pub selected_action_id: String, + + #[serde(alias = "ShowOnSystem")] + pub show_on_system: bool, +} + +#[derive(Serialize, Deserialize, Debug, PartialEq)] +pub struct NotificationType(i32); + +pub const INFO: NotificationType = NotificationType(0); +pub const WARN: NotificationType = NotificationType(1); +pub const PROMPT: NotificationType = NotificationType(2); +pub const ERROR: NotificationType = NotificationType(3); + +#[derive(Serialize, Deserialize, Debug, PartialEq)] +pub struct Action { + #[serde(alias = "ID")] + pub id: String, + + #[serde(alias = "Text")] + pub text: String, + + #[serde(alias = "Type")] + pub action_type: String, + + #[serde(alias = "Payload")] + pub payload: serde_json::Value, +} + +impl Notification { + pub fn is_info(self) -> bool { + self.notification_type == INFO + } + + pub fn is_warning(self) -> bool { + self.notification_type == WARN + } + + pub fn is_prompt(self) -> bool { + self.notification_type == PROMPT + } + + pub fn is_error(self) -> bool { + self.notification_type == ERROR + } +} \ No newline at end of file diff --git a/tauri-app/src-tauri/src/portapi/types.rs b/tauri-app/src-tauri/src/portapi/types.rs new file mode 100644 index 00000000..c637d8d6 --- /dev/null +++ b/tauri-app/src-tauri/src/portapi/types.rs @@ -0,0 +1,172 @@ + +use super::message::*; + +#[derive(PartialEq, Debug)] +pub enum Request { + Get(String), + Query(String), + Subscribe(String), + QuerySubscribe(String), + Create(String, Payload), + Update(String, Payload), + Insert(String, Payload), + Delete(String), + Cancel, +} + +impl std::convert::TryFrom for Request { + type Error = DeserializeError; + + fn try_from(value: Message) -> Result { + match value.cmd.as_str() { + "get" => { + let key = value.key.ok_or(DeserializeError::MissingKey)?; + Ok(Request::Get(key)) + }, + "query" => { + let key = value.key.ok_or(DeserializeError::MissingKey)?; + Ok(Request::Query(key)) + }, + "sub" => { + let key = value.key.ok_or(DeserializeError::MissingKey)?; + Ok(Request::Subscribe(key)) + }, + "qsub" => { + let key = value.key.ok_or(DeserializeError::MissingKey)?; + Ok(Request::QuerySubscribe(key)) + }, + "create" => { + let key = value.key.ok_or(DeserializeError::MissingKey)?; + let payload = value.payload.ok_or(DeserializeError::MissingPayload)?; + Ok(Request::Create(key, payload)) + }, + "update" => { + let key = value.key.ok_or(DeserializeError::MissingKey)?; + let payload = value.payload.ok_or(DeserializeError::MissingPayload)?; + Ok(Request::Update(key, payload)) + }, + "insert" => { + let key = value.key.ok_or(DeserializeError::MissingKey)?; + let payload = value.payload.ok_or(DeserializeError::MissingPayload)?; + Ok(Request::Insert(key, payload)) + }, + "delete" => { + let key = value.key.ok_or(DeserializeError::MissingKey)?; + Ok(Request::Delete(key)) + }, + "cancel" => { + Ok(Request::Cancel) + }, + &_ => { + Err(DeserializeError::UnknownCommand) + } + } + } +} + +impl std::convert::TryFrom for Message { + type Error = DeserializeError; + + fn try_from(value: Request) -> Result { + match value { + Request::Get(key) => Ok(Message { id: 0, cmd: "get".to_string(), key: Some(key), payload: None }), + Request::Query(key) => Ok(Message { id: 0, cmd: "query".to_string(), key: Some(key), payload: None }), + Request::Subscribe(key) => Ok(Message { id: 0, cmd: "sub".to_string(), key: Some(key), payload: None }), + Request::QuerySubscribe(key) => Ok(Message { id: 0, cmd: "qsub".to_string(), key: Some(key), payload: None }), + Request::Create(key, value) => Ok(Message{ id: 0, cmd: "create".to_string(), key: Some(key), payload: Some(value)}), + Request::Update(key, value) => Ok(Message{ id: 0, cmd: "update".to_string(), key: Some(key), payload: Some(value)}), + Request::Insert(key, value) => Ok(Message{ id: 0, cmd: "insert".to_string(), key: Some(key), payload: Some(value)}), + Request::Delete(key) => Ok(Message { id: 0, cmd: "delete".to_string(), key: Some(key), payload: None }), + Request::Cancel => Ok(Message { id: 0, cmd: "cancel".to_string(), key: None, payload: None }), + } + } +} + +#[derive(PartialEq, Debug)] +pub enum Response { + Ok(String, Payload), + Update(String, Payload), + New(String, Payload), + Delete(String), + Success, + Error(String), + Warning(String), + Done +} + +impl std::convert::TryFrom for Response { + type Error = DeserializeError; + + fn try_from(value: Message) -> Result { + match value.cmd.as_str() { + "ok" => { + let key = value.key.ok_or(DeserializeError::MissingKey)?; + let payload = value.payload.ok_or(DeserializeError::MissingPayload)?; + + Ok(Response::Ok(key, payload)) + }, + "upd" => { + let key = value.key.ok_or(DeserializeError::MissingKey)?; + let payload = value.payload.ok_or(DeserializeError::MissingPayload)?; + + Ok(Response::Update(key, payload)) + }, + "new" => { + let key = value.key.ok_or(DeserializeError::MissingKey)?; + let payload = value.payload.ok_or(DeserializeError::MissingPayload)?; + + Ok(Response::New(key, payload)) + }, + "del" => { + let key = value.key.ok_or(DeserializeError::MissingKey)?; + + Ok(Response::Delete(key)) + }, + "success" => { + Ok(Response::Success) + }, + "error" => { + let key = value.key.ok_or(DeserializeError::MissingKey)?; + + Ok(Response::Error(key)) + }, + "warning" => { + let key = value.key.ok_or(DeserializeError::MissingKey)?; + + Ok(Response::Warning(key)) + }, + "done" => { + Ok(Response::Done) + }, + &_ => Err(DeserializeError::UnknownCommand) + } + } +} + +impl std::convert::TryFrom for Message { + type Error = DeserializeError; + + fn try_from(value: Response) -> Result { + match value { + Response::Ok(key, payload) => Ok(Message{id: 0, cmd: "ok".to_string(), key: Some(key), payload: Some(payload)}), + Response::Update(key, payload) => Ok(Message{id: 0, cmd: "upd".to_string(), key: Some(key), payload: Some(payload)}), + Response::New(key, payload) => Ok(Message{id: 0, cmd: "new".to_string(), key: Some(key), payload: Some(payload)}), + Response::Delete(key ) => Ok(Message{id: 0, cmd: "del".to_string(), key: Some(key), payload: None}), + Response::Success => Ok(Message{id: 0, cmd: "success".to_string(), key: None, payload: None}), + Response::Warning(key) => Ok(Message{id: 0, cmd: "warning".to_string(), key: Some(key), payload: None}), + Response::Error(key) => Ok(Message{id: 0, cmd: "error".to_string(), key: Some(key), payload: None}), + Response::Done => Ok(Message{id: 0, cmd: "done".to_string(), key: None, payload: None}), + } + } +} + + +#[derive(serde::Serialize, serde::Deserialize)] +#[serde(rename_all = "UPPERCASE")] +pub struct Record { + pub created: u64, + pub deleted: u64, + pub expires: u64, + pub modified: u64, + pub key: String, +} \ No newline at end of file