use std::borrow::Cow; use std::cell::Cell; use std::collections::HashSet; use std::hash::{Hash, Hasher}; use std::rc::Rc; use anyhow::{anyhow, Error}; use gpui::{ App, AppContext, Context, Entity, Global, IntoElement, ParentElement, SharedString, Styled, Subscription, Task, Window, }; use nostr_sdk::prelude::*; use settings::AppSettings; use smallvec::{smallvec, SmallVec}; use state::{tracker, NostrRegistry}; use theme::ActiveTheme; use ui::button::{Button, ButtonVariants}; use ui::notification::Notification; use ui::{v_flex, ContextModal, Disableable, IconName, Sizable}; const AUTH_MESSAGE: &str = "Approve the authentication request to allow Coop to continue sending or receiving events."; pub fn init(window: &mut Window, cx: &mut App) { RelayAuth::set_global(cx.new(|cx| RelayAuth::new(window, cx)), cx); } /// Authentication request #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] pub struct AuthRequest { pub url: RelayUrl, pub challenge: String, } impl Hash for AuthRequest { fn hash(&self, state: &mut H) { self.challenge.hash(state); } } impl AuthRequest { pub fn new(challenge: impl Into, url: RelayUrl) -> Self { Self { challenge: challenge.into(), url, } } } struct GlobalRelayAuth(Entity); impl Global for GlobalRelayAuth {} // Relay authentication #[derive(Debug)] pub struct RelayAuth { /// Entity for managing auth requests requests: HashSet, /// Event subscriptions _subscriptions: SmallVec<[Subscription; 1]>, /// Tasks for asynchronous operations _tasks: SmallVec<[Task<()>; 1]>, } impl RelayAuth { /// Retrieve the global relay auth state pub fn global(cx: &App) -> Entity { cx.global::().0.clone() } /// Set the global relay auth instance fn set_global(state: Entity, cx: &mut App) { cx.set_global(GlobalRelayAuth(state)); } /// Create a new relay auth instance fn new(window: &mut Window, cx: &mut Context) -> Self { let nostr = NostrRegistry::global(cx); let client = nostr.read(cx).client(); // Get the current entity let entity = cx.entity(); // Channel for communication between nostr and gpui let (tx, rx) = flume::bounded::(100); let mut subscriptions = smallvec![]; let mut tasks = smallvec![]; subscriptions.push( // Observe the current state cx.observe_in(&entity, window, |this, _, window, cx| { let settings = AppSettings::global(cx); let auto_auth = AppSettings::get_auto_auth(cx); for req in this.requests.clone().into_iter() { let is_authenticated = settings.read(cx).is_authenticated(&req.url); if auto_auth && is_authenticated { // Automatically authenticate if the relay is authenticated before this.response(req, window, cx); } else { // Otherwise open the auth request popup this.ask_for_approval(req, window, cx); } } }), ); tasks.push( // Handle nostr notifications cx.background_spawn(async move { Self::handle_notifications(&client, &tx).await }), ); tasks.push( // Update GPUI states cx.spawn(async move |this, cx| { while let Ok(request) = rx.recv_async().await { this.update(cx, |this, cx| { this.add_request(request, cx); }) .ok(); } }), ); Self { requests: HashSet::new(), _subscriptions: subscriptions, _tasks: tasks, } } // Handle nostr notifications async fn handle_notifications(client: &Client, tx: &flume::Sender) { let mut notifications = client.notifications(); while let Ok(notification) = notifications.recv().await { if let RelayPoolNotification::Message { message: RelayMessage::Auth { challenge }, relay_url, } = notification { let request = AuthRequest::new(challenge, relay_url); if let Err(e) = tx.send_async(request).await { log::error!("Failed to send auth request: {}", e); } } } } /// Add a new authentication request. fn add_request(&mut self, request: AuthRequest, cx: &mut Context) { self.requests.insert(request); cx.notify(); } /// Get the number of pending requests. pub fn pending_requests(&self, _cx: &App) -> usize { self.requests.len() } /// Reask for approval for all pending requests. pub fn re_ask(&mut self, window: &mut Window, cx: &mut Context) { for request in self.requests.clone().into_iter() { self.ask_for_approval(request, window, cx); } } /// Respond to an authentication request. fn response(&mut self, req: AuthRequest, window: &mut Window, cx: &mut Context) { let settings = AppSettings::global(cx); let nostr = NostrRegistry::global(cx); let client = nostr.read(cx).client(); let challenge = req.challenge.to_owned(); let url = req.url.to_owned(); let challenge_clone = challenge.clone(); let url_clone = url.clone(); let task: Task> = cx.background_spawn(async move { let signer = client.signer().await?; // Construct event let event: Event = EventBuilder::auth(challenge_clone, url_clone.clone()) .sign(&signer) .await?; // Get the event ID let id = event.id; // Get the relay let relay = client.pool().relay(url_clone).await?; let relay_url = relay.url(); // Subscribe to notifications let mut notifications = relay.notifications(); // Send the AUTH message relay.send_msg(ClientMessage::Auth(Cow::Borrowed(&event)))?; while let Ok(notification) = notifications.recv().await { match notification { RelayNotification::Message { message: RelayMessage::Ok { event_id, .. }, } => { if id == event_id { // Re-subscribe to previous subscription relay.resubscribe().await?; // Get all pending events that need to be resent let mut tracker = tracker().write().await; let ids: Vec = tracker.pending_resend(relay_url); for id in ids.into_iter() { if let Some(event) = client.database().event_by_id(&id).await? { let event_id = relay.send_event(&event).await?; tracker.sent(event_id); } } return Ok(()); } } RelayNotification::AuthenticationFailed => break, RelayNotification::Shutdown => break, _ => {} } } Err(anyhow!("Authentication failed")) }); self._tasks.push( // Handle response in the background cx.spawn_in(window, async move |this, cx| { match task.await { Ok(_) => { this.update_in(cx, |this, window, cx| { // Clear the current notification window.clear_notification_by_id(SharedString::from(&challenge), cx); // Push a new notification window.push_notification(format!("{url} has been authenticated"), cx); // Save the authenticated relay to automatically authenticate future requests settings.update(cx, |this, cx| { this.push_relay(&url, cx); }); // Remove the challenge from the list of pending authentications this.requests.remove(&req); cx.notify(); }) .expect("Entity has been released"); } Err(e) => { this.update_in(cx, |_, window, cx| { window.push_notification(Notification::error(e.to_string()), cx); }) .expect("Entity has been released"); } }; }), ); } /// Push a popup to approve the authentication request. fn ask_for_approval(&mut self, req: AuthRequest, window: &mut Window, cx: &mut Context) { let url = SharedString::from(req.url.clone().to_string()); let entity = cx.entity().downgrade(); let loading = Rc::new(Cell::new(false)); let note = Notification::new() .custom_id(SharedString::from(&req.challenge)) .autohide(false) .icon(IconName::Info) .title(SharedString::from("Authentication Required")) .content(move |_window, cx| { v_flex() .gap_2() .text_sm() .child(SharedString::from(AUTH_MESSAGE)) .child( v_flex() .py_1() .px_1p5() .rounded_sm() .text_xs() .bg(cx.theme().warning_background) .text_color(cx.theme().warning_foreground) .child(url.clone()), ) .into_any_element() }) .action(move |_window, _cx| { let entity = entity.clone(); let req = req.clone(); Button::new("approve") .label("Approve") .small() .primary() .loading(loading.get()) .disabled(loading.get()) .on_click({ let loading = Rc::clone(&loading); move |_ev, window, cx| { // Set loading state to true loading.set(true); // Process to approve the request entity .update(cx, |this, cx| { this.response(req.clone(), window, cx); }) .expect("Entity has been released"); } }) }); // Push the notification to the current window window.push_notification(note, cx); // Focus the window if it's not active if !window.is_window_hovered() { window.activate_window(); } } }