First commit in rust
This commit is contained in:
33
backchannel-client/Cargo.toml
Normal file
33
backchannel-client/Cargo.toml
Normal file
@@ -0,0 +1,33 @@
|
||||
[package]
|
||||
name = "backchannel-client"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[[bin]]
|
||||
name = "backchannel"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
backchannel-common = { path = "../backchannel-common" }
|
||||
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
uuid = { workspace = true }
|
||||
chrono = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
anyhow = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
tracing-subscriber = { workspace = true }
|
||||
base64 = { workspace = true }
|
||||
rand = { workspace = true }
|
||||
ed25519-dalek = { workspace = true }
|
||||
x25519-dalek = { workspace = true }
|
||||
chacha20poly1305 = { workspace = true }
|
||||
hkdf = { workspace = true }
|
||||
sha2 = { workspace = true }
|
||||
tokio = { workspace = true }
|
||||
tokio-tungstenite = { version = "0.21", features = ["rustls-tls-native-roots"] }
|
||||
futures-util = "0.3"
|
||||
|
||||
ratatui = "0.26"
|
||||
crossterm = "0.27"
|
||||
24
backchannel-client/src/config.rs
Normal file
24
backchannel-client/src/config.rs
Normal file
@@ -0,0 +1,24 @@
|
||||
/// Runtime configuration for the BackChannel client.
|
||||
pub struct ClientConfig {
|
||||
/// WebSocket server URL, e.g. `ws://127.0.0.1:7777` or `wss://server:7777`.
|
||||
pub server_url: String,
|
||||
|
||||
/// Path to the keystore JSON file (holds the Ed25519 identity keypair).
|
||||
pub keystore_path: String,
|
||||
}
|
||||
|
||||
impl ClientConfig {
|
||||
pub fn from_env() -> Self {
|
||||
let server_url = std::env::var("BC_SERVER_URL")
|
||||
.unwrap_or_else(|_| "ws://127.0.0.1:7777".into());
|
||||
|
||||
let keystore_path = std::env::var("BC_KEYSTORE").unwrap_or_else(|_| {
|
||||
let home = std::env::var("USERPROFILE")
|
||||
.or_else(|_| std::env::var("HOME"))
|
||||
.unwrap_or_else(|_| ".".into());
|
||||
format!("{}/.backchannel/keystore.json", home)
|
||||
});
|
||||
|
||||
Self { server_url, keystore_path }
|
||||
}
|
||||
}
|
||||
36
backchannel-client/src/crypto/dm.rs
Normal file
36
backchannel-client/src/crypto/dm.rs
Normal file
@@ -0,0 +1,36 @@
|
||||
use backchannel_common::crypto::{aead, ecdh};
|
||||
use x25519_dalek::EphemeralSecret;
|
||||
|
||||
use crate::error::Result;
|
||||
|
||||
/// Generate an ephemeral X25519 keypair to initiate a DM key exchange.
|
||||
///
|
||||
/// Returns the `EphemeralSecret` (store in `Keystore::pending_ecdh`) and the
|
||||
/// base64-encoded public key to send to the server.
|
||||
pub fn generate_exchange() -> (EphemeralSecret, String) {
|
||||
let (secret, pubkey) = ecdh::generate_ephemeral();
|
||||
let pubkey_b64 = ecdh::pubkey_to_b64(&pubkey);
|
||||
(secret, pubkey_b64)
|
||||
}
|
||||
|
||||
/// Complete a key exchange given our ephemeral secret and the peer's base64
|
||||
/// public key. Returns the 32-byte derived DM session key.
|
||||
///
|
||||
/// The `EphemeralSecret` is consumed here — it cannot be reused.
|
||||
pub fn complete_exchange(our_secret: EphemeralSecret, their_pubkey_b64: &str) -> Result<[u8; 32]> {
|
||||
let their_pubkey = ecdh::b64_to_pubkey(their_pubkey_b64)?;
|
||||
let raw_secret = ecdh::diffie_hellman(our_secret, &their_pubkey);
|
||||
Ok(aead::derive_dm_key(&raw_secret))
|
||||
}
|
||||
|
||||
/// Encrypt a plaintext DM using a derived session key.
|
||||
/// Returns `(ciphertext_b64, nonce_b64)`.
|
||||
pub fn encrypt(key: &[u8; 32], plaintext: &str) -> Result<(String, String)> {
|
||||
aead::encrypt(key, plaintext.as_bytes()).map_err(Into::into)
|
||||
}
|
||||
|
||||
/// Decrypt a received DM ciphertext using a derived session key.
|
||||
pub fn decrypt(key: &[u8; 32], nonce_b64: &str, ciphertext_b64: &str) -> Result<String> {
|
||||
let bytes = aead::decrypt(key, nonce_b64, ciphertext_b64)?;
|
||||
Ok(String::from_utf8(bytes)?)
|
||||
}
|
||||
1
backchannel-client/src/crypto/mod.rs
Normal file
1
backchannel-client/src/crypto/mod.rs
Normal file
@@ -0,0 +1 @@
|
||||
pub mod dm;
|
||||
30
backchannel-client/src/error.rs
Normal file
30
backchannel-client/src/error.rs
Normal file
@@ -0,0 +1,30 @@
|
||||
use thiserror::Error;
|
||||
|
||||
pub type Result<T> = std::result::Result<T, ClientError>;
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum ClientError {
|
||||
#[error("not authenticated")]
|
||||
NotAuthenticated,
|
||||
|
||||
#[error("server error {code}: {message}")]
|
||||
Server { code: u16, message: String },
|
||||
|
||||
#[error("no DM key for peer — initiate key exchange first")]
|
||||
NoDmKey,
|
||||
|
||||
#[error("crypto error: {0}")]
|
||||
Crypto(#[from] backchannel_common::BackchannelError),
|
||||
|
||||
#[error("serialization error: {0}")]
|
||||
Json(#[from] serde_json::Error),
|
||||
|
||||
#[error("I/O error: {0}")]
|
||||
Io(#[from] std::io::Error),
|
||||
|
||||
#[error("UTF-8 error: {0}")]
|
||||
Utf8(#[from] std::string::FromUtf8Error),
|
||||
|
||||
#[error("{0}")]
|
||||
Other(String),
|
||||
}
|
||||
91
backchannel-client/src/keystore.rs
Normal file
91
backchannel-client/src/keystore.rs
Normal file
@@ -0,0 +1,91 @@
|
||||
use std::collections::HashMap;
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
|
||||
use ed25519_dalek::{SigningKey, VerifyingKey};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
use x25519_dalek::EphemeralSecret;
|
||||
|
||||
use backchannel_common::crypto::identity;
|
||||
|
||||
use crate::error::{ClientError, Result};
|
||||
|
||||
/// On-disk representation of the keystore.
|
||||
#[derive(Serialize, Deserialize)]
|
||||
struct KeystoreFile {
|
||||
/// Base64-encoded Ed25519 signing key (32 bytes).
|
||||
signing_key_b64: String,
|
||||
}
|
||||
|
||||
/// Holds cryptographic material for the local client.
|
||||
pub struct Keystore {
|
||||
pub signing_key: SigningKey,
|
||||
pub verifying_key: VerifyingKey,
|
||||
|
||||
/// Derived DM session keys cached in memory: peer_user_id → 32-byte key.
|
||||
/// Populated after completing an X25519 key exchange.
|
||||
pub dm_keys: HashMap<Uuid, [u8; 32]>,
|
||||
|
||||
/// Ephemeral secrets awaiting the remote party's public key.
|
||||
/// Keyed by the peer's user UUID. Consumed on exchange completion.
|
||||
pub pending_ecdh: HashMap<Uuid, EphemeralSecret>,
|
||||
}
|
||||
|
||||
impl Keystore {
|
||||
/// Load an existing keystore from `path`, or generate a fresh one and save it.
|
||||
pub fn load_or_create(path: &str) -> Result<Self> {
|
||||
if Path::new(path).exists() {
|
||||
let contents = fs::read_to_string(path)?;
|
||||
let file: KeystoreFile =
|
||||
serde_json::from_str(&contents).map_err(ClientError::Json)?;
|
||||
let signing_key = identity::b64_to_signing_key(&file.signing_key_b64)?;
|
||||
let verifying_key = signing_key.verifying_key();
|
||||
tracing::debug!("Loaded keystore from {}", path);
|
||||
Ok(Self::from_signing_key(signing_key, verifying_key))
|
||||
} else {
|
||||
let (signing_key, verifying_key) = identity::generate_keypair();
|
||||
let file = KeystoreFile {
|
||||
signing_key_b64: identity::signing_key_to_b64(&signing_key),
|
||||
};
|
||||
if let Some(parent) = Path::new(path).parent() {
|
||||
fs::create_dir_all(parent)?;
|
||||
}
|
||||
fs::write(path, serde_json::to_string_pretty(&file)?)?;
|
||||
tracing::info!("Generated new identity keypair, saved to {}", path);
|
||||
Ok(Self::from_signing_key(signing_key, verifying_key))
|
||||
}
|
||||
}
|
||||
|
||||
fn from_signing_key(signing_key: SigningKey, verifying_key: VerifyingKey) -> Self {
|
||||
Self {
|
||||
signing_key,
|
||||
verifying_key,
|
||||
dm_keys: HashMap::new(),
|
||||
pending_ecdh: HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Base64-encoded Ed25519 public key, suitable for sending to the server.
|
||||
pub fn identity_pubkey_b64(&self) -> String {
|
||||
identity::pubkey_to_b64(&self.verifying_key)
|
||||
}
|
||||
|
||||
/// Store an `EphemeralSecret` while waiting for the remote peer's response.
|
||||
pub fn store_pending_ecdh(&mut self, peer_id: Uuid, secret: EphemeralSecret) {
|
||||
self.pending_ecdh.insert(peer_id, secret);
|
||||
}
|
||||
|
||||
/// Retrieve and remove a pending ephemeral secret (consumed by ECDH).
|
||||
pub fn take_pending_ecdh(&mut self, peer_id: Uuid) -> Option<EphemeralSecret> {
|
||||
self.pending_ecdh.remove(&peer_id)
|
||||
}
|
||||
|
||||
pub fn cache_dm_key(&mut self, peer_id: Uuid, key: [u8; 32]) {
|
||||
self.dm_keys.insert(peer_id, key);
|
||||
}
|
||||
|
||||
pub fn get_dm_key(&self, peer_id: &Uuid) -> Option<&[u8; 32]> {
|
||||
self.dm_keys.get(peer_id)
|
||||
}
|
||||
}
|
||||
53
backchannel-client/src/main.rs
Normal file
53
backchannel-client/src/main.rs
Normal file
@@ -0,0 +1,53 @@
|
||||
mod config;
|
||||
mod crypto;
|
||||
mod error;
|
||||
mod keystore;
|
||||
mod net;
|
||||
mod tui;
|
||||
|
||||
use std::io;
|
||||
|
||||
use anyhow::Result;
|
||||
use crossterm::{
|
||||
execute,
|
||||
terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
|
||||
};
|
||||
use ratatui::{backend::CrosstermBackend, Terminal};
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<()> {
|
||||
tracing_subscriber::fmt()
|
||||
.with_env_filter(
|
||||
tracing_subscriber::EnvFilter::try_from_default_env()
|
||||
.unwrap_or_else(|_| "backchannel=info".into()),
|
||||
)
|
||||
.with_writer(std::io::stderr) // keep TUI output clean
|
||||
.init();
|
||||
|
||||
let cfg = config::ClientConfig::from_env();
|
||||
|
||||
let keystore = keystore::Keystore::load_or_create(&cfg.keystore_path)?;
|
||||
|
||||
tracing::info!("Connecting to {}", cfg.server_url);
|
||||
let (net_tx, mut server_rx) = net::connection::connect(&cfg.server_url).await?;
|
||||
|
||||
let app = &mut tui::app::App::new(net_tx, keystore);
|
||||
|
||||
// ── Terminal setup ────────────────────────────────────────────────────
|
||||
enable_raw_mode()?;
|
||||
let mut stdout = io::stdout();
|
||||
execute!(stdout, EnterAlternateScreen)?;
|
||||
let backend = CrosstermBackend::new(stdout);
|
||||
let mut terminal = Terminal::new(backend)?;
|
||||
terminal.clear()?;
|
||||
|
||||
// ── Main event loop ───────────────────────────────────────────────────
|
||||
let result = tui::events::run(app, &mut terminal, &mut server_rx).await;
|
||||
|
||||
// ── Cleanup (always run, even on error) ───────────────────────────────
|
||||
disable_raw_mode()?;
|
||||
execute!(terminal.backend_mut(), LeaveAlternateScreen)?;
|
||||
terminal.show_cursor()?;
|
||||
|
||||
result
|
||||
}
|
||||
66
backchannel-client/src/net/connection.rs
Normal file
66
backchannel-client/src/net/connection.rs
Normal file
@@ -0,0 +1,66 @@
|
||||
use anyhow::{Context, Result};
|
||||
use futures_util::{SinkExt, StreamExt};
|
||||
use tokio::sync::mpsc;
|
||||
use tokio_tungstenite::{connect_async, tungstenite::Message as WsMessage};
|
||||
|
||||
use backchannel_common::protocol::{ClientMessage, ServerMessage};
|
||||
|
||||
/// Connect to the BackChannel server and return a pair of mpsc channels:
|
||||
///
|
||||
/// - `UnboundedSender<ClientMessage>` — send messages **to** the server
|
||||
/// - `UnboundedReceiver<ServerMessage>` — receive messages **from** the server
|
||||
///
|
||||
/// Two background tasks handle the actual WebSocket I/O so the caller never
|
||||
/// touches the raw stream.
|
||||
pub async fn connect(
|
||||
server_url: &str,
|
||||
) -> Result<(
|
||||
mpsc::UnboundedSender<ClientMessage>,
|
||||
mpsc::UnboundedReceiver<ServerMessage>,
|
||||
)> {
|
||||
let (ws_stream, _) = connect_async(server_url)
|
||||
.await
|
||||
.with_context(|| format!("Failed to connect to {}", server_url))?;
|
||||
|
||||
let (mut ws_sink, mut ws_source) = ws_stream.split();
|
||||
|
||||
let (client_tx, mut client_rx) = mpsc::unbounded_channel::<ClientMessage>();
|
||||
let (server_tx, server_rx) = mpsc::unbounded_channel::<ServerMessage>();
|
||||
|
||||
// Outbound: client_rx → WS sink
|
||||
tokio::spawn(async move {
|
||||
while let Some(msg) = client_rx.recv().await {
|
||||
match serde_json::to_string(&msg) {
|
||||
Ok(json) => {
|
||||
if ws_sink.send(WsMessage::Text(json)).await.is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Err(e) => tracing::error!("Failed to serialize outbound message: {}", e),
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Inbound: WS source → server_tx
|
||||
tokio::spawn(async move {
|
||||
while let Some(item) = ws_source.next().await {
|
||||
match item {
|
||||
Ok(WsMessage::Text(text)) => {
|
||||
match serde_json::from_str::<ServerMessage>(&text) {
|
||||
Ok(msg) => {
|
||||
if server_tx.send(msg).is_err() {
|
||||
break; // receiver dropped; shut down
|
||||
}
|
||||
}
|
||||
Err(e) => tracing::warn!("Failed to parse server message: {}", e),
|
||||
}
|
||||
}
|
||||
Ok(WsMessage::Close(_)) | Err(_) => break,
|
||||
Ok(_) => {}
|
||||
}
|
||||
}
|
||||
tracing::info!("Connection to server closed");
|
||||
});
|
||||
|
||||
Ok((client_tx, server_rx))
|
||||
}
|
||||
1
backchannel-client/src/net/mod.rs
Normal file
1
backchannel-client/src/net/mod.rs
Normal file
@@ -0,0 +1 @@
|
||||
pub mod connection;
|
||||
351
backchannel-client/src/tui/app.rs
Normal file
351
backchannel-client/src/tui/app.rs
Normal file
@@ -0,0 +1,351 @@
|
||||
use std::collections::{HashMap, VecDeque};
|
||||
|
||||
use backchannel_common::protocol::{ClientMessage, ServerMessage};
|
||||
use chrono::Local;
|
||||
use tokio::sync::mpsc;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::keystore::Keystore;
|
||||
|
||||
const MAX_MESSAGES: usize = 500;
|
||||
|
||||
/// What the TUI is currently displaying.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum View {
|
||||
Login,
|
||||
Channel,
|
||||
Dm,
|
||||
}
|
||||
|
||||
/// Which input field is focused on the login screen.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum LoginField {
|
||||
Username,
|
||||
Password,
|
||||
}
|
||||
|
||||
/// A single chat message for display.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ChatMessage {
|
||||
pub author: String,
|
||||
pub content: String,
|
||||
pub timestamp: String,
|
||||
}
|
||||
|
||||
/// All mutable state for the TUI application.
|
||||
pub struct App {
|
||||
// ── Navigation ────────────────────────────────────────────────────────
|
||||
pub view: View,
|
||||
pub should_quit: bool,
|
||||
|
||||
// ── Login screen ─────────────────────────────────────────────────────
|
||||
pub login_username: String,
|
||||
pub login_password: String,
|
||||
pub login_field: LoginField,
|
||||
pub login_register: bool, // toggle between Login and Register mode
|
||||
pub status_msg: Option<String>,
|
||||
|
||||
// ── Auth state ────────────────────────────────────────────────────────
|
||||
pub my_user_id: Option<Uuid>,
|
||||
pub my_username: Option<String>,
|
||||
|
||||
// ── Channel state ─────────────────────────────────────────────────────
|
||||
pub channels: Vec<(Uuid, String)>, // (id, name)
|
||||
pub selected_channel: usize,
|
||||
pub channel_messages: HashMap<Uuid, VecDeque<ChatMessage>>,
|
||||
|
||||
// ── DM state ──────────────────────────────────────────────────────────
|
||||
pub dm_peer_id: Option<Uuid>,
|
||||
pub dm_peer_name: Option<String>,
|
||||
pub dm_messages: HashMap<Uuid, VecDeque<ChatMessage>>,
|
||||
|
||||
// ── Text input (shared between channel + DM views) ────────────────────
|
||||
pub input: String,
|
||||
|
||||
// ── Online users ──────────────────────────────────────────────────────
|
||||
pub online_users: Vec<(Uuid, String)>,
|
||||
|
||||
// ── Network ───────────────────────────────────────────────────────────
|
||||
/// Send channel messages to the server connection task.
|
||||
pub net_tx: mpsc::UnboundedSender<ClientMessage>,
|
||||
|
||||
/// Keystore for crypto operations.
|
||||
pub keystore: Keystore,
|
||||
}
|
||||
|
||||
impl App {
|
||||
pub fn new(net_tx: mpsc::UnboundedSender<ClientMessage>, keystore: Keystore) -> Self {
|
||||
Self {
|
||||
view: View::Login,
|
||||
should_quit: false,
|
||||
|
||||
login_username: String::new(),
|
||||
login_password: String::new(),
|
||||
login_field: LoginField::Username,
|
||||
login_register: false,
|
||||
status_msg: None,
|
||||
|
||||
my_user_id: None,
|
||||
my_username: None,
|
||||
|
||||
channels: Vec::new(),
|
||||
selected_channel: 0,
|
||||
channel_messages: HashMap::new(),
|
||||
|
||||
dm_peer_id: None,
|
||||
dm_peer_name: None,
|
||||
dm_messages: HashMap::new(),
|
||||
|
||||
input: String::new(),
|
||||
|
||||
online_users: Vec::new(),
|
||||
|
||||
net_tx,
|
||||
keystore,
|
||||
}
|
||||
}
|
||||
|
||||
// ── Network message handling ──────────────────────────────────────────
|
||||
|
||||
pub fn handle_server_message(&mut self, msg: ServerMessage) {
|
||||
match msg {
|
||||
ServerMessage::AuthSuccess { user_id, username, .. } => {
|
||||
self.my_user_id = Some(user_id);
|
||||
self.my_username = Some(username.clone());
|
||||
self.status_msg = None;
|
||||
self.view = View::Channel;
|
||||
self.status_msg = Some(format!("Logged in as {}", username));
|
||||
}
|
||||
|
||||
ServerMessage::AuthError { reason } => {
|
||||
self.status_msg = Some(format!("Auth failed: {}", reason));
|
||||
}
|
||||
|
||||
ServerMessage::ChannelMessage {
|
||||
channel_id,
|
||||
author_username,
|
||||
content,
|
||||
timestamp,
|
||||
..
|
||||
} => {
|
||||
let entry = self
|
||||
.channel_messages
|
||||
.entry(channel_id)
|
||||
.or_default();
|
||||
|
||||
entry.push_back(ChatMessage {
|
||||
author: author_username,
|
||||
content,
|
||||
timestamp: timestamp.with_timezone(&Local).format("%H:%M").to_string(),
|
||||
});
|
||||
if entry.len() > MAX_MESSAGES {
|
||||
entry.pop_front();
|
||||
}
|
||||
}
|
||||
|
||||
ServerMessage::ChannelCreated { channel_id, name, .. } => {
|
||||
if !self.channels.iter().any(|(id, _)| *id == channel_id) {
|
||||
self.channels.push((channel_id, name));
|
||||
}
|
||||
}
|
||||
|
||||
ServerMessage::ChannelDeleted { channel_id } => {
|
||||
self.channels.retain(|(id, _)| *id != channel_id);
|
||||
self.channel_messages.remove(&channel_id);
|
||||
}
|
||||
|
||||
ServerMessage::DirectMessage {
|
||||
sender_id,
|
||||
sender_username,
|
||||
ciphertext,
|
||||
nonce,
|
||||
timestamp,
|
||||
..
|
||||
} => {
|
||||
// Determine the peer UUID (the other party in the conversation).
|
||||
let peer_id = if Some(sender_id) == self.my_user_id {
|
||||
self.dm_peer_id.unwrap_or(sender_id)
|
||||
} else {
|
||||
sender_id
|
||||
};
|
||||
|
||||
// Try to decrypt; fall back to a placeholder if the key is missing.
|
||||
let plaintext = self.keystore
|
||||
.get_dm_key(&peer_id)
|
||||
.and_then(|key| {
|
||||
crate::crypto::dm::decrypt(key, &nonce, &ciphertext).ok()
|
||||
})
|
||||
.unwrap_or_else(|| "[encrypted]".into());
|
||||
|
||||
let entry = self.dm_messages.entry(peer_id).or_default();
|
||||
entry.push_back(ChatMessage {
|
||||
author: sender_username,
|
||||
content: plaintext,
|
||||
timestamp: timestamp.with_timezone(&Local).format("%H:%M").to_string(),
|
||||
});
|
||||
if entry.len() > MAX_MESSAGES {
|
||||
entry.pop_front();
|
||||
}
|
||||
}
|
||||
|
||||
ServerMessage::DmKeyExchangeRequest {
|
||||
initiator_id,
|
||||
initiator_username,
|
||||
sender_ephemeral_pubkey,
|
||||
} => {
|
||||
// Bob receives this — generate our ephemeral pair and respond.
|
||||
let (secret, our_pubkey_b64) = crate::crypto::dm::generate_exchange();
|
||||
|
||||
// Derive the shared key now (Bob has both keys).
|
||||
if let Ok(key) = crate::crypto::dm::complete_exchange(secret, &sender_ephemeral_pubkey) {
|
||||
self.keystore.cache_dm_key(initiator_id, key);
|
||||
}
|
||||
|
||||
let _ = self.net_tx.send(ClientMessage::AcceptDmKeyExchange {
|
||||
initiator_id,
|
||||
recipient_ephemeral_pubkey: our_pubkey_b64,
|
||||
});
|
||||
|
||||
self.status_msg = Some(format!("{} wants to DM you", initiator_username));
|
||||
}
|
||||
|
||||
ServerMessage::DmKeyExchangeResponse {
|
||||
recipient_id,
|
||||
recipient_ephemeral_pubkey,
|
||||
..
|
||||
} => {
|
||||
// Alice receives this — complete the exchange.
|
||||
if let Some(secret) = self.keystore.take_pending_ecdh(recipient_id) {
|
||||
if let Ok(key) =
|
||||
crate::crypto::dm::complete_exchange(secret, &recipient_ephemeral_pubkey)
|
||||
{
|
||||
self.keystore.cache_dm_key(recipient_id, key);
|
||||
self.status_msg = Some("DM key exchange complete — you can now send encrypted messages".into());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ServerMessage::UserOnline { user_id, username } => {
|
||||
if !self.online_users.iter().any(|(id, _)| *id == user_id) {
|
||||
self.online_users.push((user_id, username));
|
||||
}
|
||||
}
|
||||
|
||||
ServerMessage::UserOffline { user_id, .. } => {
|
||||
self.online_users.retain(|(id, _)| *id != user_id);
|
||||
}
|
||||
|
||||
ServerMessage::Error { code, message } => {
|
||||
self.status_msg = Some(format!("Error {}: {}", code, message));
|
||||
}
|
||||
|
||||
// Other messages (history, role events, etc.) are acknowledged but
|
||||
// not yet wired into the TUI — they will be handled in future iterations.
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Input helpers ─────────────────────────────────────────────────────
|
||||
|
||||
/// Submit the current channel text input.
|
||||
pub fn submit_channel_message(&mut self) {
|
||||
let content = self.input.trim().to_string();
|
||||
if content.is_empty() {
|
||||
return;
|
||||
}
|
||||
if let Some((channel_id, _)) = self.channels.get(self.selected_channel) {
|
||||
let _ = self.net_tx.send(ClientMessage::SendChannelMessage {
|
||||
channel_id: *channel_id,
|
||||
content,
|
||||
});
|
||||
}
|
||||
self.input.clear();
|
||||
}
|
||||
|
||||
/// Submit the current DM text input (encrypted).
|
||||
pub fn submit_dm_message(&mut self) {
|
||||
let content = self.input.trim().to_string();
|
||||
if content.is_empty() {
|
||||
return;
|
||||
}
|
||||
let Some(peer_id) = self.dm_peer_id else { return };
|
||||
|
||||
match self.keystore.get_dm_key(&peer_id) {
|
||||
Some(key) => {
|
||||
let key = *key;
|
||||
match crate::crypto::dm::encrypt(&key, &content) {
|
||||
Ok((ciphertext, nonce)) => {
|
||||
let _ = self.net_tx.send(ClientMessage::SendDm {
|
||||
recipient_id: peer_id,
|
||||
ciphertext,
|
||||
nonce,
|
||||
});
|
||||
}
|
||||
Err(e) => {
|
||||
self.status_msg = Some(format!("Encryption error: {}", e));
|
||||
}
|
||||
}
|
||||
}
|
||||
None => {
|
||||
self.status_msg = Some(
|
||||
"No DM key — initiate key exchange with /dm @user first".into(),
|
||||
);
|
||||
}
|
||||
}
|
||||
self.input.clear();
|
||||
}
|
||||
|
||||
/// Initiate a DM key exchange with a peer by their UUID.
|
||||
pub fn initiate_dm(&mut self, peer_id: Uuid, peer_name: String) {
|
||||
let (secret, our_pubkey_b64) = crate::crypto::dm::generate_exchange();
|
||||
self.keystore.store_pending_ecdh(peer_id, secret);
|
||||
|
||||
let _ = self.net_tx.send(ClientMessage::InitDmKeyExchange {
|
||||
recipient_id: peer_id,
|
||||
sender_ephemeral_pubkey: our_pubkey_b64,
|
||||
});
|
||||
|
||||
self.dm_peer_id = Some(peer_id);
|
||||
self.dm_peer_name = Some(peer_name);
|
||||
self.view = View::Dm;
|
||||
self.status_msg = Some("Key exchange initiated — waiting for peer...".into());
|
||||
}
|
||||
|
||||
/// Submit the login/register form.
|
||||
pub fn submit_login(&mut self) {
|
||||
let username = self.login_username.trim().to_string();
|
||||
let password = self.login_password.trim().to_string();
|
||||
if username.is_empty() || password.is_empty() {
|
||||
self.status_msg = Some("Username and password are required".into());
|
||||
return;
|
||||
}
|
||||
|
||||
let identity_pubkey = self.keystore.identity_pubkey_b64();
|
||||
|
||||
let msg = if self.login_register {
|
||||
ClientMessage::Register { username, password, identity_pubkey }
|
||||
} else {
|
||||
ClientMessage::Login { username, password, identity_pubkey }
|
||||
};
|
||||
|
||||
let _ = self.net_tx.send(msg);
|
||||
self.status_msg = Some("Connecting...".into());
|
||||
}
|
||||
|
||||
/// Current channel (Uuid, name), if any channel is selected.
|
||||
pub fn current_channel(&self) -> Option<&(Uuid, String)> {
|
||||
self.channels.get(self.selected_channel)
|
||||
}
|
||||
|
||||
/// Messages for the currently viewed channel.
|
||||
pub fn current_channel_messages(&self) -> Option<&VecDeque<ChatMessage>> {
|
||||
self.current_channel()
|
||||
.and_then(|(id, _)| self.channel_messages.get(id))
|
||||
}
|
||||
|
||||
/// Messages for the current DM peer.
|
||||
pub fn current_dm_messages(&self) -> Option<&VecDeque<ChatMessage>> {
|
||||
self.dm_peer_id
|
||||
.and_then(|id| self.dm_messages.get(&id))
|
||||
}
|
||||
}
|
||||
116
backchannel-client/src/tui/events.rs
Normal file
116
backchannel-client/src/tui/events.rs
Normal file
@@ -0,0 +1,116 @@
|
||||
use anyhow::Result;
|
||||
use crossterm::event::{self, Event, KeyCode, KeyModifiers};
|
||||
use ratatui::{backend::CrosstermBackend, Terminal};
|
||||
use std::io::Stdout;
|
||||
use std::time::Duration;
|
||||
|
||||
use crate::tui::app::{App, LoginField, View};
|
||||
|
||||
/// The main event loop. Runs until `app.should_quit` is set.
|
||||
///
|
||||
/// Every ~50 ms we:
|
||||
/// 1. Drain any pending server messages from `server_rx`.
|
||||
/// 2. Poll for a crossterm keyboard event.
|
||||
/// 3. Redraw the terminal.
|
||||
pub async fn run(
|
||||
app: &mut App,
|
||||
terminal: &mut Terminal<CrosstermBackend<Stdout>>,
|
||||
server_rx: &mut tokio::sync::mpsc::UnboundedReceiver<backchannel_common::protocol::ServerMessage>,
|
||||
) -> Result<()> {
|
||||
loop {
|
||||
// Drain server messages (non-blocking).
|
||||
while let Ok(msg) = server_rx.try_recv() {
|
||||
app.handle_server_message(msg);
|
||||
}
|
||||
|
||||
// Draw.
|
||||
terminal.draw(|f| crate::tui::views::draw(f, app))?;
|
||||
|
||||
// Poll keyboard events with a short timeout so server messages stay responsive.
|
||||
if event::poll(Duration::from_millis(50))? {
|
||||
if let Event::Key(key) = event::read()? {
|
||||
handle_key(app, key.code, key.modifiers);
|
||||
}
|
||||
}
|
||||
|
||||
if app.should_quit {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn handle_key(app: &mut App, code: KeyCode, modifiers: KeyModifiers) {
|
||||
// Ctrl-C or Ctrl-Q always quit.
|
||||
if modifiers.contains(KeyModifiers::CONTROL)
|
||||
&& (code == KeyCode::Char('c') || code == KeyCode::Char('q'))
|
||||
{
|
||||
app.should_quit = true;
|
||||
return;
|
||||
}
|
||||
|
||||
match app.view {
|
||||
View::Login => handle_login_key(app, code),
|
||||
View::Channel => handle_channel_key(app, code),
|
||||
View::Dm => handle_dm_key(app, code),
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_login_key(app: &mut App, code: KeyCode) {
|
||||
match code {
|
||||
KeyCode::Tab => {
|
||||
app.login_field = match app.login_field {
|
||||
LoginField::Username => LoginField::Password,
|
||||
LoginField::Password => LoginField::Username,
|
||||
};
|
||||
}
|
||||
KeyCode::Enter => app.submit_login(),
|
||||
KeyCode::Char('t') if app.login_username.is_empty() && app.login_password.is_empty() => {
|
||||
// Toggle register/login mode only when both fields are empty
|
||||
app.login_register = !app.login_register;
|
||||
}
|
||||
KeyCode::Char(c) => match app.login_field {
|
||||
LoginField::Username => app.login_username.push(c),
|
||||
LoginField::Password => app.login_password.push(c),
|
||||
},
|
||||
KeyCode::Backspace => match app.login_field {
|
||||
LoginField::Username => { app.login_username.pop(); }
|
||||
LoginField::Password => { app.login_password.pop(); }
|
||||
},
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_channel_key(app: &mut App, code: KeyCode) {
|
||||
match code {
|
||||
KeyCode::Enter => app.submit_channel_message(),
|
||||
KeyCode::Char(c) => app.input.push(c),
|
||||
KeyCode::Backspace => { app.input.pop(); }
|
||||
// Channel navigation
|
||||
KeyCode::Up => {
|
||||
if app.selected_channel > 0 {
|
||||
app.selected_channel -= 1;
|
||||
}
|
||||
}
|
||||
KeyCode::Down => {
|
||||
if app.selected_channel + 1 < app.channels.len() {
|
||||
app.selected_channel += 1;
|
||||
}
|
||||
}
|
||||
KeyCode::Esc => app.status_msg = None,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_dm_key(app: &mut App, code: KeyCode) {
|
||||
match code {
|
||||
KeyCode::Enter => app.submit_dm_message(),
|
||||
KeyCode::Char(c) => app.input.push(c),
|
||||
KeyCode::Backspace => { app.input.pop(); }
|
||||
KeyCode::Esc => {
|
||||
app.view = View::Channel;
|
||||
app.input.clear();
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
3
backchannel-client/src/tui/mod.rs
Normal file
3
backchannel-client/src/tui/mod.rs
Normal file
@@ -0,0 +1,3 @@
|
||||
pub mod app;
|
||||
pub mod events;
|
||||
pub mod views;
|
||||
147
backchannel-client/src/tui/views/channel.rs
Normal file
147
backchannel-client/src/tui/views/channel.rs
Normal file
@@ -0,0 +1,147 @@
|
||||
use ratatui::{
|
||||
layout::{Constraint, Direction, Layout},
|
||||
style::{Color, Modifier, Style},
|
||||
text::{Line, Span},
|
||||
widgets::{Block, Borders, List, ListItem, ListState, Paragraph},
|
||||
Frame,
|
||||
};
|
||||
|
||||
use crate::tui::app::App;
|
||||
|
||||
pub fn draw(frame: &mut Frame, app: &App) {
|
||||
let area = frame.size();
|
||||
|
||||
// Split into sidebar | main column
|
||||
let columns = Layout::default()
|
||||
.direction(Direction::Horizontal)
|
||||
.constraints([Constraint::Length(22), Constraint::Min(0)])
|
||||
.split(area);
|
||||
|
||||
draw_sidebar(frame, app, columns[0]);
|
||||
draw_main(frame, app, columns[1]);
|
||||
}
|
||||
|
||||
fn draw_sidebar(frame: &mut Frame, app: &App, area: ratatui::layout::Rect) {
|
||||
let rows = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([Constraint::Min(0), Constraint::Length(6)])
|
||||
.split(area);
|
||||
|
||||
// ── Channel list ────────────────────────────────────────────────────
|
||||
let items: Vec<ListItem> = app
|
||||
.channels
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, (_, name))| {
|
||||
let style = if i == app.selected_channel {
|
||||
Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)
|
||||
} else {
|
||||
Style::default()
|
||||
};
|
||||
ListItem::new(format!("# {}", name)).style(style)
|
||||
})
|
||||
.collect();
|
||||
|
||||
let mut state = ListState::default();
|
||||
state.select(Some(app.selected_channel));
|
||||
|
||||
let channels = List::new(items)
|
||||
.block(
|
||||
Block::default()
|
||||
.title(" Channels ")
|
||||
.borders(Borders::ALL)
|
||||
.style(Style::default().fg(Color::Cyan)),
|
||||
)
|
||||
.highlight_style(Style::default().add_modifier(Modifier::BOLD));
|
||||
|
||||
frame.render_stateful_widget(channels, rows[0], &mut state);
|
||||
|
||||
// ── Online users ─────────────────────────────────────────────────────
|
||||
let user_items: Vec<ListItem> = app
|
||||
.online_users
|
||||
.iter()
|
||||
.map(|(_, name)| ListItem::new(format!("● {}", name)))
|
||||
.collect();
|
||||
|
||||
let online = List::new(user_items).block(
|
||||
Block::default()
|
||||
.title(" Online ")
|
||||
.borders(Borders::ALL)
|
||||
.style(Style::default().fg(Color::Green)),
|
||||
);
|
||||
frame.render_widget(online, rows[1]);
|
||||
}
|
||||
|
||||
fn draw_main(frame: &mut Frame, app: &App, area: ratatui::layout::Rect) {
|
||||
let rows = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([
|
||||
Constraint::Length(1), // channel title bar
|
||||
Constraint::Min(0), // messages
|
||||
Constraint::Length(3), // input box
|
||||
Constraint::Length(1), // status bar
|
||||
])
|
||||
.split(area);
|
||||
|
||||
// ── Title bar ────────────────────────────────────────────────────────
|
||||
let title = app
|
||||
.current_channel()
|
||||
.map(|(_, name)| format!(" # {} ", name))
|
||||
.unwrap_or_else(|| " No channel selected ".into());
|
||||
|
||||
let title_bar = Paragraph::new(title)
|
||||
.style(Style::default().fg(Color::White).bg(Color::DarkGray).add_modifier(Modifier::BOLD));
|
||||
frame.render_widget(title_bar, rows[0]);
|
||||
|
||||
// ── Messages ─────────────────────────────────────────────────────────
|
||||
let msgs: Vec<ListItem> = app
|
||||
.current_channel_messages()
|
||||
.map(|deque| {
|
||||
deque
|
||||
.iter()
|
||||
.map(|m| {
|
||||
ListItem::new(Line::from(vec![
|
||||
Span::styled(
|
||||
format!("{} ", m.timestamp),
|
||||
Style::default().fg(Color::DarkGray),
|
||||
),
|
||||
Span::styled(
|
||||
format!("{}: ", m.author),
|
||||
Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD),
|
||||
),
|
||||
Span::raw(m.content.clone()),
|
||||
]))
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
|
||||
let message_count = msgs.len();
|
||||
let mut list_state = ListState::default();
|
||||
if message_count > 0 {
|
||||
list_state.select(Some(message_count - 1));
|
||||
}
|
||||
|
||||
let messages = List::new(msgs)
|
||||
.block(Block::default().borders(Borders::LEFT))
|
||||
.highlight_style(Style::default());
|
||||
frame.render_stateful_widget(messages, rows[1], &mut list_state);
|
||||
|
||||
// ── Input box ─────────────────────────────────────────────────────────
|
||||
let input = Paragraph::new(app.input.as_str())
|
||||
.block(
|
||||
Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.title(" Message (Enter to send) "),
|
||||
);
|
||||
frame.render_widget(input, rows[2]);
|
||||
|
||||
// ── Status bar ────────────────────────────────────────────────────────
|
||||
let status_text = app
|
||||
.status_msg
|
||||
.as_deref()
|
||||
.unwrap_or("Ctrl-Q: quit | ↑↓: channels | Esc: dismiss");
|
||||
let status = Paragraph::new(status_text)
|
||||
.style(Style::default().fg(Color::DarkGray));
|
||||
frame.render_widget(status, rows[3]);
|
||||
}
|
||||
86
backchannel-client/src/tui/views/dm.rs
Normal file
86
backchannel-client/src/tui/views/dm.rs
Normal file
@@ -0,0 +1,86 @@
|
||||
use ratatui::{
|
||||
layout::{Constraint, Direction, Layout},
|
||||
style::{Color, Modifier, Style},
|
||||
text::{Line, Span},
|
||||
widgets::{Block, Borders, List, ListItem, ListState, Paragraph},
|
||||
Frame,
|
||||
};
|
||||
|
||||
use crate::tui::app::App;
|
||||
|
||||
pub fn draw(frame: &mut Frame, app: &App) {
|
||||
let area = frame.size();
|
||||
|
||||
let rows = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([
|
||||
Constraint::Length(1), // title bar
|
||||
Constraint::Min(0), // messages
|
||||
Constraint::Length(3), // input
|
||||
Constraint::Length(1), // status
|
||||
])
|
||||
.split(area);
|
||||
|
||||
// ── Title bar ─────────────────────────────────────────────────────────
|
||||
let peer_name = app
|
||||
.dm_peer_name
|
||||
.as_deref()
|
||||
.unwrap_or("Unknown");
|
||||
|
||||
let title = Paragraph::new(format!(" DM: {} (E2EE) ", peer_name))
|
||||
.style(Style::default().fg(Color::White).bg(Color::Blue).add_modifier(Modifier::BOLD));
|
||||
frame.render_widget(title, rows[0]);
|
||||
|
||||
// ── Messages ──────────────────────────────────────────────────────────
|
||||
let msgs: Vec<ListItem> = app
|
||||
.current_dm_messages()
|
||||
.map(|deque| {
|
||||
deque
|
||||
.iter()
|
||||
.map(|m| {
|
||||
let is_self = app.my_username.as_deref() == Some(m.author.as_str());
|
||||
let author_style = if is_self {
|
||||
Style::default().fg(Color::Green).add_modifier(Modifier::BOLD)
|
||||
} else {
|
||||
Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD)
|
||||
};
|
||||
ListItem::new(Line::from(vec![
|
||||
Span::styled(
|
||||
format!("{} ", m.timestamp),
|
||||
Style::default().fg(Color::DarkGray),
|
||||
),
|
||||
Span::styled(format!("{}: ", m.author), author_style),
|
||||
Span::raw(m.content.clone()),
|
||||
]))
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
|
||||
let msg_count = msgs.len();
|
||||
let mut state = ListState::default();
|
||||
if msg_count > 0 {
|
||||
state.select(Some(msg_count - 1));
|
||||
}
|
||||
|
||||
let messages = List::new(msgs).block(Block::default().borders(Borders::LEFT));
|
||||
frame.render_stateful_widget(messages, rows[1], &mut state);
|
||||
|
||||
// ── Input box ─────────────────────────────────────────────────────────
|
||||
let input = Paragraph::new(app.input.as_str())
|
||||
.block(
|
||||
Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.title(" Encrypted message (Enter: send Esc: back) "),
|
||||
);
|
||||
frame.render_widget(input, rows[2]);
|
||||
|
||||
// ── Status bar ────────────────────────────────────────────────────────
|
||||
let status_text = app
|
||||
.status_msg
|
||||
.as_deref()
|
||||
.unwrap_or("🔒 End-to-end encrypted");
|
||||
let status = Paragraph::new(status_text)
|
||||
.style(Style::default().fg(Color::DarkGray));
|
||||
frame.render_widget(status, rows[3]);
|
||||
}
|
||||
98
backchannel-client/src/tui/views/login.rs
Normal file
98
backchannel-client/src/tui/views/login.rs
Normal file
@@ -0,0 +1,98 @@
|
||||
use ratatui::{
|
||||
layout::{Alignment, Constraint, Direction, Layout},
|
||||
style::{Color, Modifier, Style},
|
||||
text::{Line, Span},
|
||||
widgets::{Block, Borders, Paragraph},
|
||||
Frame,
|
||||
};
|
||||
|
||||
use crate::tui::app::{App, LoginField};
|
||||
|
||||
pub fn draw(frame: &mut Frame, app: &App) {
|
||||
let area = frame.size();
|
||||
|
||||
// Centre a 50×12 box on screen.
|
||||
let v = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([
|
||||
Constraint::Percentage(30),
|
||||
Constraint::Length(14),
|
||||
Constraint::Min(0),
|
||||
])
|
||||
.split(area);
|
||||
|
||||
let h = Layout::default()
|
||||
.direction(Direction::Horizontal)
|
||||
.constraints([
|
||||
Constraint::Percentage(25),
|
||||
Constraint::Percentage(50),
|
||||
Constraint::Percentage(25),
|
||||
])
|
||||
.split(v[1]);
|
||||
|
||||
let box_area = h[1];
|
||||
|
||||
let mode_label = if app.login_register { "Register" } else { "Login" };
|
||||
|
||||
let outer = Block::default()
|
||||
.title(format!(" BackChannel — {} ", mode_label))
|
||||
.borders(Borders::ALL)
|
||||
.style(Style::default().fg(Color::Cyan));
|
||||
|
||||
frame.render_widget(outer, box_area);
|
||||
|
||||
let inner = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([
|
||||
Constraint::Length(1), // padding
|
||||
Constraint::Length(3), // username
|
||||
Constraint::Length(3), // password
|
||||
Constraint::Length(1), // padding
|
||||
Constraint::Length(1), // hint
|
||||
Constraint::Length(1), // status
|
||||
])
|
||||
.margin(1)
|
||||
.split(box_area);
|
||||
|
||||
// Username field
|
||||
let username_style = if app.login_field == LoginField::Username {
|
||||
Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)
|
||||
} else {
|
||||
Style::default()
|
||||
};
|
||||
let username_widget = Paragraph::new(app.login_username.as_str())
|
||||
.block(Block::default().borders(Borders::ALL).title(" Username "))
|
||||
.style(username_style);
|
||||
frame.render_widget(username_widget, inner[1]);
|
||||
|
||||
// Password field (mask with asterisks)
|
||||
let password_style = if app.login_field == LoginField::Password {
|
||||
Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)
|
||||
} else {
|
||||
Style::default()
|
||||
};
|
||||
let masked: String = "*".repeat(app.login_password.len());
|
||||
let password_widget = Paragraph::new(masked.as_str())
|
||||
.block(Block::default().borders(Borders::ALL).title(" Password "))
|
||||
.style(password_style);
|
||||
frame.render_widget(password_widget, inner[2]);
|
||||
|
||||
// Hints
|
||||
let hint = Paragraph::new(Line::from(vec![
|
||||
Span::raw("Tab: switch field | Enter: submit | "),
|
||||
Span::styled(
|
||||
if app.login_register { "T: switch to Login" } else { "T: switch to Register" },
|
||||
Style::default().fg(Color::DarkGray),
|
||||
),
|
||||
]))
|
||||
.alignment(Alignment::Center);
|
||||
frame.render_widget(hint, inner[4]);
|
||||
|
||||
// Status / error message
|
||||
if let Some(msg) = &app.status_msg {
|
||||
let status = Paragraph::new(msg.as_str())
|
||||
.style(Style::default().fg(Color::Red))
|
||||
.alignment(Alignment::Center);
|
||||
frame.render_widget(status, inner[5]);
|
||||
}
|
||||
}
|
||||
16
backchannel-client/src/tui/views/mod.rs
Normal file
16
backchannel-client/src/tui/views/mod.rs
Normal file
@@ -0,0 +1,16 @@
|
||||
pub mod channel;
|
||||
pub mod dm;
|
||||
pub mod login;
|
||||
|
||||
use ratatui::Frame;
|
||||
|
||||
use crate::tui::app::{App, View};
|
||||
|
||||
/// Top-level draw dispatcher — renders the correct view based on `app.view`.
|
||||
pub fn draw(frame: &mut Frame, app: &App) {
|
||||
match app.view {
|
||||
View::Login => login::draw(frame, app),
|
||||
View::Channel => channel::draw(frame, app),
|
||||
View::Dm => dm::draw(frame, app),
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user