lib

Core libraries for Radroots
git clone https://radroots.dev/git/lib.git
Log | Files | Refs | README | LICENSE

commit cf0a4605eca6cc735b03ea92b413d1b1ec9507b1
parent edb617171cbf78ee3eb05238f43e2f346c7552ec
Author: triesap <tyson@radroots.org>
Date:   Fri,  3 Oct 2025 20:05:14 +0100

net-core: add `nostr_client` manager and relay configuration handling

Diffstat:
MCargo.lock | 1+
Mcrates/net-core/Cargo.toml | 10+++++++++-
Mcrates/net-core/src/lib.rs | 3+++
Mcrates/net-core/src/net.rs | 46++++++++++++++++++++++++++++++++++++++++++++++
Acrates/net-core/src/nostr_client.rs | 146+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
5 files changed, 205 insertions(+), 1 deletion(-)

diff --git a/Cargo.lock b/Cargo.lock @@ -1701,6 +1701,7 @@ dependencies = [ "directories", "hex", "nostr", + "nostr-sdk", "radroots-log", "secrecy", "serde", diff --git a/crates/net-core/Cargo.toml b/crates/net-core/Cargo.toml @@ -10,7 +10,14 @@ license.workspace = true default = ["std"] std = [] rt = ["dep:tokio"] -nostr-client = ["dep:nostr", "dep:secrecy", "dep:hex", "dep:tempfile", "dep:serde_json"] +nostr-client = [ +"dep:nostr", +"dep:nostr-sdk", +"dep:secrecy", +"dep:hex", +"dep:tempfile", +"dep:serde_json" +] directories = ["dep:directories"] fs-persistence = [] @@ -19,6 +26,7 @@ radroots-log = { workspace = true } directories = { workspace = true, optional = true } hex = { workspace = true, optional = true } nostr = { workspace = true, optional = true } +nostr-sdk = { workspace = true, optional = true } secrecy = { workspace = true, optional = true } serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true, optional = true } diff --git a/crates/net-core/src/lib.rs b/crates/net-core/src/lib.rs @@ -14,4 +14,7 @@ pub mod config; #[cfg(feature = "nostr-client")] pub mod keys; +#[cfg(feature = "nostr-client")] +mod nostr_client; + pub use net::{Net, NetHandle, NetInfo}; diff --git a/crates/net-core/src/net.rs b/crates/net-core/src/net.rs @@ -4,6 +4,8 @@ use std::sync::{Arc, Mutex, MutexGuard}; use crate::error::{NetError, Result}; #[cfg(feature = "nostr-client")] use crate::keys::KeysManager; +#[cfg(feature = "nostr-client")] +use crate::nostr_client::{NostrClientManager, NostrConnectionSnapshot}; #[derive(Debug, Clone, Serialize)] pub struct BuildInfo { @@ -31,6 +33,9 @@ pub struct Net { #[cfg(feature = "nostr-client")] pub keys: KeysManager, + #[cfg(feature = "nostr-client")] + pub nostr: Option<NostrClientManager>, + #[cfg(feature = "rt")] pub rt: Option<tokio::runtime::Runtime>, } @@ -51,6 +56,8 @@ impl Net { config: cfg, #[cfg(feature = "nostr-client")] keys: KeysManager::default(), + #[cfg(feature = "nostr-client")] + nostr: None, #[cfg(feature = "rt")] rt: None, } @@ -78,6 +85,45 @@ impl Net { self.rt = Some(rt); Ok(()) } + + #[cfg(feature = "nostr-client")] + pub fn nostr_set_default_relays(&mut self, urls: &[String]) -> Result<()> { + if self.nostr.is_none() { + let keys = self.keys.require()?; + let rt = self + .rt + .as_ref() + .ok_or_else(|| NetError::msg("tokio runtime missing"))?; + self.nostr = Some(NostrClientManager::new(keys.clone(), rt.handle().clone())); + } + if let Some(n) = &self.nostr { + n.set_relays(urls); + } + Ok(()) + } + + #[cfg(feature = "nostr-client")] + pub fn nostr_connect_if_key_present(&mut self) -> Result<()> { + if self.keys.state.loaded { + let rt = self + .rt + .as_ref() + .ok_or_else(|| NetError::msg("tokio runtime missing"))?; + if self.nostr.is_none() { + let keys = self.keys.require()?; + self.nostr = Some(NostrClientManager::new(keys.clone(), rt.handle().clone())); + } + if let Some(n) = &self.nostr { + n.connect(); + } + } + Ok(()) + } + + #[cfg(feature = "nostr-client")] + pub fn nostr_connection_snapshot(&self) -> Option<NostrConnectionSnapshot> { + self.nostr.as_ref().map(|n| n.snapshot()) + } } #[derive(Clone)] diff --git a/crates/net-core/src/nostr_client.rs b/crates/net-core/src/nostr_client.rs @@ -0,0 +1,146 @@ +#[cfg(feature = "nostr-client")] +use std::collections::HashMap; +#[cfg(feature = "nostr-client")] +use std::sync::{Arc, Mutex}; + +#[cfg(feature = "nostr-client")] +use nostr_sdk::prelude::*; +#[cfg(feature = "nostr-client")] +use tokio::runtime::Handle; +#[cfg(feature = "nostr-client")] +use tracing::{error, info}; + +#[cfg(feature = "nostr-client")] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Light { + Red, + Yellow, + Green, +} + +#[cfg(feature = "nostr-client")] +#[derive(Debug, Clone)] +pub struct NostrConnectionSnapshot { + pub light: Light, + pub connected: usize, + pub connecting: usize, + pub last_error: Option<String>, +} + +#[cfg(feature = "nostr-client")] +#[derive(Clone)] +pub struct NostrClientManager { + inner: Arc<Inner>, +} + +#[cfg(feature = "nostr-client")] +struct Inner { + client: Client, + relays: Arc<Mutex<Vec<String>>>, + statuses: Arc<Mutex<HashMap<RelayUrl, RelayStatus>>>, + last_error: Arc<Mutex<Option<String>>>, + rt: Handle, +} + +#[cfg(feature = "nostr-client")] +impl NostrClientManager { + pub fn new(keys: nostr::Keys, rt: Handle) -> Self { + let monitor = Monitor::new(2048); + let client = Client::builder().signer(keys).monitor(monitor).build(); + + let inner = Arc::new(Inner { + client, + relays: Arc::new(Mutex::new(Vec::new())), + statuses: Arc::new(Mutex::new(HashMap::new())), + last_error: Arc::new(Mutex::new(None)), + rt, + }); + + let this = Self { + inner: inner.clone(), + }; + this.spawn_status_watcher(); + this + } + + pub fn set_relays(&self, urls: &[String]) { + if let Ok(mut guard) = self.inner.relays.lock() { + *guard = urls.to_vec(); + } + } + + pub fn connect(&self) { + let inner = self.inner.clone(); + inner.rt.spawn(async move { + let urls = { + let g = inner.relays.lock().ok(); + g.map(|v| v.clone()).unwrap_or_default() + }; + if urls.is_empty() { + info!("no relays configured; using default wss://relay.damus.io"); + } + let effective = if urls.is_empty() { + vec!["wss://relay.damus.io".to_string()] + } else { + urls + }; + + for u in &effective { + match inner.client.add_relay(u).await { + Ok(_) => {} + Err(e) => { + *inner.last_error.lock().unwrap() = Some(format!("add_relay {u}: {e}")); + error!("add_relay failed for {u}: {e}"); + } + } + } + + inner.client.connect().await; + }); + } + + pub fn snapshot(&self) -> NostrConnectionSnapshot { + let map = self.inner.statuses.lock().ok().cloned().unwrap_or_default(); + let mut connected = 0usize; + let mut connecting = 0usize; + for (_url, st) in map.iter() { + match st { + RelayStatus::Connected => connected += 1, + RelayStatus::Connecting => connecting += 1, + _ => {} + } + } + let light = if connected > 0 { + Light::Green + } else if connecting > 0 { + Light::Yellow + } else { + Light::Red + }; + let last_error = self.inner.last_error.lock().ok().and_then(|e| e.clone()); + NostrConnectionSnapshot { + light, + connected, + connecting, + last_error, + } + } + + fn spawn_status_watcher(&self) { + let inner = self.inner.clone(); + inner.rt.spawn(async move { + if let Some(m) = inner.client.monitor() { + let mut rx = m.subscribe(); + while let Ok(notification) = rx.recv().await { + if let MonitorNotification::StatusChanged { relay_url, status } = notification { + { + let mut map = inner.statuses.lock().unwrap(); + map.insert(relay_url.clone(), status); + } + info!("relay status changed {} -> {:?}", relay_url, status); + } + } + } + }); + } +}