Initial commit: split VNOX monorepo into VNOX-Client
Desktop client with egui + wgpu UI, audio pipeline (cpal/opus/rodio), networking (LNEx protocol), and identity management with encrypted vault. Jitter buffer module extracted from voice-node for standalone operation.
This commit is contained in:
commit
bf0b237afd
112 changed files with 19113 additions and 0 deletions
152
src/ui/state/default.rs
Normal file
152
src/ui/state/default.rs
Normal file
|
|
@ -0,0 +1,152 @@
|
|||
use std::collections::{HashMap, HashSet};
|
||||
use std::time::Instant;
|
||||
|
||||
use super::models::{default_bookmarks, now_utc_hms};
|
||||
use super::types::{ConnState, UiState, VoiceUiState};
|
||||
|
||||
impl Default for UiState {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
conn: ConnState::Disconnected,
|
||||
connect_input: "127.0.0.1:7600".into(),
|
||||
bookmarks: default_bookmarks(),
|
||||
selected_bookmark: 0,
|
||||
status_msg: None,
|
||||
channels: Vec::new(),
|
||||
active_channel: None,
|
||||
messages: Default::default(),
|
||||
chat_input: String::new(),
|
||||
editing_message_id: None,
|
||||
replying_to: None,
|
||||
context_menu_message_id: None,
|
||||
scroll_bottom: false,
|
||||
voice: VoiceUiState::Off,
|
||||
voice_joined_at: None,
|
||||
user_names: Default::default(),
|
||||
server_channel: None,
|
||||
rtt_ms: None,
|
||||
|
||||
settings_open: false,
|
||||
settings_page: Default::default(),
|
||||
|
||||
mic_enabled: true,
|
||||
deafen: false,
|
||||
noise_suppress: true,
|
||||
echo_cancel: true,
|
||||
vad_mode: 0,
|
||||
vad_threshold: 40.0,
|
||||
voice_bitrate: 64.0,
|
||||
packet_interval: 1,
|
||||
mic_device: 0,
|
||||
input_volume: 80.0,
|
||||
input_device_labels: vec!["Default".into()],
|
||||
input_device_metas: Vec::new(),
|
||||
output_device_labels: vec!["Default".into()],
|
||||
output_device_metas: Vec::new(),
|
||||
last_device_scan: None,
|
||||
|
||||
output_device: 0,
|
||||
output_volume: 90.0,
|
||||
jitter_size: 1,
|
||||
adaptive_buffer: true,
|
||||
|
||||
overlay_enabled: false,
|
||||
overlay_speakers: true,
|
||||
overlay_latency: true,
|
||||
|
||||
relay_enabled: true,
|
||||
relay_address: "relay.nightcore.lnex".into(),
|
||||
auto_relay: true,
|
||||
force_relay: false,
|
||||
udp_port: "7700".into(),
|
||||
auto_reconnect: true,
|
||||
|
||||
ui_scale: 100.0,
|
||||
|
||||
kb_ptt: "mouse4".into(),
|
||||
kb_mute: "ctrl+m".into(),
|
||||
kb_deafen: "ctrl+d".into(),
|
||||
kb_overlay: "ctrl+shift+o".into(),
|
||||
|
||||
log_level: 0,
|
||||
log_voice_packets: false,
|
||||
show_packet_stats: false,
|
||||
disable_encryption: false,
|
||||
|
||||
guilds: Vec::new(),
|
||||
active_guild_id: None,
|
||||
presences: Default::default(),
|
||||
presence_status: Some("online".into()),
|
||||
custom_status: None,
|
||||
activity_type: None,
|
||||
activity_text: None,
|
||||
friends: Vec::new(),
|
||||
pending_friend_requests: Vec::new(),
|
||||
blocked_users: Vec::new(),
|
||||
block_input: None,
|
||||
block_list_fetched_at: None,
|
||||
dm_conversations: Vec::new(),
|
||||
dm_messages: Default::default(),
|
||||
active_dm_id: None,
|
||||
new_dm_input: None,
|
||||
|
||||
add_server_open: false,
|
||||
add_server_input: String::new(),
|
||||
invite_accept_open: false,
|
||||
invite_accept_code: String::new(),
|
||||
create_guild_open: false,
|
||||
create_guild_input: String::new(),
|
||||
|
||||
create_channel_open: false,
|
||||
create_channel_input: String::new(),
|
||||
create_channel_kind: "text".into(),
|
||||
|
||||
vault_set_open: false,
|
||||
vault_remove_open: false,
|
||||
vault_passphrase_input: String::new(),
|
||||
vault_passphrase_confirm: String::new(),
|
||||
vault_error: None,
|
||||
|
||||
export_keyfile_open: false,
|
||||
export_keyfile_pass: String::new(),
|
||||
export_keyfile_confirm: String::new(),
|
||||
export_keyfile_output: String::new(),
|
||||
export_keyfile_error: None,
|
||||
import_keyfile_open: false,
|
||||
import_keyfile_input: String::new(),
|
||||
import_keyfile_pass: String::new(),
|
||||
import_keyfile_error: None,
|
||||
|
||||
audit_log_open: false,
|
||||
audit_log_guild_id: None,
|
||||
audit_log_entries: Vec::new(),
|
||||
|
||||
guild_members_open: false,
|
||||
guild_member_list_guild_id: None,
|
||||
guild_member_list: Vec::new(),
|
||||
guild_role_list_guild_id: None,
|
||||
guild_role_list: Vec::new(),
|
||||
|
||||
add_friend_open: false,
|
||||
add_friend_input: String::new(),
|
||||
|
||||
dm_search_query: String::new(),
|
||||
user_colors: HashMap::new(),
|
||||
per_user_volumes: Default::default(),
|
||||
kb_listening: None,
|
||||
|
||||
private_mode: false,
|
||||
|
||||
collapsed_categories: HashSet::new(),
|
||||
startup_time_str: now_utc_hms(),
|
||||
typing_users: HashMap::new(),
|
||||
last_typing_send: Instant::now(),
|
||||
read_receipts: HashMap::new(),
|
||||
friends_tab: "all".into(),
|
||||
local_speaking: false,
|
||||
remote_speaking: false,
|
||||
last_remote_speaker_id: String::new(),
|
||||
last_voice_packet_ms: 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
131
src/ui/state/mod.rs
Normal file
131
src/ui/state/mod.rs
Normal file
|
|
@ -0,0 +1,131 @@
|
|||
mod default;
|
||||
mod models;
|
||||
mod types;
|
||||
|
||||
pub use models::{
|
||||
Channel, ChatMessage, DmConversation, FriendState, GuildState, NodeBookmark, SettingsPage,
|
||||
};
|
||||
pub use types::{ConnState, TYPING_TIMEOUT, UiState, VoiceUiState};
|
||||
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use models::bookmark_label_from_address;
|
||||
|
||||
impl UiState {
|
||||
pub fn refresh_audio_devices(&mut self) {
|
||||
let now = Instant::now();
|
||||
if let Some(last) = self.last_device_scan
|
||||
&& now.duration_since(last) < Duration::from_millis(2500)
|
||||
{
|
||||
return;
|
||||
}
|
||||
self.last_device_scan = Some(now);
|
||||
|
||||
match vnox_client::audio::devices::enumerate() {
|
||||
Ok(lists) => {
|
||||
self.input_device_labels = lists.input_labels;
|
||||
self.input_device_metas = lists.input_devices;
|
||||
self.output_device_labels = lists.output_labels;
|
||||
self.output_device_metas = lists.output_devices;
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!("audio device enumeration: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
if self.input_device_labels.is_empty() {
|
||||
self.input_device_labels.push("Default".into());
|
||||
}
|
||||
if self.output_device_labels.is_empty() {
|
||||
self.output_device_labels.push("Default".into());
|
||||
}
|
||||
|
||||
self.mic_device = self
|
||||
.mic_device
|
||||
.min(self.input_device_labels.len().saturating_sub(1));
|
||||
self.output_device = self
|
||||
.output_device
|
||||
.min(self.output_device_labels.len().saturating_sub(1));
|
||||
}
|
||||
|
||||
pub fn force_refresh_audio_devices(&mut self) {
|
||||
self.last_device_scan = None;
|
||||
self.refresh_audio_devices();
|
||||
}
|
||||
|
||||
pub fn remove_bookmark(&mut self, index: usize) {
|
||||
if index < self.bookmarks.len() {
|
||||
self.bookmarks.remove(index);
|
||||
if self.selected_bookmark >= self.bookmarks.len() && !self.bookmarks.is_empty() {
|
||||
self.selected_bookmark = self.bookmarks.len() - 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn add_bookmark_from_input(&mut self) -> bool {
|
||||
let address = self.connect_input.trim().to_string();
|
||||
if address.is_empty() || self.bookmarks.iter().any(|b| b.address == address) {
|
||||
return false;
|
||||
}
|
||||
let label = bookmark_label_from_address(&address);
|
||||
self.bookmarks.push(NodeBookmark { label, address });
|
||||
self.selected_bookmark = self.bookmarks.len().saturating_sub(1);
|
||||
true
|
||||
}
|
||||
|
||||
pub fn voice_elapsed(&self) -> Option<String> {
|
||||
let secs = self.voice_joined_at?.elapsed().as_secs();
|
||||
Some(format!("{}:{:02}", secs / 60, secs % 60))
|
||||
}
|
||||
|
||||
pub fn nick_for<'a>(&'a self, sender_id: &'a str) -> &'a str {
|
||||
self.user_names
|
||||
.get(sender_id)
|
||||
.map(|s| s.as_str())
|
||||
.unwrap_or(sender_id)
|
||||
}
|
||||
|
||||
pub fn active_voice_channel(&self) -> Option<&Channel> {
|
||||
self.channels
|
||||
.iter()
|
||||
.find(|c| matches!(&self.voice, VoiceUiState::Active { channel } if channel == &c.id))
|
||||
}
|
||||
|
||||
pub fn seed_default_channels(&mut self) {
|
||||
if !self.channels.is_empty() {
|
||||
return;
|
||||
}
|
||||
self.channels = vec![
|
||||
Channel {
|
||||
id: "general".into(),
|
||||
name: "general".into(),
|
||||
kind: "text".into(),
|
||||
members: Vec::new(),
|
||||
},
|
||||
Channel {
|
||||
id: "dev-talk".into(),
|
||||
name: "dev-talk".into(),
|
||||
kind: "text".into(),
|
||||
members: Vec::new(),
|
||||
},
|
||||
Channel {
|
||||
id: "plugins".into(),
|
||||
name: "plugins".into(),
|
||||
kind: "text".into(),
|
||||
members: Vec::new(),
|
||||
},
|
||||
Channel {
|
||||
id: "lobby".into(),
|
||||
name: "lobby".into(),
|
||||
kind: "voice".into(),
|
||||
members: Vec::new(),
|
||||
},
|
||||
Channel {
|
||||
id: "gaming".into(),
|
||||
name: "gaming".into(),
|
||||
kind: "voice".into(),
|
||||
members: Vec::new(),
|
||||
},
|
||||
];
|
||||
}
|
||||
}
|
||||
92
src/ui/state/models.rs
Normal file
92
src/ui/state/models.rs
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
use std::collections::HashMap;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Default)]
|
||||
pub enum SettingsPage {
|
||||
Identity,
|
||||
#[default]
|
||||
Voice,
|
||||
AudioOutput,
|
||||
Network,
|
||||
Overlay,
|
||||
Appearance,
|
||||
Keybinds,
|
||||
Plugins,
|
||||
Advanced,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Channel {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub kind: String,
|
||||
pub members: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ChatMessage {
|
||||
pub message_id: String,
|
||||
pub sender_id: String,
|
||||
pub content: String,
|
||||
pub timestamp: i64,
|
||||
pub edited: bool,
|
||||
pub reactions: HashMap<String, Vec<String>>,
|
||||
/// Optional message_id this message is replying to.
|
||||
#[cfg_attr(not(test), allow(dead_code))]
|
||||
pub reply_to: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct DmConversation {
|
||||
pub dm_id: String,
|
||||
pub other_user_id: String,
|
||||
pub other_nickname: String,
|
||||
pub unread_count: u32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct GuildState {
|
||||
pub guild_id: String,
|
||||
pub name: String,
|
||||
pub owner_id: String,
|
||||
pub member_count: u32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct FriendState {
|
||||
pub user_id: String,
|
||||
pub nickname: String,
|
||||
pub status: String,
|
||||
pub since: i64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct NodeBookmark {
|
||||
pub label: String,
|
||||
pub address: String,
|
||||
}
|
||||
|
||||
pub(crate) fn default_bookmarks() -> Vec<NodeBookmark> {
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
pub(crate) fn bookmark_label_from_address(address: &str) -> String {
|
||||
address
|
||||
.split(':')
|
||||
.next()
|
||||
.filter(|s| !s.is_empty())
|
||||
.unwrap_or(address)
|
||||
.chars()
|
||||
.take(12)
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub(crate) fn now_utc_hms() -> String {
|
||||
let secs = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_secs())
|
||||
.unwrap_or(0);
|
||||
let h = (secs % 86400) / 3600;
|
||||
let m = (secs % 3600) / 60;
|
||||
let s = secs % 60;
|
||||
format!("{h:02}:{m:02}:{s:02}")
|
||||
}
|
||||
205
src/ui/state/types.rs
Normal file
205
src/ui/state/types.rs
Normal file
|
|
@ -0,0 +1,205 @@
|
|||
use std::collections::{HashMap, HashSet};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
pub const TYPING_TIMEOUT: Duration = Duration::from_secs(4);
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Default)]
|
||||
pub enum VoiceUiState {
|
||||
#[default]
|
||||
Off,
|
||||
Active {
|
||||
channel: String,
|
||||
},
|
||||
Error(String),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum ConnState {
|
||||
Disconnected,
|
||||
Connecting,
|
||||
Reconnecting {
|
||||
attempt: u32,
|
||||
},
|
||||
Connected {
|
||||
session_id: String,
|
||||
node_name: String,
|
||||
},
|
||||
}
|
||||
|
||||
pub struct UiState {
|
||||
pub conn: ConnState,
|
||||
pub connect_input: String,
|
||||
pub bookmarks: Vec<super::NodeBookmark>,
|
||||
pub selected_bookmark: usize,
|
||||
pub status_msg: Option<String>,
|
||||
|
||||
pub channels: Vec<super::Channel>,
|
||||
pub active_channel: Option<String>,
|
||||
pub messages: HashMap<String, Vec<super::ChatMessage>>,
|
||||
pub chat_input: String,
|
||||
pub editing_message_id: Option<String>,
|
||||
/// When set, the next sent message will be a reply to this message_id.
|
||||
pub replying_to: Option<(String, String)>, // (message_id, sender_id)
|
||||
/// Message currently shown in the context menu (right-click).
|
||||
pub context_menu_message_id: Option<String>,
|
||||
pub scroll_bottom: bool,
|
||||
|
||||
pub voice: VoiceUiState,
|
||||
pub voice_joined_at: Option<Instant>,
|
||||
|
||||
pub user_names: HashMap<String, String>,
|
||||
pub server_channel: Option<String>,
|
||||
pub rtt_ms: Option<u32>,
|
||||
|
||||
pub settings_open: bool,
|
||||
pub settings_page: super::SettingsPage,
|
||||
|
||||
pub mic_enabled: bool,
|
||||
pub deafen: bool,
|
||||
pub noise_suppress: bool,
|
||||
pub echo_cancel: bool,
|
||||
pub vad_mode: usize,
|
||||
pub vad_threshold: f32,
|
||||
pub voice_bitrate: f32,
|
||||
pub packet_interval: usize,
|
||||
pub mic_device: usize,
|
||||
pub input_volume: f32,
|
||||
pub input_device_labels: Vec<String>,
|
||||
pub output_device_labels: Vec<String>,
|
||||
pub input_device_metas: Vec<vnox_client::audio::devices::DeviceLabel>,
|
||||
pub output_device_metas: Vec<vnox_client::audio::devices::DeviceLabel>,
|
||||
pub last_device_scan: Option<Instant>,
|
||||
|
||||
pub output_device: usize,
|
||||
pub output_volume: f32,
|
||||
pub jitter_size: usize,
|
||||
pub adaptive_buffer: bool,
|
||||
|
||||
pub overlay_enabled: bool,
|
||||
pub overlay_speakers: bool,
|
||||
pub overlay_latency: bool,
|
||||
|
||||
pub relay_enabled: bool,
|
||||
pub relay_address: String,
|
||||
pub auto_relay: bool,
|
||||
pub force_relay: bool,
|
||||
pub udp_port: String,
|
||||
pub auto_reconnect: bool,
|
||||
|
||||
pub ui_scale: f32,
|
||||
|
||||
pub kb_ptt: String,
|
||||
pub kb_mute: String,
|
||||
pub kb_deafen: String,
|
||||
pub kb_overlay: String,
|
||||
|
||||
pub log_level: usize,
|
||||
pub log_voice_packets: bool,
|
||||
pub show_packet_stats: bool,
|
||||
pub disable_encryption: bool,
|
||||
|
||||
pub private_mode: bool,
|
||||
|
||||
pub guilds: Vec<super::GuildState>,
|
||||
pub active_guild_id: Option<String>,
|
||||
|
||||
pub presences: HashMap<String, crate::net::PresenceInfo>,
|
||||
pub presence_status: Option<String>,
|
||||
/// Free-form custom status text ("making tea").
|
||||
pub custom_status: Option<String>,
|
||||
/// Activity type ("playing", "listening", "watching", "streaming").
|
||||
pub activity_type: Option<String>,
|
||||
/// Activity text (e.g. "Counter-Strike 2").
|
||||
pub activity_text: Option<String>,
|
||||
|
||||
pub friends: Vec<super::FriendState>,
|
||||
pub pending_friend_requests: Vec<super::FriendState>,
|
||||
/// Blocked user IDs.
|
||||
pub blocked_users: Vec<String>,
|
||||
/// Input buffer for the block-user text field.
|
||||
pub block_input: Option<String>,
|
||||
/// When the blocked list was last fetched from the server.
|
||||
pub block_list_fetched_at: Option<Instant>,
|
||||
|
||||
pub dm_conversations: Vec<super::DmConversation>,
|
||||
pub dm_messages: HashMap<String, Vec<super::ChatMessage>>,
|
||||
pub active_dm_id: Option<String>,
|
||||
pub new_dm_input: Option<String>,
|
||||
pub dm_search_query: String,
|
||||
|
||||
pub user_colors: HashMap<String, eframe::egui::Color32>,
|
||||
|
||||
pub per_user_volumes: HashMap<String, f32>,
|
||||
|
||||
pub add_server_open: bool,
|
||||
pub add_server_input: String,
|
||||
pub invite_accept_open: bool,
|
||||
pub invite_accept_code: String,
|
||||
|
||||
pub create_guild_open: bool,
|
||||
pub create_guild_input: String,
|
||||
|
||||
/// "Create a Channel" popup state.
|
||||
pub create_channel_open: bool,
|
||||
pub create_channel_input: String,
|
||||
pub create_channel_kind: String,
|
||||
|
||||
/// Vault UI state — set/remove passphrase modals.
|
||||
pub vault_set_open: bool,
|
||||
pub vault_remove_open: bool,
|
||||
pub vault_passphrase_input: String,
|
||||
pub vault_passphrase_confirm: String,
|
||||
pub vault_error: Option<String>,
|
||||
|
||||
/// Export/import keyfile UI state.
|
||||
pub export_keyfile_open: bool,
|
||||
pub export_keyfile_pass: String,
|
||||
pub export_keyfile_confirm: String,
|
||||
pub export_keyfile_output: String,
|
||||
pub export_keyfile_error: Option<String>,
|
||||
pub import_keyfile_open: bool,
|
||||
pub import_keyfile_input: String,
|
||||
pub import_keyfile_pass: String,
|
||||
pub import_keyfile_error: Option<String>,
|
||||
|
||||
/// Audit log viewer state.
|
||||
pub audit_log_open: bool,
|
||||
pub audit_log_guild_id: Option<String>,
|
||||
pub audit_log_entries: Vec<vnox_client::net::payloads::AuditLogEntryPayload>,
|
||||
|
||||
/// Guild member list viewer state.
|
||||
pub guild_members_open: bool,
|
||||
pub guild_member_list_guild_id: Option<String>,
|
||||
pub guild_member_list: Vec<vnox_client::net::payloads::GuildMemberInfoPayload>,
|
||||
|
||||
/// Guild role list state.
|
||||
pub guild_role_list_guild_id: Option<String>,
|
||||
pub guild_role_list: Vec<vnox_client::net::payloads::GuildRoleInfoPayload>,
|
||||
|
||||
pub add_friend_open: bool,
|
||||
pub add_friend_input: String,
|
||||
|
||||
pub kb_listening: Option<String>,
|
||||
|
||||
pub collapsed_categories: HashSet<String>,
|
||||
|
||||
pub startup_time_str: String,
|
||||
|
||||
pub typing_users: HashMap<String, Vec<(String, Instant)>>,
|
||||
pub last_typing_send: Instant,
|
||||
|
||||
pub read_receipts: HashMap<String, HashMap<String, String>>,
|
||||
|
||||
/// Active tab in the Friends panel ("online" / "all" / "pending" / "blocked").
|
||||
pub friends_tab: String,
|
||||
|
||||
/// True when local user is transmitting voice (PTT held or VAD above threshold).
|
||||
pub local_speaking: bool,
|
||||
/// True when a remote peer's voice packet arrived in the last ~500ms.
|
||||
pub remote_speaking: bool,
|
||||
/// Most recently received voice-packet sender's user_id (hex pubkey).
|
||||
/// Empty when no remote user has spoken recently.
|
||||
pub last_remote_speaker_id: String,
|
||||
/// UNIX millisecond timestamp of the last voice packet from any remote peer.
|
||||
pub last_voice_packet_ms: u64,
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue