//! [`RelayClient`] — the public façade over the networking layer. //! //! Concrete struct with inherent async methods (no trait): there is exactly one //! implementation and the consumer wants a thin, direct handle. The client owns //! the WS loop lifecycle and the broadcast event channel; all transport/crypto //! logic lives in [`crate::state::RelayState`], shared behind an `Arc`. use std::sync::Arc; use anyhow::Result; use sqlx::SqlitePool; use tokio::sync::{broadcast, mpsc, Mutex}; use tokio::task::JoinHandle; use tokio_util::sync::CancellationToken; use tracing::info; use crate::config::RelayClientConfig; use crate::db::{self, ClientRow}; use crate::events::RelayEvent; use crate::identity::Identity; use crate::pairing::{QrCodeData, SessionState, StartedPairing}; use crate::state::{RelayState, StateConfig}; use crate::ws; /// How many events the broadcast channel buffers before lagging slow consumers. const EVENT_CHANNEL_CAP: usize = 256; /// A standalone, payload-agnostic relay client (agent role). /// /// Lifecycle: [`new`](Self::new) derives the identity and initializes the DB but /// does **not** connect; [`start`](Self::start) spawns the reconnecting WS loop; /// [`shutdown`](Self::shutdown) cancels it and joins. Inbound traffic and /// lifecycle transitions are delivered via [`events`](Self::events). pub struct RelayClient { state: Arc, /// Token cancelling the WS loop; `Some` only while started. cancel: Mutex>, handle: Mutex>>, } impl RelayClient { /// Derive the identity from the seed source, ensure the `relay_clients` /// table exists, and build the client. Does NOT connect — call /// [`start`](Self::start). pub async fn new(db: Arc, config: RelayClientConfig) -> Result { db::init(&db).await?; let identity = Identity::from_source(&config.seed)?; info!( crate_name = "skald-relay-client", namespace = identity.namespace_id_hex(), "relay client identity loaded" ); let (events_tx, _) = broadcast::channel(EVENT_CHANNEL_CAP); let state = Arc::new(RelayState::new( identity, db, StateConfig { relay_url: config.relay_url, pairing_ttl: config.pairing_ttl }, events_tx, )); Ok(Self { state, cancel: Mutex::new(None), handle: Mutex::new(None), }) } /// Spawn the reconnecting WS loop. No-op (stays idle) if `relay_url` is /// empty. Wires a fresh outbound channel into the state. Calling `start` /// while already started replaces the loop (the caller should `shutdown` /// first; this guards by cancelling any prior token). pub async fn start(&self) -> Result<()> { // Cancel any previous loop defensively. if let Some(c) = self.cancel.lock().await.take() { c.cancel(); } if let Some(h) = self.handle.lock().await.take() { let _ = h.await; } let cancel = CancellationToken::new(); let (out_tx, out_rx) = mpsc::unbounded_channel::>(); self.state.set_outbound(out_tx); if self.state.relay_url().is_empty() { // Idle: no WS loop, but the outbound sender is set so pairing/send // calls fail loudly ("WS not started") rather than panic. *self.cancel.lock().await = Some(cancel); return Ok(()); } let st = Arc::clone(&self.state); let c = cancel.clone(); let handle = tokio::spawn(async move { ws::run_loop(st, out_rx, c).await; }); *self.cancel.lock().await = Some(cancel); *self.handle.lock().await = Some(handle); Ok(()) } /// Cancel the WS loop, clear the outbound sender, and join the task. pub async fn shutdown(&self) { if let Some(c) = self.cancel.lock().await.take() { c.cancel(); } self.state.clear_outbound(); self.state.set_connected(false); if let Some(h) = self.handle.lock().await.take() { let _ = h.await; } } /// Subscribe to the client's [`RelayEvent`] stream. Each call returns a new /// receiver; a slow consumer lags (`RecvError::Lagged`) rather than blocking /// the WS loop. pub fn events(&self) -> broadcast::Receiver { self.state.subscribe() } /// Seal `payload` to one authorized client and queue the `message` frame. /// `live=true` routes-or-fails (peer online by construction); `live=false` /// stores-and-forwards + pushes for offline phones. pub async fn send(&self, dest: &[u8; 32], payload: &[u8], live: bool) -> Result<()> { self.state.send_to_client(dest, payload, live).await } // ── Pipe (relayed byte-stream, docs/relay/pipe.md) ───────────────────────── /// Open an end-to-end-encrypted byte pipe to `peer` (a namespace member). /// Brokers the rendezvous over the E2E channel (`pipe_invite`/`pipe_accept`, /// ephemeral DH → PFS) and returns the live data-plane channel. pub async fn open_pipe( &self, peer: &[u8; 32], stream_type: &str, headers: std::collections::BTreeMap, ) -> Result { self.state.open_pipe(peer, stream_type, headers).await } /// Subscribe to inbound pipe invites (responder side). Each invite is an /// [`IncomingPipe`](crate::pipe::IncomingPipe); call [`accept_pipe`](Self::accept_pipe) /// or [`reject_pipe`](Self::reject_pipe) on it. Single-consumer expected. pub fn incoming_pipes(&self) -> broadcast::Receiver { self.state.incoming_pipes() } /// Accept an inbound invite → returns the live data-plane channel. pub async fn accept_pipe( &self, incoming: &crate::pipe::IncomingPipe, ) -> Result { self.state.accept_pipe(incoming).await } /// Decline an inbound invite. pub async fn reject_pipe( &self, incoming: &crate::pipe::IncomingPipe, reason: &str, ) -> Result<()> { self.state.reject_pipe(incoming, reason).await } // ── Pairing ─────────────────────────────────────────────────────────────── /// Open the pairing window (single-window, latest-wins). `ttl_secs == 0` /// uses the configured default. pub async fn start_pairing(&self, ttl_secs: u32) -> Result { let ttl = if ttl_secs == 0 { self.state.default_pairing_ttl() } else { ttl_secs }; self.state.start_pairing(ttl).await } /// Close the pairing window locally and tell the relay. pub async fn stop_pairing(&self) -> Result<()> { self.state.stop_pairing().await } /// Resolve a pairing `code` to its QR payload + lifecycle state (QR router). pub fn lookup_pairing(&self, code: &str) -> Option<(QrCodeData, SessionState)> { self.state.lookup_pairing(code) } /// The configured default pairing TTL (seconds). pub fn default_pairing_ttl(&self) -> u32 { self.state.default_pairing_ttl() } // ── Device registry / authorization ─────────────────────────────────────── /// Mark a Pending device Authorized and push the updated authorize set. /// Payload-agnostic: it does not broadcast any application snapshot — the /// consumer does that after authorizing if needed. pub async fn authorize(&self, ed25519_pub: &[u8; 32]) -> Result<()> { self.state.authorize(ed25519_pub).await } /// Revoke a device (delete keys/counters, re-push the authorize set without /// it). Emits [`RelayEvent::ClientRevoked`]. pub async fn revoke(&self, ed25519_pub: &[u8; 32]) -> Result<()> { self.state.revoke(ed25519_pub).await } /// Remove every device and push an empty authorize set. Emits one /// `ClientRevoked` per removed device. pub async fn clear_all(&self) -> Result<()> { self.state.clear_all().await } /// All known devices (pending + authorized), ordered by `authorized_at`. pub async fn list_clients(&self) -> Vec { self.state.list_clients().await } /// Persist the `device_info` JSON for a device (the consumer decodes the /// `hello` payload and hands the raw JSON here). pub async fn set_device_info(&self, ed25519_pub: &[u8; 32], json: &str) -> Result<()> { self.state.set_device_info(ed25519_pub, json).await } // ── Identity accessors ──────────────────────────────────────────────────── pub fn agent_ed25519_pub(&self) -> [u8; 32] { self.state.identity().ed25519_pub() } pub fn agent_x25519_pub(&self) -> [u8; 32] { self.state.identity().x25519_pub() } pub fn namespace_id_hex(&self) -> String { self.state.identity().namespace_id_hex().to_string() } pub fn is_connected(&self) -> bool { self.state.is_connected() } }