RPC layer (quicprochat-rpc): - RpcClient now uses tokio::sync::Mutex<Connection> for safe reconnection - Auto-reconnect with exponential backoff + jitter on retriable errors - QUIC-level keepalive via quinn TransportConfig - subscribe_push() returns Option<PushFrame> with None sentinel on break - RpcError::is_retriable() classifies transient vs permanent errors - ConnectionState enum (Connected/Reconnecting/Disconnected) with Display - Configurable max_retries, base_delay, max_backoff, keepalive_secs SDK layer (quicprochat-sdk): - QpqClient wraps RpcClient in Arc for safe heartbeat task sharing - start_heartbeat() spawns background task checking connection every 30s - connection_state() exposes RPC-layer state to UI - Reconnecting event added to ClientEvent enum - disconnect() aborts heartbeat before closing connection Client UI (quicprochat-client): - TUI status bar shows Connected/Reconnecting.../Offline with color - TUI handles Reconnecting event with attempt count display - REPL event listener prints connection state changes - REPL /status shows connection state instead of bool - Both TUI and REPL call start_heartbeat() on startup
422 lines
15 KiB
Rust
422 lines
15 KiB
Rust
//! `QpqClient` — the main entry point for the quicprochat SDK.
|
|
//!
|
|
//! Provides connection lifecycle management with auto-reconnect, heartbeat
|
|
//! monitoring, push subscription recovery, and a connection state machine.
|
|
|
|
use std::sync::Arc;
|
|
use std::time::Duration;
|
|
|
|
use tokio::sync::broadcast;
|
|
use tracing::info;
|
|
|
|
pub use quicprochat_rpc::client::ConnectionState;
|
|
|
|
use crate::config::ClientConfig;
|
|
use crate::conversation::ConversationStore;
|
|
use crate::error::SdkError;
|
|
use crate::events::ClientEvent;
|
|
|
|
/// Default heartbeat interval for proactive dead-connection detection.
|
|
const HEARTBEAT_INTERVAL_SECS: u64 = 30;
|
|
|
|
/// The main SDK client. All state is contained within this struct — no globals.
|
|
pub struct QpqClient {
|
|
config: ClientConfig,
|
|
rpc: Option<Arc<quicprochat_rpc::client::RpcClient>>,
|
|
event_tx: broadcast::Sender<ClientEvent>,
|
|
/// The authenticated username, if logged in.
|
|
username: Option<String>,
|
|
/// The local identity key (Ed25519 public key, 32 bytes).
|
|
identity_key: Option<Vec<u8>>,
|
|
/// Session token from OPAQUE login.
|
|
session_token: Option<Vec<u8>>,
|
|
/// Local conversation store (SQLCipher).
|
|
conv_store: Option<ConversationStore>,
|
|
/// Device ID for multi-device support.
|
|
device_id: Option<Vec<u8>>,
|
|
/// Handle to the heartbeat background task (if running).
|
|
heartbeat_handle: Option<tokio::task::JoinHandle<()>>,
|
|
}
|
|
|
|
impl QpqClient {
|
|
/// Create a new client with the given configuration.
|
|
pub fn new(config: ClientConfig) -> Self {
|
|
let (event_tx, _) = broadcast::channel(256);
|
|
Self {
|
|
config,
|
|
rpc: None,
|
|
event_tx,
|
|
username: None,
|
|
identity_key: None,
|
|
session_token: None,
|
|
conv_store: None,
|
|
device_id: None,
|
|
heartbeat_handle: None,
|
|
}
|
|
}
|
|
|
|
/// Connect to the server.
|
|
pub async fn connect(&mut self) -> Result<(), SdkError> {
|
|
let tls_config = build_tls_config(self.config.accept_invalid_certs)?;
|
|
|
|
let rpc_config = quicprochat_rpc::client::RpcClientConfig {
|
|
server_addr: self.config.server_addr,
|
|
server_name: self.config.server_name.clone(),
|
|
tls_config: Arc::new(tls_config),
|
|
alpn: self.config.alpn.clone(),
|
|
session_token: self.session_token.clone(),
|
|
max_retries: 0, // use defaults
|
|
base_delay_ms: 0,
|
|
max_backoff_ms: 0,
|
|
keepalive_secs: 0,
|
|
};
|
|
|
|
let client = quicprochat_rpc::client::RpcClient::connect(rpc_config).await?;
|
|
self.rpc = Some(Arc::new(client));
|
|
|
|
// Open local conversation store.
|
|
let store = ConversationStore::open(
|
|
&self.config.db_path,
|
|
self.config.db_password.as_deref(),
|
|
)?;
|
|
self.conv_store = Some(store);
|
|
|
|
self.emit(ClientEvent::Connected);
|
|
info!(server = %self.config.server_addr, "connected");
|
|
Ok(())
|
|
}
|
|
|
|
/// Subscribe to client events. Returns a broadcast receiver.
|
|
pub fn subscribe(&self) -> broadcast::Receiver<ClientEvent> {
|
|
self.event_tx.subscribe()
|
|
}
|
|
|
|
/// Get the authenticated username, if logged in.
|
|
pub fn username(&self) -> Option<&str> {
|
|
self.username.as_deref()
|
|
}
|
|
|
|
/// Get the local identity key.
|
|
pub fn identity_key(&self) -> Option<&[u8]> {
|
|
self.identity_key.as_deref()
|
|
}
|
|
|
|
/// Whether the client is connected.
|
|
pub fn is_connected(&self) -> bool {
|
|
self.rpc.is_some()
|
|
}
|
|
|
|
/// Whether the client is authenticated.
|
|
pub fn is_authenticated(&self) -> bool {
|
|
self.session_token.is_some()
|
|
}
|
|
|
|
/// Get the server address as a string (e.g. "127.0.0.1:7000").
|
|
pub fn server_addr_string(&self) -> String {
|
|
self.config.server_addr.to_string()
|
|
}
|
|
|
|
/// Get the state file path from the client configuration.
|
|
pub fn config_state_path(&self) -> std::path::PathBuf {
|
|
self.config.state_path.clone()
|
|
}
|
|
|
|
/// Get a reference to the RPC client (for direct calls).
|
|
pub fn rpc(&self) -> Result<&quicprochat_rpc::client::RpcClient, SdkError> {
|
|
self.rpc.as_deref().ok_or(SdkError::NotConnected)
|
|
}
|
|
|
|
/// Get a reference to the conversation store.
|
|
pub fn conversations(&self) -> Result<&ConversationStore, SdkError> {
|
|
self.conv_store
|
|
.as_ref()
|
|
.ok_or(SdkError::NotConnected)
|
|
}
|
|
|
|
/// Get the current connection state from the RPC layer.
|
|
pub fn connection_state(&self) -> ConnectionState {
|
|
match &self.rpc {
|
|
Some(rpc) => rpc.connection_state(),
|
|
None => ConnectionState::Disconnected,
|
|
}
|
|
}
|
|
|
|
/// Start a background heartbeat task that monitors the connection and
|
|
/// emits events on state changes. Checks QUIC connection liveness every
|
|
/// 30 seconds. If a dead connection is detected, emits a `Disconnected`
|
|
/// event.
|
|
///
|
|
/// Call this after `connect()` to enable proactive dead-connection detection.
|
|
pub fn start_heartbeat(&mut self) {
|
|
// Cancel any existing heartbeat.
|
|
if let Some(h) = self.heartbeat_handle.take() {
|
|
h.abort();
|
|
}
|
|
|
|
let rpc = match self.rpc.clone() {
|
|
Some(rpc) => rpc,
|
|
None => return,
|
|
};
|
|
|
|
let event_tx = self.event_tx.clone();
|
|
|
|
self.heartbeat_handle = Some(tokio::spawn(async move {
|
|
let mut last_state = ConnectionState::Connected;
|
|
loop {
|
|
tokio::time::sleep(Duration::from_secs(HEARTBEAT_INTERVAL_SECS)).await;
|
|
|
|
let alive = rpc.is_alive();
|
|
let current_state = rpc.connection_state();
|
|
|
|
if current_state != last_state {
|
|
match current_state {
|
|
ConnectionState::Connected => {
|
|
let _ = event_tx.send(ClientEvent::Connected);
|
|
}
|
|
ConnectionState::Reconnecting { attempt } => {
|
|
let _ = event_tx.send(ClientEvent::Reconnecting { attempt });
|
|
}
|
|
ConnectionState::Disconnected => {
|
|
let _ = event_tx.send(ClientEvent::Disconnected {
|
|
reason: "connection lost".into(),
|
|
});
|
|
}
|
|
}
|
|
last_state = current_state;
|
|
} else if !alive && last_state == ConnectionState::Connected {
|
|
// Connection died but RPC layer hasn't noticed yet.
|
|
let _ = event_tx.send(ClientEvent::Disconnected {
|
|
reason: "heartbeat: connection dead".into(),
|
|
});
|
|
last_state = ConnectionState::Disconnected;
|
|
}
|
|
}
|
|
}));
|
|
}
|
|
|
|
/// Register a new user account via OPAQUE.
|
|
pub async fn register(&mut self, username: &str, password: &str) -> Result<(), SdkError> {
|
|
let rpc = self.rpc.as_deref().ok_or(SdkError::NotConnected)?;
|
|
let keypair = crate::auth::opaque_register(rpc, username, password, None).await?;
|
|
self.identity_key = Some(keypair.public_key_bytes().to_vec());
|
|
self.emit(ClientEvent::Registered {
|
|
username: username.to_string(),
|
|
});
|
|
info!(username, "registered");
|
|
Ok(())
|
|
}
|
|
|
|
/// Log in via OPAQUE and store the session token.
|
|
pub async fn login(&mut self, username: &str, password: &str) -> Result<(), SdkError> {
|
|
let identity_key = self
|
|
.identity_key
|
|
.as_ref()
|
|
.ok_or_else(|| SdkError::AuthFailed("no identity key — register or load state first".into()))?
|
|
.clone();
|
|
|
|
let rpc = self.rpc.as_deref().ok_or(SdkError::NotConnected)?;
|
|
let session_token = crate::auth::opaque_login(rpc, username, password, &identity_key).await?;
|
|
|
|
self.session_token = Some(session_token);
|
|
self.username = Some(username.to_string());
|
|
self.emit(ClientEvent::LoggedIn {
|
|
username: username.to_string(),
|
|
});
|
|
info!(username, "logged in");
|
|
Ok(())
|
|
}
|
|
|
|
/// Clear authentication state (session token, username).
|
|
pub fn logout(&mut self) -> Result<(), SdkError> {
|
|
self.session_token = None;
|
|
let username = self.username.take();
|
|
self.emit(ClientEvent::LoggedOut {
|
|
username: username.unwrap_or_default(),
|
|
});
|
|
info!("logged out");
|
|
Ok(())
|
|
}
|
|
|
|
/// Set the identity key directly (e.g. after loading from state).
|
|
pub fn set_identity_key(&mut self, key: Vec<u8>) {
|
|
self.identity_key = Some(key);
|
|
}
|
|
|
|
/// Get the session token, if authenticated.
|
|
pub fn session_token(&self) -> Option<&[u8]> {
|
|
self.session_token.as_deref()
|
|
}
|
|
|
|
// ── Multi-device ─────────────────────────────────────────────────────────
|
|
|
|
/// Set the device ID for this client.
|
|
pub fn set_device_id(&mut self, device_id: Vec<u8>) {
|
|
self.device_id = Some(device_id);
|
|
}
|
|
|
|
/// Get the current device ID, if set.
|
|
pub fn device_id(&self) -> Option<&[u8]> {
|
|
self.device_id.as_deref()
|
|
}
|
|
|
|
/// Register this device with the server.
|
|
pub async fn register_device(
|
|
&mut self,
|
|
device_id: &[u8],
|
|
device_name: &str,
|
|
) -> Result<bool, SdkError> {
|
|
let rpc = self.rpc.as_deref().ok_or(SdkError::NotConnected)?;
|
|
let newly_registered =
|
|
crate::devices::register_device(rpc, device_id, device_name).await?;
|
|
self.device_id = Some(device_id.to_vec());
|
|
Ok(newly_registered)
|
|
}
|
|
|
|
/// List all registered devices for this identity.
|
|
pub async fn list_devices(&self) -> Result<Vec<crate::devices::DeviceInfo>, SdkError> {
|
|
let rpc = self.rpc.as_deref().ok_or(SdkError::NotConnected)?;
|
|
crate::devices::list_devices(rpc).await
|
|
}
|
|
|
|
/// Revoke (remove) a registered device.
|
|
pub async fn revoke_device(&self, device_id: &[u8]) -> Result<bool, SdkError> {
|
|
let rpc = self.rpc.as_deref().ok_or(SdkError::NotConnected)?;
|
|
crate::devices::revoke_device(rpc, device_id).await
|
|
}
|
|
|
|
// ── Moderation (client-side) ────────────────────────────────────────────
|
|
|
|
/// Block a user locally. Their messages will be hidden from display.
|
|
pub fn block_user(&self, identity_key: &[u8], reason: &str) -> Result<(), SdkError> {
|
|
let store = self.conversations()?;
|
|
store
|
|
.block_user(identity_key, reason)
|
|
.map_err(|e| SdkError::Storage(e.to_string()))?;
|
|
info!(identity = %hex::encode(identity_key), "user blocked");
|
|
Ok(())
|
|
}
|
|
|
|
/// Unblock a user locally.
|
|
pub fn unblock_user(&self, identity_key: &[u8]) -> Result<bool, SdkError> {
|
|
let store = self.conversations()?;
|
|
let removed = store
|
|
.unblock_user(identity_key)
|
|
.map_err(|e| SdkError::Storage(e.to_string()))?;
|
|
if removed {
|
|
info!(identity = %hex::encode(identity_key), "user unblocked");
|
|
}
|
|
Ok(removed)
|
|
}
|
|
|
|
/// Check if a user is blocked locally.
|
|
pub fn is_blocked(&self, identity_key: &[u8]) -> Result<bool, SdkError> {
|
|
let store = self.conversations()?;
|
|
store
|
|
.is_blocked(identity_key)
|
|
.map_err(|e| SdkError::Storage(e.to_string()))
|
|
}
|
|
|
|
/// List all locally blocked users.
|
|
pub fn list_blocked(&self) -> Result<Vec<crate::conversation::BlockedUser>, SdkError> {
|
|
let store = self.conversations()?;
|
|
store
|
|
.list_blocked()
|
|
.map_err(|e| SdkError::Storage(e.to_string()))
|
|
}
|
|
|
|
/// Disconnect from the server gracefully.
|
|
///
|
|
/// Stops the heartbeat task and closes the QUIC connection. Emits a
|
|
/// `Disconnected` event.
|
|
pub fn disconnect(&mut self) {
|
|
// Stop heartbeat first.
|
|
if let Some(h) = self.heartbeat_handle.take() {
|
|
h.abort();
|
|
}
|
|
if let Some(rpc) = self.rpc.take() {
|
|
rpc.close();
|
|
self.emit(ClientEvent::Disconnected {
|
|
reason: "client closed".into(),
|
|
});
|
|
}
|
|
}
|
|
|
|
fn emit(&self, event: ClientEvent) {
|
|
// Ignore send errors (no subscribers).
|
|
let _ = self.event_tx.send(event);
|
|
}
|
|
}
|
|
|
|
impl Drop for QpqClient {
|
|
fn drop(&mut self) {
|
|
self.disconnect();
|
|
}
|
|
}
|
|
|
|
fn build_tls_config(accept_invalid_certs: bool) -> Result<rustls::ClientConfig, SdkError> {
|
|
let builder = rustls::ClientConfig::builder();
|
|
|
|
if accept_invalid_certs {
|
|
let config = builder
|
|
.dangerous()
|
|
.with_custom_certificate_verifier(Arc::new(InsecureVerifier))
|
|
.with_no_client_auth();
|
|
Ok(config)
|
|
} else {
|
|
let roots = rustls::RootCertStore::empty();
|
|
let config = builder
|
|
.with_root_certificates(roots)
|
|
.with_no_client_auth();
|
|
Ok(config)
|
|
}
|
|
}
|
|
|
|
/// A TLS verifier that accepts any certificate (for dev mode only).
|
|
#[derive(Debug)]
|
|
struct InsecureVerifier;
|
|
|
|
impl rustls::client::danger::ServerCertVerifier for InsecureVerifier {
|
|
fn verify_server_cert(
|
|
&self,
|
|
_end_entity: &rustls::pki_types::CertificateDer<'_>,
|
|
_intermediates: &[rustls::pki_types::CertificateDer<'_>],
|
|
_server_name: &rustls::pki_types::ServerName<'_>,
|
|
_ocsp_response: &[u8],
|
|
_now: rustls::pki_types::UnixTime,
|
|
) -> Result<rustls::client::danger::ServerCertVerified, rustls::Error> {
|
|
Ok(rustls::client::danger::ServerCertVerified::assertion())
|
|
}
|
|
|
|
fn verify_tls12_signature(
|
|
&self,
|
|
_message: &[u8],
|
|
_cert: &rustls::pki_types::CertificateDer<'_>,
|
|
_dss: &rustls::DigitallySignedStruct,
|
|
) -> Result<rustls::client::danger::HandshakeSignatureValid, rustls::Error> {
|
|
Ok(rustls::client::danger::HandshakeSignatureValid::assertion())
|
|
}
|
|
|
|
fn verify_tls13_signature(
|
|
&self,
|
|
_message: &[u8],
|
|
_cert: &rustls::pki_types::CertificateDer<'_>,
|
|
_dss: &rustls::DigitallySignedStruct,
|
|
) -> Result<rustls::client::danger::HandshakeSignatureValid, rustls::Error> {
|
|
Ok(rustls::client::danger::HandshakeSignatureValid::assertion())
|
|
}
|
|
|
|
fn supported_verify_schemes(&self) -> Vec<rustls::SignatureScheme> {
|
|
vec![
|
|
rustls::SignatureScheme::RSA_PKCS1_SHA256,
|
|
rustls::SignatureScheme::RSA_PKCS1_SHA384,
|
|
rustls::SignatureScheme::RSA_PKCS1_SHA512,
|
|
rustls::SignatureScheme::ECDSA_NISTP256_SHA256,
|
|
rustls::SignatureScheme::ECDSA_NISTP384_SHA384,
|
|
rustls::SignatureScheme::ED25519,
|
|
rustls::SignatureScheme::RSA_PSS_SHA256,
|
|
rustls::SignatureScheme::RSA_PSS_SHA384,
|
|
rustls::SignatureScheme::RSA_PSS_SHA512,
|
|
]
|
|
}
|
|
}
|