feat: introduce client event system

This commit is contained in:
osmaczko
2026-05-20 22:47:51 +02:00
parent 65e103ab1d
commit f21ffcad95
30 changed files with 1183 additions and 741 deletions
+64 -39
View File
@@ -5,7 +5,7 @@ use std::sync::mpsc;
use anyhow::Result;
use arboard::Clipboard;
use logos_chat::{ChatClient, ConversationIdOwned, DeliveryService};
use logos_chat::{ChatClient, ConversationIdOwned, DeliveryService, Event};
use serde::{Deserialize, Serialize};
use crate::utils::now;
@@ -43,7 +43,7 @@ pub struct AppState {
pub struct ChatApp<D: DeliveryService> {
pub client: ChatClient<D>,
inbound: mpsc::Receiver<Vec<u8>>,
events: mpsc::Receiver<Event>,
pub state: AppState,
/// Ephemeral command output — not persisted, cleared on chat switch.
command_output: Vec<DisplayMessage>,
@@ -56,7 +56,7 @@ pub struct ChatApp<D: DeliveryService> {
impl<D: DeliveryService + 'static> ChatApp<D> {
pub fn new(
client: ChatClient<D>,
inbound: mpsc::Receiver<Vec<u8>>,
events: mpsc::Receiver<Event>,
user_name: &str,
data_dir: &Path,
) -> Result<Self> {
@@ -76,7 +76,7 @@ impl<D: DeliveryService + 'static> ChatApp<D> {
Ok(Self {
client,
inbound,
events,
state,
command_output: Vec::new(),
input: String::new(),
@@ -142,43 +142,61 @@ impl<D: DeliveryService + 'static> ChatApp<D> {
}
pub fn process_incoming(&mut self) -> Result<()> {
while let Ok(payload) = self.inbound.try_recv() {
match self.client.receive(&payload) {
Ok(Some(content)) => {
let chat_id = &content.conversation_id;
if !self.state.chats.contains_key(chat_id) && content.is_new_convo {
let session = ChatSession {
chat_id: chat_id.clone(),
nickname: None,
messages: Vec::new(),
};
self.state.chats.insert(chat_id.clone(), session);
let label = chat_id[..8.min(chat_id.len())].to_string();
self.set_active_chat(Some(chat_id.clone()));
self.status = format!("New chat ({label})! Use /nickname to name it.");
}
if !content.data.is_empty() {
let text = String::from_utf8_lossy(&content.data).to_string();
if let Some(session) = self.state.chats.get_mut(chat_id) {
session.messages.push(DisplayMessage {
from_self: false,
content: text,
timestamp: now(),
});
}
}
self.save_state()?;
}
Ok(None) => {}
Err(e) => tracing::warn!("receive error: {e:?}"),
}
let mut had_event = false;
while let Ok(event) = self.events.try_recv() {
self.handle_event(event);
had_event = true;
}
if had_event {
self.save_state()?;
}
Ok(())
}
fn handle_event(&mut self, event: Event) {
match event {
Event::ConversationStarted {
conversation_id, ..
} => {
let chat_id = conversation_id.to_string();
if !self.state.chats.contains_key(&chat_id) {
let session = ChatSession {
chat_id: chat_id.clone(),
nickname: None,
messages: Vec::new(),
};
self.state.chats.insert(chat_id.clone(), session);
let label = chat_id[..8.min(chat_id.len())].to_string();
self.set_active_chat(Some(chat_id));
self.status = format!("New chat ({label})! Use /nickname to name it.");
}
}
Event::MessageReceived {
conversation_id,
data,
..
} => {
if data.is_empty() {
return;
}
let chat_id = conversation_id.as_ref();
let text = String::from_utf8_lossy(&data).to_string();
if let Some(session) = self.state.chats.get_mut(chat_id) {
session.messages.push(DisplayMessage {
from_self: false,
content: text,
timestamp: now(),
});
}
}
Event::DeliveryFailed { reason, .. } => {
tracing::warn!("delivery failed: {reason:?}");
self.status = format!("Delivery failed ({reason:?}); peer may not receive.");
}
_ => {}
}
}
pub fn send_message(&mut self, content: &str) -> Result<()> {
let chat_id = self
.state
@@ -188,9 +206,13 @@ impl<D: DeliveryService + 'static> ChatApp<D> {
let convo_id: ConversationIdOwned = chat_id.as_str().into();
self.client
let events = self
.client
.send_message(&convo_id, content.as_bytes())
.map_err(|e| anyhow::anyhow!("{e:?}"))?;
for event in events {
self.handle_event(event);
}
if let Some(session) = self.state.chats.get_mut(&chat_id) {
session.messages.push(DisplayMessage {
@@ -253,10 +275,13 @@ impl<D: DeliveryService + 'static> ChatApp<D> {
return Ok(Some("Usage: /connect <bundle>".to_string()));
}
let initial = format!("Hello from {}!", self.user_name);
let convo_id = self
let (convo_id, events) = self
.client
.create_conversation(args.as_bytes(), initial.as_bytes())
.map_err(|e| anyhow::anyhow!("{e:?}"))?;
for event in events {
self.handle_event(event);
}
let chat_id = convo_id.to_string();
let label = chat_id[..8.min(chat_id.len())].to_string();
+7 -78
View File
@@ -4,7 +4,6 @@ mod ui;
mod utils;
use std::path::{Path, PathBuf};
use std::sync::mpsc;
use anyhow::{Context, Result};
use clap::{Parser, ValueEnum};
@@ -66,9 +65,9 @@ fn main() -> Result<()> {
match cli.transport {
TransportKind::File => {
let transport_dir = cli.data.join("transport");
let (transport, inbound) = transport::file::FileTransport::new(&transport_dir)
let transport = transport::file::FileTransport::new(&transport_dir)
.context("failed to create file transport")?;
run(transport, inbound, &cli)
run(transport, &cli)
}
#[cfg(logos_delivery)]
TransportKind::LogosDelivery => {
@@ -82,20 +81,15 @@ fn main() -> Result<()> {
tcp_port: cli.port,
..Default::default()
};
let (transport, inbound) =
Service::start(cfg).context("failed to start logos-delivery")?;
let transport = Service::start(cfg).context("failed to start logos-delivery")?;
println!("Node connected. Initializing chat client...");
run(transport, inbound, &cli)
run(transport, &cli)
}
}
}
fn run<D: DeliveryService + 'static>(
transport: D,
inbound: mpsc::Receiver<Vec<u8>>,
cli: &Cli,
) -> Result<()> {
fn run<D: DeliveryService + 'static>(transport: D, cli: &Cli) -> Result<()> {
let db_path = cli
.db
.clone()
@@ -105,7 +99,7 @@ fn run<D: DeliveryService + 'static>(
.context("db path contains non-UTF-8 characters")?
.to_string();
let client = logos_chat::ChatClient::open(
let (client, events) = logos_chat::ChatClient::open(
cli.name.clone(),
logos_chat::StorageConfig::Encrypted {
path: db_str,
@@ -116,7 +110,7 @@ fn run<D: DeliveryService + 'static>(
.map_err(|e| anyhow::anyhow!("{e:?}"))
.context("failed to open chat client")?;
let mut app = ChatApp::new(client, inbound, &cli.name, &cli.data)?;
let mut app = ChatApp::new(client, events, &cli.name, &cli.data)?;
if cli.smoketest {
return Ok(());
@@ -128,71 +122,6 @@ fn run<D: DeliveryService + 'static>(
result
}
#[cfg_attr(not(logos_delivery), allow(dead_code, unused_variables))]
fn run_logos_delivery(cli: Cli) -> Result<()> {
#[cfg(logos_delivery)]
{
use transport::logos_delivery::{Config, Service};
eprintln!("Starting logos-delivery node (preset={})...", cli.preset);
eprintln!("This may take a few seconds while connecting to the network.");
let logos_cfg = Config {
preset: cli.preset.clone(),
tcp_port: cli.port,
..Default::default()
};
let (delivery, inbound) =
Service::start(logos_cfg).context("failed to start logos-delivery")?;
eprintln!("Node connected. Initializing chat client...");
let data_dir = cli
.db
.as_ref()
.and_then(|p| p.parent())
.map(|p| p.to_path_buf())
.unwrap_or_else(|| cli.data.clone());
let client = match cli.db {
Some(ref path) => {
let db_str = path
.to_str()
.context("db path contains non-UTF-8 characters")?
.to_string();
logos_chat::ChatClient::open(
cli.name.clone(),
logos_chat::StorageConfig::Encrypted {
path: db_str,
key: "chat-cli".to_string(),
},
delivery,
)
.map_err(|e| anyhow::anyhow!("{e:?}"))
.context("failed to open persistent client")?
}
None => logos_chat::ChatClient::new(cli.name.clone(), delivery),
};
let mut app = ChatApp::new(client, inbound, &cli.name, &data_dir)?;
if cli.smoketest {
return Ok(());
}
let mut terminal = ui::init().context("failed to initialize terminal")?;
let result = run_app(&mut terminal, &mut app);
ui::restore().context("failed to restore terminal")?;
return result;
}
#[cfg(not(logos_delivery))]
anyhow::bail!(
"logos-delivery transport is not available in this build.\n\
Build with LOGOS_DELIVERY_LIB_DIR set to enable it."
)
}
fn run_app<D: DeliveryService + 'static>(
terminal: &mut ui::Tui,
app: &mut ChatApp<D>,
+16 -13
View File
@@ -2,11 +2,11 @@ use std::collections::BTreeMap;
use std::fs::{self, File, OpenOptions};
use std::io::{self, BufReader, Read, Seek, SeekFrom, Write};
use std::path::{Path, PathBuf};
use std::sync::mpsc;
use std::sync::{Mutex, mpsc};
use std::thread;
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use logos_chat::{AddressedEnvelope, DeliveryService};
use logos_chat::{AddressedEnvelope, DeliveryService, drain_inbound};
#[derive(Debug, thiserror::Error)]
pub enum FileTransportError {
@@ -17,16 +17,17 @@ pub enum FileTransportError {
#[derive(Debug)]
pub struct FileTransport {
transport_dir: PathBuf,
inbound: Mutex<mpsc::Receiver<Vec<u8>>>,
}
impl FileTransport {
/// All instances pointing at the same `transport_dir` share one broadcast bus.
///
/// Messages are written to `{transport_dir}/{delivery_address}/{hours_since_epoch}.bin`
/// as length-prefixed frames (`[u32 BE length][payload bytes]`). The background
/// as length-prefixed frames (`[u32 BE length][payload bytes]`). A background
/// thread reads all files under `transport_dir` and forwards every frame to
/// the returned channel; `client.receive()` discards frames it cannot decrypt.
pub fn new(transport_dir: &Path) -> io::Result<(Self, mpsc::Receiver<Vec<u8>>)> {
/// the internal inbound channel; the client discards frames it cannot decrypt.
pub fn new(transport_dir: &Path) -> io::Result<Self> {
fs::create_dir_all(transport_dir)?;
let (tx, rx) = mpsc::sync_channel(1024);
@@ -36,19 +37,17 @@ impl FileTransport {
.name("file-transport".into())
.spawn(move || poll_reader(dir, tx))?;
Ok((
Self {
transport_dir: transport_dir.to_path_buf(),
},
rx,
))
Ok(Self {
transport_dir: transport_dir.to_path_buf(),
inbound: Mutex::new(rx),
})
}
}
impl DeliveryService for FileTransport {
type Error = FileTransportError;
fn publish(&mut self, envelope: AddressedEnvelope) -> Result<(), FileTransportError> {
fn publish(&self, envelope: AddressedEnvelope) -> Result<(), FileTransportError> {
let addr_dir = self.transport_dir.join(&envelope.delivery_address);
fs::create_dir_all(&addr_dir)?;
@@ -62,10 +61,14 @@ impl DeliveryService for FileTransport {
Ok(())
}
fn subscribe(&mut self, _delivery_address: &str) -> Result<(), Self::Error> {
fn subscribe(&self, _delivery_address: &str) -> Result<(), Self::Error> {
// FileTransport does not support filtering
Ok(())
}
fn pull(&self) -> Vec<Vec<u8>> {
drain_inbound(&self.inbound)
}
}
/// Hours since Unix epoch — used as the rolling filename.
+17 -13
View File
@@ -18,7 +18,7 @@ use std::time::Duration;
use base64::Engine;
use base64::engine::general_purpose::STANDARD as BASE64;
use logos_chat::{AddressedEnvelope, DeliveryService};
use logos_chat::{AddressedEnvelope, DeliveryService, drain_inbound};
use tracing::{error, info, warn};
use wrapper::LogosNodeCtx;
@@ -123,12 +123,14 @@ pub struct Service {
outbound: mpsc::SyncSender<OutboundCmd>,
#[allow(dead_code)]
subscribers: SubscriberList,
inbound: Arc<Mutex<mpsc::Receiver<Vec<u8>>>>,
}
impl Service {
/// Start the embedded logos-delivery node. Returns the service and a
/// receiver for inbound raw payloads.
pub fn start(cfg: Config) -> Result<(Self, mpsc::Receiver<Vec<u8>>), DeliveryError> {
/// Start the embedded logos-delivery node. Returns the service handle —
/// inbound payloads are drained via the `DeliveryService::pull` trait
/// method.
pub fn start(cfg: Config) -> Result<Self, DeliveryError> {
let (out_tx, out_rx) = mpsc::sync_channel::<OutboundCmd>(256);
let subscribers: SubscriberList = Arc::new(Mutex::new(Vec::new()));
let (ready_tx, ready_rx) = mpsc::channel::<Result<(), DeliveryError>>();
@@ -167,13 +169,11 @@ impl Service {
return Err(e);
}
Ok((
Self {
outbound: out_tx,
subscribers,
},
inbound_rx,
))
Ok(Self {
outbound: out_tx,
subscribers,
inbound: Arc::new(Mutex::new(inbound_rx)),
})
}
fn node_thread(
@@ -281,7 +281,7 @@ impl Service {
impl DeliveryService for Service {
type Error = DeliveryError;
fn publish(&mut self, envelope: AddressedEnvelope) -> Result<(), DeliveryError> {
fn publish(&self, envelope: AddressedEnvelope) -> Result<(), DeliveryError> {
let msg = WakuMessage {
content_topic: content_topic_for(&envelope.delivery_address),
payload: BASE64.encode(&envelope.data),
@@ -301,8 +301,12 @@ impl DeliveryService for Service {
reply_rx.recv().map_err(|_| DeliveryError::ChannelClosed)?
}
fn subscribe(&mut self, _: &str) -> Result<(), <Self as DeliveryService>::Error> {
fn subscribe(&self, _: &str) -> Result<(), <Self as DeliveryService>::Error> {
// This Service does not support filtering
Ok(())
}
fn pull(&self) -> Vec<Vec<u8>> {
drain_inbound(&self.inbound)
}
}