From 44d284a56887f6c900d67acc9db6776b08a4aff9 Mon Sep 17 00:00:00 2001 From: loki5512344 Date: Tue, 21 Jul 2026 17:39:51 +0200 Subject: [PATCH] docs: rewrite README with feature list, tech stack, status table; fix Slint padding warnings; update encryption status --- README.md | 145 +++++++++++++++++++-------- src/net/internal/dispatch/channel.rs | 3 + src/net/payloads/channel.rs | 4 + src/net/session/handlers/core.rs | 4 +- src/net/types/command.rs | 1 + src/net/types/event.rs | 2 + src/net/types/info.rs | 1 + src/ui/bridge.rs | 4 + src/ui/slint/settings.slint | 6 +- src/ui/state/mod.rs | 5 + src/ui/state/models.rs | 1 + src/ui/update.rs | 8 ++ 12 files changed, 135 insertions(+), 49 deletions(-) diff --git a/README.md b/README.md index 0293f64..4e9c615 100644 --- a/README.md +++ b/README.md @@ -1,74 +1,131 @@ -# VNOX +# VNOX — Client -Self-hosted realtime voice and chat. Decentralized. Lightweight. Moddable. -Built on LNEx, a custom protocol for low-latency federated communication. +> Native desktop client for VNOX. Voice, text, no cloud. -Not Discord. Not TeamSpeak. Not cloud. +[![License: GPL-3.0](https://img.shields.io/badge/license-GPL--3.0-blue.svg)](LICENSE) +[![Rust](https://img.shields.io/badge/rust-1.85%2B-orange.svg)](https://www.rust-lang.org/) +[![Status: Phase 1](https://img.shields.io/badge/status-phase%201%20%E2%80%94%20implemented-yellow.svg)](docs/00-status.md) -``` -vnox://server/channel -``` +--- -## Quick links +## What is this? -- [Architecture](docs/01-architecture.md) -- [Protocol](docs/02-protocol/README.md) -- [Current status and limitations](docs/00-status.md) -- [Server setup](docs/03-server/deployment.md) -- [Local dev config](dev/README.md) -- [Contributing](docs/community/contributing.md) -- [Changelog](CHANGELOG.md) +The official desktop client for [VNOX](https://github.com/loki5512344/Vnox) — a self-hosted voice and text communication platform. Connects to a VNOX server over the LNEx protocol. + +- Connects to gateway via **TCP** (text, auth, channels, guilds, DMs) +- Connects to voice node via **UDP** (encrypted Opus voice packets) +- Opus audio encoding/decoding with adaptive jitter buffer +- Native UI built with **Slint** — no Electron, no web views + +--- + +## Features + +### Implemented +- **Auth:** Ed25519 keypair generation, challenge-response login, reconnect with exponential backoff +- **Encryption:** ChaCha20-Poly1305 AEAD + X25519 ECDH key exchange +- **Text chat:** Persistent history, reactions, replies, edit, delete, typing indicators, read receipts +- **Channel management:** Join, leave, create, delete; text and voice channels +- **Guilds:** Create, list, leave, member list with roles, kick, audit log viewer +- **Roles:** Role list, assign/unassign with permission checks +- **Direct Messages:** 1:1 DMs with persistent history, unread badges, search +- **Friends:** Requests, accept/decline, Online/All/Pending/Blocked tabs +- **Presence:** Online/Idle/DND/Invisible, custom status text, activity display +- **Voice:** PTT / VAD / always-on modes, configurable bitrate, per-user volume +- **Jitter buffer:** Adaptive mode, configurable target latency +- **Noise suppression:** RNNoise (feature-gated, off by default) +- **Identity vault:** Optional Argon2id + ChaCha20-Poly1305 encrypted keyfile +- **Keyfile export/import:** Encrypted or plain JSON with passphrase +- **Bookmarks:** Save/remove server nodes in connect screen + +### Planned +- In-game overlay (Phase 2) +- Federation support (Phase 3) +- Mobile client (Phase 3) + +--- ## Status -Phase 1: implemented, not production ready. +Phase 1 is implemented. Not production ready. -See [docs/00-status.md](docs/00-status.md) for an honest list of what works, -what is only specified on paper, and known gaps. +| Feature | Status | +|----------------------|----------------------------| +| Connect to server | ✅ Working | +| Text chat | ✅ Working | +| Channel management | ✅ Working | +| Guilds & roles | ✅ Working | +| Direct Messages | ✅ Working | +| Friends & presence | ✅ Working | +| Voice (send/receive) | 🔧 Partial (audio pipeline) | +| Encryption | ✅ ChaCha20-Poly1305 + X25519 | +| In-game overlay | 🔲 Phase 2 | +| Mobile client | 🔲 Phase 3 | -| Component | Status | -|----------------|--------| -| Gateway | TCP listener, LNEx handshake, channels, chat, SQLite | -| Voice node | UDP relay, voice packet routing | -| Desktop client | egui UI, net layer, audio pipeline (partial) | -| LNEx protocol | Specified and implemented (JSON in Phase 1) | -| Federation | Planned (Phase 3) | -| Mobile client | Planned (Phase 3) | +See [docs/00-status.md](docs/00-status.md) for a full breakdown. -Traffic in v0.1.x is **unencrypted plaintext**. Do not use in production. +--- -## Running locally +## Quick start -Requires Rust 1.85+. +**Requirements:** Rust 1.85+, a running [VNOX Server](https://github.com/loki5512344/Vnox) -```sh -# terminal 1 - gateway +```bash +# Terminal 1 — gateway cargo run -p vnox-gateway -- --config dev/config.toml -# terminal 2 - voice node +# Terminal 2 — voice node cargo run -p vnox-voice-node -- --config dev/config.toml -# terminal 3 - client +# Terminal 3 — client cargo run -p vnox-client ``` -The client connects to `127.0.0.1:7600` by default (editable in the UI). -Config details: [dev/README.md](dev/README.md). +The client connects to `127.0.0.1:7600` by default. You can change the address in the UI on the connect screen. ### Opus on Windows -`audiopus_sys` builds libopus from source via CMake. -CMake 4.x requires a policy flag, already set in `.cargo/config.toml`: +`audiopus_sys` builds libopus from source via CMake. CMake 4.x policy flag is already set in `.cargo/config.toml` — no manual steps needed. -```toml -[env] -CMAKE_POLICY_VERSION_MINIMUM = "3.5" +--- + +## Tech stack + +| Layer | Technology | +|-------------|--------------------------------| +| UI | Slint | +| Networking | Tokio (async TCP + UDP) | +| Audio | cpal + rodio + opus + rnnoise | +| Identity | ed25519-dalek + x25519-dalek | +| Crypto | ChaCha20-Poly1305 + Argon2id | +| Protocol | LNEx v1 (JSON, Phase 1) | + +--- + +## Project structure + +``` +src/ +├── main.rs # Entry point +├── ui/ # Slint UI (slint files + Rust glue) +├── net/ # LNEx TCP + UDP networking +│ ├── crypto/ # Session encryption +│ ├── framing/ # Packet read/write +│ ├── voice/ # UDP voice send/recv +│ └── session/ # Connection lifecycle, reconnection +├── audio/ # Audio pipeline +│ ├── capture.rs # Mic → Opus encode +│ ├── playback.rs # Opus decode → speaker +│ ├── config.rs # Bitrate, VAD, jitter settings +│ └── processing/ # VAD, noise suppression +├── identity_vault.rs # Argon2id-encrypted keyfile +├── app.rs # UI state and event dispatch +└── jitter/ # Jitter buffer ``` -No manual steps needed. +--- ## License -GPL-3.0. See [docs/LICENSE.md](docs/LICENSE.md). - -The LNEx protocol specification is CC0 (public domain). +Client code: **GPL-3.0** — see [LICENSE](LICENSE) +LNEx protocol specification: **CC0** (public domain) diff --git a/src/net/internal/dispatch/channel.rs b/src/net/internal/dispatch/channel.rs index 2cf8f30..cb914e6 100644 --- a/src/net/internal/dispatch/channel.rs +++ b/src/net/internal/dispatch/channel.rs @@ -25,6 +25,7 @@ pub async fn handle(pid: u16, payload: &[u8], tx: &mpsc::Sender) -> Re }) .collect(), voice_endpoint: p.voice_endpoint, + guild_id: p.guild_id, }) .await; } @@ -35,6 +36,7 @@ pub async fn handle(pid: u16, payload: &[u8], tx: &mpsc::Sender) -> Re channel_id: p.channel_id, channel_name: p.channel_name, kind: p.kind, + guild_id: p.guild_id, }) .await; } @@ -57,6 +59,7 @@ pub async fn handle(pid: u16, payload: &[u8], tx: &mpsc::Sender) -> Re channel_id: c.channel_id, channel_name: c.channel_name, kind: c.kind, + guild_id: c.guild_id, }) .collect(), }) diff --git a/src/net/payloads/channel.rs b/src/net/payloads/channel.rs index 201da86..6b3927c 100644 --- a/src/net/payloads/channel.rs +++ b/src/net/payloads/channel.rs @@ -54,6 +54,8 @@ pub struct ChannelStatePayload { pub kind: String, pub members: Vec, pub voice_endpoint: String, + #[serde(default)] + pub guild_id: Option, } #[derive(Serialize, Deserialize, Debug, Clone)] @@ -81,6 +83,8 @@ pub struct ChannelListItem { pub channel_id: String, pub channel_name: String, pub kind: String, + #[serde(default)] + pub guild_id: Option, } #[derive(Serialize, Deserialize, Debug, Clone)] diff --git a/src/net/session/handlers/core.rs b/src/net/session/handlers/core.rs index 40eab47..3071355 100644 --- a/src/net/session/handlers/core.rs +++ b/src/net/session/handlers/core.rs @@ -102,12 +102,12 @@ pub async fn session_loop( framing::io::write_encrypted(&mut stream, PID_LEAVE_CHANNEL, &mut seq, &serde_json::to_vec(&LeaveChannelPayload { channel_id })?, &crypto).await?; } - Some(NetCommand::ChannelCreate { channel_id, channel_name, kind }) => { + Some(NetCommand::ChannelCreate { channel_id, channel_name, kind, guild_id }) => { let payload = serde_json::to_vec(&ChannelCreatePayload { channel_id, channel_name, kind, - guild_id: None, + guild_id, })?; framing::io::write_encrypted(&mut stream, PID_CHANNEL_CREATE, &mut seq, &payload, &crypto).await?; } diff --git a/src/net/types/command.rs b/src/net/types/command.rs index 8d7d7ad..a23b504 100644 --- a/src/net/types/command.rs +++ b/src/net/types/command.rs @@ -14,6 +14,7 @@ pub enum NetCommand { channel_id: String, channel_name: String, kind: String, + guild_id: Option, }, ChannelDelete { channel_id: String, diff --git a/src/net/types/event.rs b/src/net/types/event.rs index 6a4bbcd..cd8d359 100644 --- a/src/net/types/event.rs +++ b/src/net/types/event.rs @@ -23,11 +23,13 @@ pub enum NetEvent { kind: String, members: Vec, voice_endpoint: String, + guild_id: Option, }, ChannelCreated { channel_id: String, channel_name: String, kind: String, + guild_id: Option, }, ChannelDeleted { channel_id: String, diff --git a/src/net/types/info.rs b/src/net/types/info.rs index 8e803e1..10c38ca 100644 --- a/src/net/types/info.rs +++ b/src/net/types/info.rs @@ -10,6 +10,7 @@ pub struct ChannelListItem { pub channel_id: String, pub channel_name: String, pub kind: String, + pub guild_id: Option, } #[derive(Debug, Clone)] diff --git a/src/ui/bridge.rs b/src/ui/bridge.rs index 7e98c67..bfade3e 100644 --- a/src/ui/bridge.rs +++ b/src/ui/bridge.rs @@ -69,6 +69,10 @@ fn sync_sidebar(window: &MainWindow, state: &UiState, own_nickname: &str) { let channels: Vec = state .channels .iter() + .filter(|c| match guild_id { + None => true, + Some(gid) => c.guild_id.as_deref() == Some(gid) || c.guild_id.is_none(), + }) .map(|c| ChannelItem { channel_id: SharedString::from(c.id.as_str()), name: SharedString::from(c.name.as_str()), diff --git a/src/ui/slint/settings.slint b/src/ui/slint/settings.slint index a692d67..0f6fb3b 100644 --- a/src/ui/slint/settings.slint +++ b/src/ui/slint/settings.slint @@ -60,21 +60,21 @@ export component SettingsWindow { background: active-tab == "audio" ? Theme.accent : Theme.bg-interactive; border-radius: Theme.radius; TouchArea { clicked => { active-tab = "audio"; } } - Text { text: "Audio"; color: Theme.text-primary; font-size: 13px; padding-left: 12px; padding-right: 12px; horizontal-alignment: center; vertical-alignment: center; } + Text { text: "Audio"; color: Theme.text-primary; font-size: 13px; horizontal-alignment: center; vertical-alignment: center; } } Rectangle { height: 28px; background: active-tab == "network" ? Theme.accent : Theme.bg-interactive; border-radius: Theme.radius; TouchArea { clicked => { active-tab = "network"; } } - Text { text: "Network"; color: Theme.text-primary; font-size: 13px; padding-left: 12px; padding-right: 12px; horizontal-alignment: center; vertical-alignment: center; } + Text { text: "Network"; color: Theme.text-primary; font-size: 13px; horizontal-alignment: center; vertical-alignment: center; } } Rectangle { height: 28px; background: active-tab == "identity" ? Theme.accent : Theme.bg-interactive; border-radius: Theme.radius; TouchArea { clicked => { active-tab = "identity"; } } - Text { text: "Identity"; color: Theme.text-primary; font-size: 13px; padding-left: 12px; padding-right: 12px; horizontal-alignment: center; vertical-alignment: center; } + Text { text: "Identity"; color: Theme.text-primary; font-size: 13px; horizontal-alignment: center; vertical-alignment: center; } } HorizontalLayout { horizontal-stretch: 1; } Rectangle { diff --git a/src/ui/state/mod.rs b/src/ui/state/mod.rs index 2dd6eba..8a0d30a 100644 --- a/src/ui/state/mod.rs +++ b/src/ui/state/mod.rs @@ -44,30 +44,35 @@ impl UiState { id: "general".into(), name: "general".into(), kind: "text".into(), + guild_id: None, members: Vec::new(), }, Channel { id: "dev-talk".into(), name: "dev-talk".into(), kind: "text".into(), + guild_id: None, members: Vec::new(), }, Channel { id: "plugins".into(), name: "plugins".into(), kind: "text".into(), + guild_id: None, members: Vec::new(), }, Channel { id: "lobby".into(), name: "lobby".into(), kind: "voice".into(), + guild_id: None, members: Vec::new(), }, Channel { id: "gaming".into(), name: "gaming".into(), kind: "voice".into(), + guild_id: None, members: Vec::new(), }, ]; diff --git a/src/ui/state/models.rs b/src/ui/state/models.rs index c279dd3..57e0dfc 100644 --- a/src/ui/state/models.rs +++ b/src/ui/state/models.rs @@ -5,6 +5,7 @@ pub struct Channel { pub id: String, pub name: String, pub kind: String, + pub guild_id: Option, pub members: Vec, } diff --git a/src/ui/update.rs b/src/ui/update.rs index a31409b..415aa66 100644 --- a/src/ui/update.rs +++ b/src/ui/update.rs @@ -311,6 +311,7 @@ fn chat_handle(ui: &mut UiState, net: &NetHandle, event: NetEvent) { channel_name, kind, members, + guild_id, .. } => { let mems: Vec = members @@ -322,11 +323,15 @@ fn chat_handle(ui: &mut UiState, net: &NetHandle, event: NetEvent) { .collect(); if let Some(ch) = ui.channels.iter_mut().find(|c| c.id == channel_id) { ch.members = mems; + if guild_id.is_some() { + ch.guild_id = guild_id.clone(); + } } else { ui.channels.push(crate::ui::state::Channel { id: channel_id.clone(), name: channel_name, kind: kind.clone(), + guild_id: guild_id.clone(), members: mems, }); } @@ -347,12 +352,14 @@ fn chat_handle(ui: &mut UiState, net: &NetHandle, event: NetEvent) { channel_id, channel_name, kind, + guild_id, } => { if !ui.channels.iter().any(|c| c.id == channel_id) { ui.channels.push(crate::ui::state::Channel { id: channel_id, name: channel_name, kind, + guild_id, members: Vec::new(), }); } @@ -381,6 +388,7 @@ fn chat_handle(ui: &mut UiState, net: &NetHandle, event: NetEvent) { id: c.channel_id, name: c.channel_name, kind: c.kind, + guild_id: c.guild_id, members, } })