From cbe0019d7c437e0453c2ac922a38db230238c816 Mon Sep 17 00:00:00 2001 From: Klesti Selimaj Date: Fri, 28 Aug 2026 01:32:59 +0200 Subject: [PATCH 01/12] Device switching --- src-tauri/src/commands/config.rs | 31 +++++++++++++++++++++++++++++-- src/app/app.ts | 3 +++ 2 files changed, 32 insertions(+), 2 deletions(-) diff --git a/src-tauri/src/commands/config.rs b/src-tauri/src/commands/config.rs index cc7cc22..79c23bd 100644 --- a/src-tauri/src/commands/config.rs +++ b/src-tauri/src/commands/config.rs @@ -4,6 +4,8 @@ use std::fs; use std::sync::Mutex; use tauri::{AppHandle, Manager, State}; +use crate::commands::audio::VoiceState; + #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct Config { @@ -42,8 +44,33 @@ fn config_file_path(app: &AppHandle) -> Result { } #[tauri::command] -pub fn update_config(state: State, config: Config) -> Result<(), String> { - *state.0.lock().unwrap() = config; +pub fn update_config( + state: State, + voice_state: State<'_, VoiceState>, + mut config: Config, +) -> Result<(), String> { + let mut state_lock = state.0.lock().unwrap(); + + std::mem::swap(&mut *state_lock, &mut config); + + let Some(sesh) = &mut *voice_state.inner.session.lock().unwrap() else { + return Ok(()); + }; + + if state_lock.input_device_name != config.input_device_name { + sesh.update_input_device( + state_lock.input_device_name.clone(), + voice_state.inner.clone(), + )?; + } + + if state_lock.output_device_name != config.output_device_name { + sesh.update_output_device( + state_lock.output_device_name.clone(), + voice_state.inner.clone(), + )?; + } + Ok(()) } diff --git a/src/app/app.ts b/src/app/app.ts index 55efdec..58924f1 100644 --- a/src/app/app.ts +++ b/src/app/app.ts @@ -8,6 +8,7 @@ import axios from "axios"; import { Account, AccountsFile, getAccounts } from "@/lib/accounts"; import { Page } from "@/components/page/PageView"; import { ClientMethod } from "./protocol"; +import { Config, getConfig } from "@/lib/config"; ed.hashes.sha512 = sha512; @@ -31,6 +32,7 @@ export default class Enclave

{ public isSettingsOpen: boolean; public forceRender: () => void; public page?: P; + public config?: Config; public constructor() { this.serverList = {}; @@ -41,6 +43,7 @@ export default class Enclave

{ public async init() { this.serverList = await getServerList(); this.accounts = await getAccounts(); + this.config = await getConfig(); } public getAccount(): Account | null { From 3a83226fd63cf1ad875c40490eca73329370f7ff Mon Sep 17 00:00:00 2001 From: Klesti Selimaj Date: Fri, 28 Aug 2026 01:35:35 +0200 Subject: [PATCH 02/12] Improved resampler --- src-tauri/Cargo.lock | 143 ++++++++++++++- src-tauri/Cargo.toml | 2 +- src-tauri/src/commands/audio.rs | 311 ++++++++++++++++++-------------- 3 files changed, 313 insertions(+), 143 deletions(-) diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index e5acfe6..06091f3 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -229,6 +229,39 @@ version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" +[[package]] +name = "audio-codec-algorithms" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1254ebf6529f3763c491acfb5ab6e960809e3c75a38584cc664f8c5667fb7107" + +[[package]] +name = "audioadapter" +version = "5.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1292ef9edf681b7426ed089004b021a42896492f58bd0c909ad44e5faec9ac4" + +[[package]] +name = "audioadapter-buffers" +version = "5.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46289a81a3bfa26d0f8b2415f8ca9decde9a66308a150568af5c102381b3c8ce" +dependencies = [ + "audioadapter", + "audioadapter-sample", + "num-traits", +] + +[[package]] +name = "audioadapter-sample" +version = "5.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5258faecf4edbe35ec483bda9b1f7438e6b391891819269c6b5461aeaa0e8d7d" +dependencies = [ + "audio-codec-algorithms", + "num-traits", +] + [[package]] name = "autocfg" version = "1.5.1" @@ -962,8 +995,8 @@ name = "enclave" version = "0.1.0" dependencies = [ "cpal", - "resampler", "ringbuf", + "rubato", "serde", "serde_json", "tauri", @@ -2239,6 +2272,15 @@ version = "1.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" +[[package]] +name = "num-complex" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" +dependencies = [ + "num-traits", +] + [[package]] name = "num-conv" version = "0.2.2" @@ -2256,6 +2298,15 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "num-integer" +version = "0.1.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b" +dependencies = [ + "num-traits", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -2792,6 +2843,15 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c" +[[package]] +name = "primal-check" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc0d895b311e3af9902528fbb8f928688abbd95872819320517cc24ca6b2bd08" +dependencies = [ + "num-integer", +] + [[package]] name = "proc-macro-crate" version = "1.3.1" @@ -2890,6 +2950,15 @@ version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539" +[[package]] +name = "realfft" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f821338fddb99d089116342c46e9f1fbf3828dba077674613e734e01d6ea8677" +dependencies = [ + "rustfft", +] + [[package]] name = "redox_syscall" version = "0.5.18" @@ -2993,12 +3062,6 @@ dependencies = [ "web-sys", ] -[[package]] -name = "resampler" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "28fdbea87ff02ebbfd904079d1e52138c0c7fbaa3aaddca4a1b9c7d3f85749f2" - [[package]] name = "ringbuf" version = "0.5.1" @@ -3010,6 +3073,22 @@ dependencies = [ "portable-atomic-util", ] +[[package]] +name = "rubato" +version = "5.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7cb1ffaf8738df50aab642a7f6465df81c6ba9e2818268053487165298114be" +dependencies = [ + "audioadapter", + "audioadapter-buffers", + "num-complex", + "num-integer", + "num-traits", + "realfft", + "visibility", + "windowfunctions", +] + [[package]] name = "rustc-hash" version = "2.1.3" @@ -3025,6 +3104,20 @@ dependencies = [ "semver", ] +[[package]] +name = "rustfft" +version = "6.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21db5f9893e91f41798c88680037dba611ca6674703c1a18601b01a72c8adb89" +dependencies = [ + "num-complex", + "num-integer", + "num-traits", + "primal-check", + "strength_reduce", + "transpose", +] + [[package]] name = "rustix" version = "1.1.4" @@ -3429,6 +3522,12 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" +[[package]] +name = "strength_reduce" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe895eb47f22e2ddd4dabc02bce419d2e643c8e3b585c78158b349195bc24d82" + [[package]] name = "string_cache" version = "0.9.0" @@ -4183,6 +4282,16 @@ dependencies = [ "once_cell", ] +[[package]] +name = "transpose" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ad61aed86bc3faea4300c7aee358b4c6d0c8d6ccc36524c96e4c92ccf26e77e" +dependencies = [ + "num-integer", + "strength_reduce", +] + [[package]] name = "tray-icon" version = "0.24.2" @@ -4342,6 +4451,17 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "visibility" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d674d135b4a8c1d7e813e2f8d1c9a58308aee4a680323066025e53132218bd91" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "vswhom" version = "0.1.0" @@ -4612,6 +4732,15 @@ dependencies = [ "windows-version", ] +[[package]] +name = "windowfunctions" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90628d739333b7c5d2ee0b70210b97b8cddc38440c682c96fd9e2c24c2db5f3a" +dependencies = [ + "num-traits", +] + [[package]] name = "windows" version = "0.61.3" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 8b27ac7..6cdf2a9 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -23,6 +23,6 @@ tauri-plugin-opener = "2" serde = { version = "1", features = ["derive"] } serde_json = "1" cpal = "0.18.2" -resampler = "0.5.1" ringbuf = "0.5.1" +rubato = "5.0.0" diff --git a/src-tauri/src/commands/audio.rs b/src-tauri/src/commands/audio.rs index b8a2ac3..17f3eaf 100644 --- a/src-tauri/src/commands/audio.rs +++ b/src-tauri/src/commands/audio.rs @@ -19,20 +19,16 @@ use tauri::State; use crate::commands::config::ConfigState; -const PACKET_SAMPLES: usize = 960; // 20ms @ 48kHz +const TARGET_SAMPLE_RATE: u32 = 48_000; +const PACKET_SAMPLES: usize = 960; // 20ms @ 48kHz mono const HEADER_SIZE: usize = 4; const MAX_PACKET_SIZE: usize = 4096; -const INITIAL_PACKET_CUSHION: usize = 3; // ~60ms cushion +const INITIAL_PACKET_CUSHION: usize = 3; -// --- Voice Activity Detection (VAD) Settings --- const VAD_THRESHOLD: f32 = 0.01; const VAD_HANGOVER_FRAMES: usize = 10; -// ============================================================================ -// STATE & TYPES -// ============================================================================ - type AudioProducer = Arc>>>>>; type AudioConsumer = Arc>>>>>; @@ -60,6 +56,39 @@ pub struct VoiceSession { pub consumer_out: AudioConsumer, } +// Simple Linear Resampler for real-time audio conversion +struct LinearResampler { + phase: f64, +} + +impl LinearResampler { + fn new() -> Self { + Self { phase: 0.0 } + } + + /// Resamples dynamic buffers from `src_rate` to `dst_rate` + fn process(&mut self, input: &[f32], src_rate: u32, dst_rate: u32, output: &mut Vec) { + if src_rate == dst_rate { + output.extend_from_slice(input); + return; + } + + let ratio = src_rate as f64 / dst_rate as f64; + while self.phase < input.len() as f64 { + let idx = self.phase as usize; + let frac = (self.phase - idx as f64) as f32; + let next_idx = (idx + 1).min(input.len() - 1); + + let sample = input[idx] * (1.0 - frac) + input[next_idx] * frac; + output.push(sample); + + self.phase += ratio; + } + + self.phase -= input.len() as f64; + } +} + impl VoiceSession { pub fn update_input_device( &mut self, @@ -89,32 +118,7 @@ impl VoiceSession { .config(); let producer = Arc::clone(&self.producer_in); - let inner_clone = Arc::clone(&state_inner); - - let new_stream = device - .build_input_stream( - input_config, - move |data: &[f32], _| { - if let Ok(mut prod) = producer.lock() { - let _ = prod.push_slice(data); - } - }, - move |err| { - eprintln!("[vc] Input error: {err}. Attempting input stream recovery..."); - if let Ok(mut lock) = inner_clone.session.lock() { - if let Some(session) = lock.as_mut() { - let target_device = session.current_input_device.clone(); - if let Err(e) = - session.update_input_device(target_device, Arc::clone(&inner_clone)) - { - eprintln!("[vc] Input recovery failed: {e}"); - } - } - } - }, - None, - ) - .map_err(|e| e.to_string())?; + let new_stream = build_input_stream(&device, input_config, producer, state_inner)?; new_stream .play() @@ -167,9 +171,136 @@ impl VoiceSession { } } -// ============================================================================ -// DEVICES -// ============================================================================ +// Helper to build normalized Input Stream (Resampled & Downmixed to 48kHz Mono) +fn build_input_stream( + device: &cpal::Device, + config: cpal::StreamConfig, + producer: AudioProducer, + state_inner: Arc, +) -> Result { + let native_sample_rate = config.sample_rate; + let channels = config.channels as usize; + let mut resampler = LinearResampler::new(); + let mut mono_buffer = Vec::with_capacity(2048); + let mut resampled_buffer = Vec::with_capacity(2048); + + let inner_input_err = Arc::clone(&state_inner); + + device + .build_input_stream( + config, + move |data: &[f32], _| { + mono_buffer.clear(); + resampled_buffer.clear(); + + // Downmix channels to mono + for chunk in data.chunks_exact(channels) { + let sum: f32 = chunk.iter().sum(); + mono_buffer.push(sum / channels as f32); + } + + // Resample to 48kHz standard target + resampler.process( + &mono_buffer, + native_sample_rate, + TARGET_SAMPLE_RATE, + &mut resampled_buffer, + ); + + if let Ok(mut prod) = producer.lock() { + let _ = prod.push_slice(&resampled_buffer); + } + }, + move |err| { + eprintln!("[vc] Input error: {err}. Attempting recovery..."); + if let Ok(mut lock) = inner_input_err.session.lock() { + if let Some(session) = lock.as_mut() { + let target_device = session.current_input_device.clone(); + let _ = session + .update_input_device(target_device, Arc::clone(&inner_input_err)); + } + } + }, + None, + ) + .map_err(|e| e.to_string()) +} + +// Helper to build normalized Output Stream (48kHz Mono -> Device Native Channels & Rate) +fn build_output_stream( + device: &cpal::Device, + config: cpal::StreamConfig, + consumer: AudioConsumer, + state_inner: Arc, +) -> Result { + let native_sample_rate = config.sample_rate; + let channels = config.channels as usize; + let mut resampler = LinearResampler::new(); + let mut raw_mono_samples = Vec::with_capacity(2048); + let mut resampled_mono = Vec::with_capacity(2048); + let mut last_sample = 0.0f32; + + let inner_output_err = Arc::clone(&state_inner); + + device + .build_output_stream( + config, + move |data: &mut [f32], _| { + let required_mono_samples = (data.len() / channels) * TARGET_SAMPLE_RATE as usize + / native_sample_rate as usize; + + raw_mono_samples.clear(); + resampled_mono.clear(); + + if let Ok(mut cons) = consumer.lock() { + for _ in 0..required_mono_samples { + if let Some(s) = cons.try_pop() { + last_sample = s; + raw_mono_samples.push(s); + } else { + // Exponential decay to prevent clicking when underflowing + last_sample *= 0.92; + raw_mono_samples.push(last_sample); + } + } + } + + // Resample from 48kHz mono to target native output rate + resampler.process( + &raw_mono_samples, + TARGET_SAMPLE_RATE, + native_sample_rate, + &mut resampled_mono, + ); + + // Interleave mono into hardware channels + let mut res_idx = 0; + let mut out_idx = 0; + while out_idx < data.len() && res_idx < resampled_mono.len() { + let mono_val = resampled_mono[res_idx]; + for ch in 0..channels { + if out_idx + ch < data.len() { + data[out_idx + ch] = mono_val; + } + } + out_idx += channels; + res_idx += 1; + } + }, + move |err| { + eprintln!("[vc] Output error: {err}. Attempting recovery..."); + if let Ok(mut lock) = inner_output_err.session.lock() { + if let Some(session) = lock.as_mut() { + let target_device = session.current_output_device.clone(); + let _ = session + .update_output_device(target_device, Arc::clone(&inner_output_err)); + } + } + }, + None, + ) + .map_err(|e| e.to_string()) +} #[tauri::command] pub fn list_input_devices() -> Result, String> { @@ -191,10 +322,6 @@ pub fn list_output_devices() -> Result, String> { .collect()) } -// ============================================================================ -// DISCONNECT -// ============================================================================ - #[tauri::command] pub fn disconnect_from_vc(voice_state: State<'_, VoiceState>) -> Result<(), String> { let mut lock = voice_state @@ -205,7 +332,6 @@ pub fn disconnect_from_vc(voice_state: State<'_, VoiceState>) -> Result<(), Stri if let Some(session) = lock.take() { session.shutdown.store(true, Ordering::SeqCst); - let _ = session.input_stream.pause(); if let Ok(output) = session.output_stream.lock() { let _ = output.pause(); @@ -215,10 +341,6 @@ pub fn disconnect_from_vc(voice_state: State<'_, VoiceState>) -> Result<(), Stri Ok(()) } -// ============================================================================ -// CONNECT -// ============================================================================ - #[tauri::command] pub fn connect_to_vc( hostname: String, @@ -280,7 +402,7 @@ pub fn connect_to_vc( let shutdown = Arc::new(AtomicBool::new(false)); - // Output Ring Buffer setup + // Ring Buffer Setup let rb_out = HeapRb::::new(19200); let (producer_out, consumer_out) = rb_out.split(); let mut producer_out = producer_out; @@ -302,7 +424,6 @@ pub fn connect_to_vc( .map_err(|e| format!("Failed to start output: {e}"))?; let output_stream = Arc::new(Mutex::new(output_stream)); - // Input Ring Buffer setup let rb_in = HeapRb::::new(19200); let (producer_in, mut consumer_in) = rb_in.split(); let shared_producer_in = Arc::new(Mutex::new(producer_in)); @@ -312,35 +433,14 @@ pub fn connect_to_vc( .map_err(|e| format!("Failed to get default input config: {e}"))? .config(); - let cb_producer = Arc::clone(&shared_producer_in); - let inner_input_err = Arc::clone(&state_inner); + let input_stream = build_input_stream( + &input_device, + input_config, + Arc::clone(&shared_producer_in), + Arc::clone(&state_inner), + )?; - let input_stream = input_device - .build_input_stream( - input_config, - move |data: &[f32], _| { - if let Ok(mut prod) = cb_producer.lock() { - let _ = prod.push_slice(data); - } - }, - move |err| { - eprintln!("[vc] Input error: {err}. Attempting input stream recovery..."); - if let Ok(mut lock) = inner_input_err.session.lock() { - if let Some(session) = lock.as_mut() { - let target_device = session.current_input_device.clone(); - if let Err(e) = - session.update_input_device(target_device, Arc::clone(&inner_input_err)) - { - eprintln!("[vc] Input recovery failed: {e}"); - } - } - } - }, - None, - ) - .map_err(|e| e.to_string())?; - - // UDP Sender Thread + // Sender Thread { let input_socket = socket.clone(); let shutdown = shutdown.clone(); @@ -386,7 +486,7 @@ pub fn connect_to_vc( }); } - // UDP Receiver Thread + // Receiver Thread { let socket = socket.clone(); let shutdown = shutdown.clone(); @@ -477,65 +577,6 @@ pub fn connect_to_vc( Ok(()) } -// ============================================================================ -// AUDIO CALLBACK -// ============================================================================ - -fn build_output_stream( - device: &cpal::Device, - config: cpal::StreamConfig, - consumer: AudioConsumer, - state_inner: Arc, -) -> Result { - let channels = config.channels as usize; - let mut last_sample = 0.0f32; - let inner_output_err = Arc::clone(&state_inner); - - device - .build_output_stream( - config, - move |data: &mut [f32], _| { - let mut idx = 0; - let mut cons_guard = consumer.lock().ok(); - - while idx < data.len() { - let sample = cons_guard.as_mut().and_then(|c| c.try_pop()); - if let Some(mono_sample) = sample { - last_sample = mono_sample; - for ch in 0..channels { - data[idx + ch] = mono_sample; - } - idx += channels; - } else { - while idx < data.len() { - last_sample *= 0.92; - for ch in 0..channels { - data[idx + ch] = last_sample; - } - idx += channels; - } - break; - } - } - }, - move |err| { - eprintln!("[vc] Output error: {err}. Attempting output stream recovery..."); - if let Ok(mut lock) = inner_output_err.session.lock() { - if let Some(session) = lock.as_mut() { - let target_device = session.current_output_device.clone(); - if let Err(e) = session - .update_output_device(target_device, Arc::clone(&inner_output_err)) - { - eprintln!("[vc] Output recovery failed: {e}"); - } - } - } - }, - None, - ) - .map_err(|e| e.to_string()) -} - impl Drop for VoiceSession { fn drop(&mut self) { self.shutdown.store(true, Ordering::SeqCst); From 58609bd7ec9dc7813d8b35d45a1172f0556147b2 Mon Sep 17 00:00:00 2001 From: Klesti Selimaj Date: Fri, 28 Aug 2026 02:25:06 +0200 Subject: [PATCH 03/12] Refactored status card --- src/app/app.ts | 40 ++++++++ src/app/server.ts | 3 + src/components/page/channel/VoiceChannel.tsx | 72 ++----------- src/components/view/AccountCard.tsx | 60 ----------- src/components/view/Sidebar.tsx | 4 +- src/components/view/StatusCard.tsx | 102 +++++++++++++++++++ 6 files changed, 157 insertions(+), 124 deletions(-) delete mode 100644 src/components/view/AccountCard.tsx create mode 100644 src/components/view/StatusCard.tsx diff --git a/src/app/app.ts b/src/app/app.ts index 58924f1..39b1115 100644 --- a/src/app/app.ts +++ b/src/app/app.ts @@ -7,6 +7,8 @@ import { base58 } from "@scure/base"; import axios from "axios"; import { Account, AccountsFile, getAccounts } from "@/lib/accounts"; import { Page } from "@/components/page/PageView"; +import { Channel } from "@/lib/types"; +import { invoke } from "@tauri-apps/api/core"; import { ClientMethod } from "./protocol"; import { Config, getConfig } from "@/lib/config"; @@ -152,6 +154,44 @@ export default class Enclave

{ this.forceRender(); } + public joinVoice(channel: Channel) { + const server = this.server; + + if (!server || channel.kind !== "voice") return; + + server.voiceJoin = (pin, channelId) => { + invoke("connect_to_vc", { hostname: server.hostname, pin, channelId }) + .then(() => { + server.isInVoiceChat = true; + server.voiceChannelId = channelId; + this.forceRender(); + }) + .catch(console.error); + }; + + server.isInVoiceChat = true; + server.voiceChannelId = channel.id; + + server.websocket?.send({ method: "JoinVoice", channel_id: channel.id }); + + this.forceRender(); + } + + public leaveVoice() { + const server = this.server; + + if (!server || !server.isInVoiceChat) return; + + server.websocket?.send({ method: "LeaveVoice" }); + invoke("disconnect_from_vc").catch(console.error); + + server.isInVoiceChat = false; + server.voiceChannelId = undefined; + server.voiceChatSpeakers = {}; + + this.forceRender(); + } + public async onMessage(msg: ClientMethod) { const server = this.server; diff --git a/src/app/server.ts b/src/app/server.ts index a3e56d1..c4e20f5 100644 --- a/src/app/server.ts +++ b/src/app/server.ts @@ -36,6 +36,8 @@ export default class EnclaveServer { public voiceJoin?: (pin: bigint, channelId: string) => void; public voiceChatUsers: Record; public voiceChatSpeakers: Record; + public isInVoiceChat: boolean; + public voiceChannelId?: string; public constructor(hostname: string, isSecure: boolean) { this.hostname = hostname; @@ -44,6 +46,7 @@ export default class EnclaveServer { this.users = {}; this.voiceChatUsers = {}; this.voiceChatSpeakers = {}; + this.isInVoiceChat = false; } public disconnect() { diff --git a/src/components/page/channel/VoiceChannel.tsx b/src/components/page/channel/VoiceChannel.tsx index 52a7ed0..25e2366 100644 --- a/src/components/page/channel/VoiceChannel.tsx +++ b/src/components/page/channel/VoiceChannel.tsx @@ -1,11 +1,9 @@ import Enclave from "@/app/app"; import { ChannelPageProps } from "../PageView"; -import { useEffect, useRef, useState } from "react"; -import { invoke } from "@tauri-apps/api/core"; import { cn } from "@/lib/utils"; import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; import { Button } from "@/components/ui/button"; -import { Phone, PhoneOff } from "lucide-react"; +import { Phone } from "lucide-react"; export default function VoiceChannel({ appRef, @@ -15,50 +13,9 @@ export default function VoiceChannel({ const channel = appRef.current?.page?.channel; if (!channel) return null; - const [isConnected, setIsConnected] = useState(false); - const lastChannelId = useRef(null); - - // Leave current channel setup - const handleLeave = () => { - appRef.current?.server?.websocket?.send({ method: "LeaveVoice" }); - invoke("disconnect_from_vc").catch(console.error); - setIsConnected(false); - }; - - // Explicit action to join the voice channel - const handleJoin = () => { - const hostname = appRef.current?.server?.hostname; - if (!hostname || channel.kind !== "voice" || !appRef.current?.server) - return; - - appRef.current.server.voiceJoin = (pin, channelId) => { - invoke("connect_to_vc", { hostname, pin, channelId }) - .then(() => setIsConnected(true)) - .catch(console.error); - }; - - appRef.current.server.websocket?.send({ - method: "JoinVoice", - channel_id: channel.id, - }); - - setIsConnected(true); - }; - - // Clean up when channel changes or component unmounts - useEffect(() => { - if (lastChannelId.current !== channel.id) { - if (isConnected) { - invoke("disconnect_from_vc").catch(console.error); - setIsConnected(false); - } - lastChannelId.current = channel.id; - } - - return () => { - invoke("disconnect_from_vc").catch(console.error); - }; - }, [channel.id]); + const isConnected = + appRef.current?.server?.isInVoiceChat && + appRef.current.server.voiceChannelId === channel.id; const speakers = appRef.current?.server?.voiceChatSpeakers && @@ -141,27 +98,18 @@ export default function VoiceChannel({ {/* Bottom Control Bar */} -

- {isConnected ? ( - - ) : ( + {!isConnected && ( +
- )} -
+
+ )} ); -} +} \ No newline at end of file diff --git a/src/components/view/AccountCard.tsx b/src/components/view/AccountCard.tsx deleted file mode 100644 index d5d63e4..0000000 --- a/src/components/view/AccountCard.tsx +++ /dev/null @@ -1,60 +0,0 @@ -import { Card } from "../ui/card"; -import { Avatar, AvatarFallback, AvatarImage } from "../ui/avatar"; -import { CopyIcon, Settings2Icon } from "lucide-react"; -import { Button } from "../ui/button"; -import Enclave from "@/app/app"; -import { base58 } from "@scure/base"; -import * as ed from "@noble/ed25519"; - -export default function AccountCard({ - appRef, -}: { - appRef: React.RefObject; -}) { - const account = appRef.current?.getAccount(); - - if (!account) return null; - - return ( -
- -
- - - {account.meta.displayName[0]} - -
- { - navigator.clipboard.writeText( - base58.encode( - ed.getPublicKey(base58.decode(account.privateKey)), - ), - ); - }} - > -

{account.meta.displayName}

- -
- Online -
-
-
- -
-
-
- ); -} diff --git a/src/components/view/Sidebar.tsx b/src/components/view/Sidebar.tsx index 1fe61fa..5113fb0 100644 --- a/src/components/view/Sidebar.tsx +++ b/src/components/view/Sidebar.tsx @@ -2,7 +2,7 @@ import Enclave from "@/app/app"; import { Channel, ChannelKind } from "@/lib/types"; import { ChevronDown, ChevronUp, HashIcon, Volume2Icon } from "lucide-react"; import { useState } from "react"; -import AccountCard from "./AccountCard"; +import StatusCard from "./StatusCard"; import { cn } from "@/lib/utils"; export function ChannelIcon({ kind }: { kind: ChannelKind["kind"] }) { @@ -106,7 +106,7 @@ export default function Sidebar({ )} - + ); } diff --git a/src/components/view/StatusCard.tsx b/src/components/view/StatusCard.tsx new file mode 100644 index 0000000..a9fa701 --- /dev/null +++ b/src/components/view/StatusCard.tsx @@ -0,0 +1,102 @@ +import { Card } from "../ui/card"; +import { Avatar, AvatarFallback, AvatarImage } from "../ui/avatar"; +import { CopyIcon, PhoneOff, Settings2Icon, Signal } from "lucide-react"; +import { Button } from "../ui/button"; +import Enclave from "@/app/app"; +import { base58 } from "@scure/base"; +import * as ed from "@noble/ed25519"; +import { Channel } from "@/lib/types"; + +function findChannel(channels: Channel[], id: string): Channel | undefined { + for (const channel of channels) { + if (channel.id === id) return channel; + + if (channel.kind === "category") { + const found = findChannel(channel.channels, id); + if (found) return found; + } + } + + return undefined; +} + +export default function StatusCard({ + appRef, +}: { + appRef: React.RefObject; +}) { + const account = appRef.current?.getAccount(); + const server = appRef.current?.server; + + const voiceChannelId = server?.voiceChannelId; + const voiceChannel = + voiceChannelId && server?.meta + ? findChannel(server.meta.channels, voiceChannelId) + : undefined; + + if (!account) return null; + + return ( +
+ + {server?.isInVoiceChat && voiceChannel && ( +
+ +
+ Voice Connected + + {voiceChannel.name} + +
+ +
+ )} +
+
+ + + {account.meta.displayName[0]} + +
+ { + navigator.clipboard.writeText( + base58.encode( + ed.getPublicKey(base58.decode(account.privateKey)), + ), + ); + }} + > +

{account.meta.displayName}

+ +
+ Online +
+
+
+ +
+
+
+
+ ); +} \ No newline at end of file From de9f3ad2fbe758b1c79c95cb0c9835cb847d1e2c Mon Sep 17 00:00:00 2001 From: Klesti Selimaj Date: Fri, 28 Aug 2026 02:40:07 +0200 Subject: [PATCH 04/12] VC user list --- src/components/view/Sidebar.tsx | 79 ++++++++++++++++++++++++++------- 1 file changed, 62 insertions(+), 17 deletions(-) diff --git a/src/components/view/Sidebar.tsx b/src/components/view/Sidebar.tsx index 5113fb0..31e837a 100644 --- a/src/components/view/Sidebar.tsx +++ b/src/components/view/Sidebar.tsx @@ -4,6 +4,7 @@ import { ChevronDown, ChevronUp, HashIcon, Volume2Icon } from "lucide-react"; import { useState } from "react"; import StatusCard from "./StatusCard"; import { cn } from "@/lib/utils"; +import { Avatar, AvatarFallback, AvatarImage } from "../ui/avatar"; export function ChannelIcon({ kind }: { kind: ChannelKind["kind"] }) { switch (kind) { @@ -49,6 +50,48 @@ export function RenderCategory({ ); } +export function RenderFeatures({ + appRef, + channel, +}: { + appRef: React.RefObject; + channel: Channel; +}) { + switch (channel.kind) { + case "voice": + const users = appRef.current?.server?.voiceChatUsers[channel.id]; + + return ( + users && ( +
+ {users.map((pubkey) => { + const user = appRef.current?.server?.users[pubkey]; + if (!user) return null; + + return ( +
+ + + + {user?.displayName.slice(0, 1).toUpperCase() || + pubkey.slice(0, 2).toUpperCase()} + + + + + {user.displayName} + +
+ ); + })} +
+ ) + ); + default: + return null; + } +} + export function RenderChannels({ appRef, channels, @@ -60,25 +103,27 @@ export function RenderChannels({ channel.kind === "category" ? ( ) : ( -
{ - if (!appRef.current) { - console.error("AppRef is not initialized yet"); - return; - } +
+
{ + if (!appRef.current) { + console.error("AppRef is not initialized yet"); + return; + } - appRef.current.page = { kind: "channel", channel }; + appRef.current.page = { kind: "channel", channel }; - appRef.current.forceRender(); - }} - > - - {channel.name} + appRef.current.forceRender(); + }} + > + + {channel.name} +
+
), ); From 5b964392b9c8144a64476f101004031efd9b6b90 Mon Sep 17 00:00:00 2001 From: Klesti Selimaj Date: Fri, 28 Aug 2026 02:51:38 +0200 Subject: [PATCH 05/12] Join on click and speaking on server channel list --- src/components/page/channel/VoiceChannel.tsx | 32 ++++++++------------ src/components/view/Sidebar.tsx | 12 +++++++- src/components/view/StatusCard.tsx | 2 +- 3 files changed, 24 insertions(+), 22 deletions(-) diff --git a/src/components/page/channel/VoiceChannel.tsx b/src/components/page/channel/VoiceChannel.tsx index 25e2366..2fe0fc1 100644 --- a/src/components/page/channel/VoiceChannel.tsx +++ b/src/components/page/channel/VoiceChannel.tsx @@ -1,9 +1,8 @@ import Enclave from "@/app/app"; import { ChannelPageProps } from "../PageView"; +import { useEffect } from "react"; import { cn } from "@/lib/utils"; import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; -import { Button } from "@/components/ui/button"; -import { Phone } from "lucide-react"; export default function VoiceChannel({ appRef, @@ -11,11 +10,18 @@ export default function VoiceChannel({ appRef: React.RefObject | null>; }) { const channel = appRef.current?.page?.channel; - if (!channel) return null; - const isConnected = - appRef.current?.server?.isInVoiceChat && - appRef.current.server.voiceChannelId === channel.id; + useEffect(() => { + if (!channel || channel.kind !== "voice") return; + + const server = appRef.current?.server; + + if (!server || server.voiceChannelId === channel.id) return; + + appRef.current?.joinVoice(channel); + }, [channel?.id]); + + if (!channel) return null; const speakers = appRef.current?.server?.voiceChatSpeakers && @@ -96,20 +102,6 @@ export default function VoiceChannel({ ); })}
- - {/* Bottom Control Bar */} - {!isConnected && ( -
- -
- )} ); } \ No newline at end of file diff --git a/src/components/view/Sidebar.tsx b/src/components/view/Sidebar.tsx index 31e837a..fe2920c 100644 --- a/src/components/view/Sidebar.tsx +++ b/src/components/view/Sidebar.tsx @@ -68,9 +68,19 @@ export function RenderFeatures({ const user = appRef.current?.server?.users[pubkey]; if (!user) return null; + const isSpeaking = + appRef.current?.server?.voiceChatSpeakers[pubkey]; + return (
- + {user?.displayName.slice(0, 1).toUpperCase() || diff --git a/src/components/view/StatusCard.tsx b/src/components/view/StatusCard.tsx index a9fa701..faee4fb 100644 --- a/src/components/view/StatusCard.tsx +++ b/src/components/view/StatusCard.tsx @@ -99,4 +99,4 @@ export default function StatusCard({
); -} \ No newline at end of file +} From 853f7bc84bc477f579655119ca413fdab5421ad2 Mon Sep 17 00:00:00 2001 From: Klesti Selimaj Date: Fri, 28 Aug 2026 02:57:30 +0200 Subject: [PATCH 06/12] More users --- src/components/view/Sidebar.tsx | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/components/view/Sidebar.tsx b/src/components/view/Sidebar.tsx index fe2920c..0243101 100644 --- a/src/components/view/Sidebar.tsx +++ b/src/components/view/Sidebar.tsx @@ -64,7 +64,7 @@ export function RenderFeatures({ return ( users && (
- {users.map((pubkey) => { + {users.slice(0, 5).map((pubkey) => { const user = appRef.current?.server?.users[pubkey]; if (!user) return null; @@ -94,6 +94,11 @@ export function RenderFeatures({
); })} + {users.length > 5 && ( + + and {users.length - 5} others + + )} ) ); From a19de564feacb394fdfc0119678b8280b6890434 Mon Sep 17 00:00:00 2001 From: Klesti Selimaj Date: Fri, 28 Aug 2026 12:17:53 +0200 Subject: [PATCH 07/12] Improved sidebar --- src/components/view/Sidebar.tsx | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/components/view/Sidebar.tsx b/src/components/view/Sidebar.tsx index 0243101..5aed5af 100644 --- a/src/components/view/Sidebar.tsx +++ b/src/components/view/Sidebar.tsx @@ -60,11 +60,12 @@ export function RenderFeatures({ switch (channel.kind) { case "voice": const users = appRef.current?.server?.voiceChatUsers[channel.id]; + const isCurrentVc = channel.id === appRef.current?.server?.voiceChannelId; return ( users && (
- {users.slice(0, 5).map((pubkey) => { + {(isCurrentVc ? users : users.slice(0, 15)).map((pubkey) => { const user = appRef.current?.server?.users[pubkey]; if (!user) return null; @@ -94,9 +95,9 @@ export function RenderFeatures({
); })} - {users.length > 5 && ( + {!isCurrentVc && users.length > 15 && ( - and {users.length - 5} others + and {users.length - 15} others )} @@ -157,7 +158,7 @@ export default function Sidebar({

{appRef.current.server.meta.name}

-
+
Date: Fri, 28 Aug 2026 14:13:45 +0200 Subject: [PATCH 08/12] Crypto handshakce and encryption/decryption --- package-lock.json | 35 +++++++++++++-- package.json | 2 + src/app/app.ts | 15 ++++++- src/app/server.ts | 2 + src/app/ws.ts | 108 ++++++++++++++++++++++++++++++++++++++++++---- 5 files changed, 149 insertions(+), 13 deletions(-) diff --git a/package-lock.json b/package-lock.json index bb4d3bc..e552de2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,8 @@ "dependencies": { "@base-ui/react": "^1.7.0", "@fontsource-variable/geist": "^5.3.0", + "@noble/ciphers": "^2.4.0", + "@noble/curves": "^2.4.0", "@noble/ed25519": "^3.1.0", "@noble/hashes": "^2.3.0", "@scure/base": "^2.3.0", @@ -1306,6 +1308,33 @@ "node": "^22.20 || ^24.12 || >=25" } }, + "node_modules/@noble/ciphers": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-2.4.0.tgz", + "integrity": "sha512-AnjFn0Jv92laAkvMrghlFZq4qQCIN/4DxFV/eooqtC2YTjB7kBeLMS2T9KJX4Dn+ZVXLOwK0lSgqDtx9gvxtiw==", + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@noble/curves": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-2.4.0.tgz", + "integrity": "sha512-P4/62zrgfH33CneE3Dn4WhJVA22YUU0eR51wKIan4NVRvwsA0YnPTwWGpNbpuacSujmSFLvyzpyuR30+fbq2Ew==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "2.4.0" + }, + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/@noble/ed25519": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/@noble/ed25519/-/ed25519-3.1.0.tgz", @@ -1316,9 +1345,9 @@ } }, "node_modules/@noble/hashes": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.3.0.tgz", - "integrity": "sha512-oN+QwyX7VSHotibwubG3kpzbwKrfnyR6OOO+3Nk/53ADL7FmgHHz4TgrbaYKvvOw09u6QTx0oiH1cNCIOuN0CQ==", + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.4.0.tgz", + "integrity": "sha512-X5XaVWZIBCT7HHZGm5I7ZQXDwLG+bGXuSrMQAW+7Zvl87h1kmc1ZB1VSRJcpUfoUrGQp4Fkoxm5kZ+Ms+aW+eA==", "license": "MIT", "engines": { "node": ">= 20.19.0" diff --git a/package.json b/package.json index 0eb2196..da53b41 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,8 @@ "dependencies": { "@base-ui/react": "^1.7.0", "@fontsource-variable/geist": "^5.3.0", + "@noble/ciphers": "^2.4.0", + "@noble/curves": "^2.4.0", "@noble/ed25519": "^3.1.0", "@noble/hashes": "^2.3.0", "@scure/base": "^2.3.0", diff --git a/src/app/app.ts b/src/app/app.ts index 39b1115..2d354c3 100644 --- a/src/app/app.ts +++ b/src/app/app.ts @@ -134,8 +134,19 @@ export default class Enclave

{ if (this.server.websocket) { this.server.websocket.send({ method: "Meta", ...account.meta }); - this.server.websocket.websocket.onmessage = (msg) => { - this.onMessage(JSON.parse(msg.data)); + this.server.websocket.websocket.onmessage = async (msg) => { + let buffer: ArrayBuffer; + if (msg.data instanceof Blob) { + buffer = await msg.data.arrayBuffer(); + } else { + buffer = msg.data as ArrayBuffer; + } + + const encrypted = new Uint8Array(buffer); + const plaintext = this.server?.websocket?.decrypt(encrypted); + const data = JSON.parse(new TextDecoder().decode(plaintext)); + + this.onMessage(data); }; } diff --git a/src/app/server.ts b/src/app/server.ts index c4e20f5..ca67ec3 100644 --- a/src/app/server.ts +++ b/src/app/server.ts @@ -4,6 +4,7 @@ import EnclaveWebSocket from "./ws"; import { sha512 } from "@noble/hashes/sha2.js"; import { getWSUrl } from "@/lib/serverList"; import { ClientMeta, ServerMeta, StoredMessage } from "@/lib/types"; +import { ed25519 } from "@noble/curves/ed25519.js"; ed.hashes.sha512 = sha512; @@ -60,6 +61,7 @@ export default class EnclaveServer { ) { this.websocket = new EnclaveWebSocket( getWSUrl(this.hostname, this.isSecure), + ed25519.utils.toMontgomerySecret(clientSecretKey), ); const publicKeyString = base58.encode(clientPublicKey); diff --git a/src/app/ws.ts b/src/app/ws.ts index 9962d7b..fd34459 100644 --- a/src/app/ws.ts +++ b/src/app/ws.ts @@ -1,46 +1,138 @@ import { invoke } from "@tauri-apps/api/core"; +import { x25519 } from "@noble/curves/ed25519.js"; +import { chacha20poly1305 } from "@noble/ciphers/chacha.js"; import { ClientMethod, ServerMethod } from "./protocol"; /** * A protocol-aware wrapper around the browser `WebSocket`. * - * Exposes typed protocol methods (`ServerMethod` / `ClientMethod`) rather - * than raw WebSocket messages. Has no concept of channels, messages, or - * app state — `EnclaveServer` builds on top of this to add those. + * Handles the x25519 key exchange handshake and ChaCha20-Poly1305 + * encryption/decryption of every message after it. Exposes typed protocol + * methods (`ServerMethod` / `ClientMethod`) rather than raw WebSocket + * messages. Has no concept of channels, messages, or app state — + * `EnclaveServer` builds on top of this to add those. */ export default class EnclaveWebSocket { public websocket: WebSocket; onOpenQueue: Array<() => void>; - public constructor(hostname: string) { + private sendCounter = 0n; + private sharedSecret: Uint8Array | null = null; + + private readonly handshakeReady: Promise; + private resolveHandshake!: () => void; + + public constructor( + hostname: string, + private readonly myX25519PrivateKey: Uint8Array, + ) { this.onOpenQueue = new Array(); + this.handshakeReady = new Promise((resolve) => { + this.resolveHandshake = resolve; + }); + this.websocket = new WebSocket(hostname); + this.websocket.binaryType = "arraybuffer"; + this.websocket.onclose = () => { invoke("disconnect_from_vc"); }; + this.websocket.onopen = () => { this.onOpenQueue.forEach((fun) => fun()); }; + + this.websocket.onmessage = (msg) => this.handleHandshakeMessage(msg); + } + + private handleHandshakeMessage(msg: MessageEvent) { + const serverPubkey = new Uint8Array(msg.data as ArrayBuffer); + + this.sharedSecret = x25519.getSharedSecret( + this.myX25519PrivateKey, + serverPubkey, + ); + + const myPublicKey = x25519.getPublicKey(this.myX25519PrivateKey); + this.websocket.send(myPublicKey); + + this.websocket.onmessage = null; + this.resolveHandshake(); + } + + private nextSendNonce(): Uint8Array { + const nonce = new Uint8Array(12); + const view = new DataView(nonce.buffer); + view.setBigUint64(0, this.sendCounter, false); // Big-endian 64-bit counter + nonce[11] |= 0b1000_0000; // Client-to-server direction flag bit + this.sendCounter += 1n; + return nonce; + } + + public encrypt(plaintext: Uint8Array): Uint8Array { + if (!this.sharedSecret) throw new Error("Handshake not complete"); + + const nonce = this.nextSendNonce(); + const cipher = chacha20poly1305(this.sharedSecret, nonce); + const ciphertext = cipher.encrypt(plaintext); + + // Prepend nonce (12 bytes) + ciphertext + const out = new Uint8Array(nonce.length + ciphertext.length); + out.set(nonce, 0); + out.set(ciphertext, nonce.length); + return out; + } + + public decrypt(data: Uint8Array): Uint8Array { + if (!this.sharedSecret) throw new Error("Handshake not complete"); + if (data.length < 12) { + throw new Error("Message too short to contain a nonce"); + } + + const nonce = new Uint8Array(data.subarray(0, 12)); + const ciphertext = new Uint8Array(data.subarray(12)); + + const cipher = chacha20poly1305(this.sharedSecret, nonce); + return cipher.decrypt(ciphertext); } public async send(method: ServerMethod) { + await this.handshakeReady; + + const send = () => { + const plaintext = new TextEncoder().encode(JSON.stringify(method)); + const encrypted = this.encrypt(plaintext); + this.websocket.send(encrypted); + }; + if (this.websocket.readyState !== WebSocket.OPEN) { return new Promise((ok) => { this.onOpenQueue.push(() => { - this.websocket.send(JSON.stringify(method)); + send(); ok(); }); }); } - this.websocket.send(JSON.stringify(method)); + send(); } public async read(): Promise { + await this.handshakeReady; + return new Promise((ok) => { - this.websocket.onmessage = (msg) => { - const data = JSON.parse(msg.data); + this.websocket.onmessage = async (msg) => { + let buffer: ArrayBuffer; + if (msg.data instanceof Blob) { + buffer = await msg.data.arrayBuffer(); + } else { + buffer = msg.data as ArrayBuffer; + } + + const encrypted = new Uint8Array(buffer); + const plaintext = this.decrypt(encrypted); + const data = JSON.parse(new TextDecoder().decode(plaintext)); ok(data); this.websocket.onmessage = null; }; From cd564bec99fd5c08032a4b959b038e20d8f88a8f Mon Sep 17 00:00:00 2001 From: Klesti Selimaj Date: Fri, 28 Aug 2026 14:16:41 +0200 Subject: [PATCH 09/12] Updated stuff --- src/app/ws.ts | 41 ++++++++++++++++++++++++----------------- 1 file changed, 24 insertions(+), 17 deletions(-) diff --git a/src/app/ws.ts b/src/app/ws.ts index fd34459..465640b 100644 --- a/src/app/ws.ts +++ b/src/app/ws.ts @@ -17,6 +17,7 @@ export default class EnclaveWebSocket { onOpenQueue: Array<() => void>; private sendCounter = 0n; + private recvCounter = 0n; private sharedSecret: Uint8Array | null = null; private readonly handshakeReady: Promise; @@ -43,6 +44,9 @@ export default class EnclaveWebSocket { this.onOpenQueue.forEach((fun) => fun()); }; + // First binary message is the server's raw x25519 pubkey — this + // one-time listener handles the handshake, then hands off to + // the normal encrypted read loop. this.websocket.onmessage = (msg) => this.handleHandshakeMessage(msg); } @@ -54,9 +58,13 @@ export default class EnclaveWebSocket { serverPubkey, ); + // Respond with our own x25519 pubkey, in plaintext — this is the + // one message on either side that can't be encrypted yet, since + // the shared secret doesn't exist until both pubkeys are known. const myPublicKey = x25519.getPublicKey(this.myX25519PrivateKey); this.websocket.send(myPublicKey); + // From here on, every message is encrypted. this.websocket.onmessage = null; this.resolveHandshake(); } @@ -64,20 +72,27 @@ export default class EnclaveWebSocket { private nextSendNonce(): Uint8Array { const nonce = new Uint8Array(12); const view = new DataView(nonce.buffer); - view.setBigUint64(0, this.sendCounter, false); // Big-endian 64-bit counter - nonce[11] |= 0b1000_0000; // Client-to-server direction flag bit + view.setBigUint64(0, this.sendCounter, false); // big-endian, first 8 bytes + nonce[11] |= 0b1000_0000; // distinguishes client-send from server-send direction this.sendCounter += 1n; return nonce; } - public encrypt(plaintext: Uint8Array): Uint8Array { + private nextRecvNonce(): Uint8Array { + const nonce = new Uint8Array(12); + const view = new DataView(nonce.buffer); + view.setBigUint64(0, this.recvCounter, false); + this.recvCounter += 1n; + return nonce; + } + + private encrypt(plaintext: Uint8Array): Uint8Array { if (!this.sharedSecret) throw new Error("Handshake not complete"); const nonce = this.nextSendNonce(); const cipher = chacha20poly1305(this.sharedSecret, nonce); const ciphertext = cipher.encrypt(plaintext); - // Prepend nonce (12 bytes) + ciphertext const out = new Uint8Array(nonce.length + ciphertext.length); out.set(nonce, 0); out.set(ciphertext, nonce.length); @@ -86,12 +101,11 @@ export default class EnclaveWebSocket { public decrypt(data: Uint8Array): Uint8Array { if (!this.sharedSecret) throw new Error("Handshake not complete"); - if (data.length < 12) { + if (data.length < 12) throw new Error("Message too short to contain a nonce"); - } - const nonce = new Uint8Array(data.subarray(0, 12)); - const ciphertext = new Uint8Array(data.subarray(12)); + const nonce = data.slice(0, 12); + const ciphertext = data.slice(12); const cipher = chacha20poly1305(this.sharedSecret, nonce); return cipher.decrypt(ciphertext); @@ -122,15 +136,8 @@ export default class EnclaveWebSocket { await this.handshakeReady; return new Promise((ok) => { - this.websocket.onmessage = async (msg) => { - let buffer: ArrayBuffer; - if (msg.data instanceof Blob) { - buffer = await msg.data.arrayBuffer(); - } else { - buffer = msg.data as ArrayBuffer; - } - - const encrypted = new Uint8Array(buffer); + this.websocket.onmessage = (msg) => { + const encrypted = new Uint8Array(msg.data as ArrayBuffer); const plaintext = this.decrypt(encrypted); const data = JSON.parse(new TextDecoder().decode(plaintext)); ok(data); From d5dc9d82d7c8647f9def49a30d829eb0c3330184 Mon Sep 17 00:00:00 2001 From: Klesti Selimaj Date: Fri, 28 Aug 2026 15:20:38 +0200 Subject: [PATCH 10/12] Crypto in vc --- src-tauri/Cargo.lock | 140 +++++++++++++++++++++++++++++++- src-tauri/Cargo.toml | 1 + src-tauri/src/commands/audio.rs | 55 +++++++++---- src-tauri/src/crypto.rs | 62 ++++++++++++++ src-tauri/src/lib.rs | 1 + src/app/app.ts | 6 +- src/app/ws.ts | 2 +- 7 files changed, 246 insertions(+), 21 deletions(-) create mode 100644 src-tauri/src/crypto.rs diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 06091f3..01365dc 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -8,6 +8,16 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" +[[package]] +name = "aead" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1973cfbc1a2daf9cf550e74e1f088c28e7f7d8c1e1418fb6c9dc5184b7e84c99" +dependencies = [ + "crypto-common 0.2.2", + "inout", +] + [[package]] name = "aho-corasick" version = "1.1.5" @@ -319,6 +329,15 @@ dependencies = [ "generic-array", ] +[[package]] +name = "block-buffer" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" +dependencies = [ + "hybrid-array", +] + [[package]] name = "block2" version = "0.6.2" @@ -508,6 +527,29 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +[[package]] +name = "chacha20" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures 0.3.1", +] + +[[package]] +name = "chacha20poly1305" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b89e1c441e926b9c82a8d023f6e1b7ae0adcfaa7d621814e4d60789bac751cb" +dependencies = [ + "aead", + "chacha20", + "cipher", + "poly1305", +] + [[package]] name = "chrono" version = "0.4.45" @@ -520,6 +562,23 @@ dependencies = [ "windows-link 0.2.1", ] +[[package]] +name = "cipher" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8cf2a2c93cd704877c0858356ed03480ff301ee950b43f1cbe4573b088bfa6c" +dependencies = [ + "block-buffer 0.12.1", + "crypto-common 0.2.2", + "inout", +] + +[[package]] +name = "cmov" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a" + [[package]] name = "combine" version = "4.6.7" @@ -642,6 +701,15 @@ dependencies = [ "libc", ] +[[package]] +name = "cpufeatures" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566" +dependencies = [ + "libc", +] + [[package]] name = "crc32fast" version = "1.5.0" @@ -676,6 +744,17 @@ dependencies = [ "typenum", ] +[[package]] +name = "crypto-common" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" +dependencies = [ + "getrandom 0.4.3", + "hybrid-array", + "rand_core", +] + [[package]] name = "cssparser" version = "0.36.0" @@ -715,6 +794,15 @@ version = "0.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "52560adf09603e58c9a7ee1fe1dcb95a16927b17c127f0ac02d6e768a0e25bc1" +[[package]] +name = "ctutils" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e" +dependencies = [ + "cmov", +] + [[package]] name = "darling" version = "0.23.0" @@ -833,8 +921,8 @@ version = "0.10.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ - "block-buffer", - "crypto-common", + "block-buffer 0.10.4", + "crypto-common 0.1.7", ] [[package]] @@ -994,6 +1082,7 @@ checksum = "4ef6b89e5b37196644d8796de5268852ff179b44e96276cf4290264843743bb7" name = "enclave" version = "0.1.0" dependencies = [ + "chacha20poly1305", "cpal", "ringbuf", "rubato", @@ -1392,6 +1481,7 @@ dependencies = [ "cfg-if", "libc", "r-efi 6.0.0", + "rand_core", ] [[package]] @@ -1627,6 +1717,15 @@ version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" +[[package]] +name = "hybrid-array" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "707114b52a152fa7bdb290cd7cd5912d9467273b6d74e21b8d81aca1f8533f6b" +dependencies = [ + "typenum", +] + [[package]] name = "hyper" version = "1.11.0" @@ -1846,6 +1945,15 @@ dependencies = [ "cfb", ] +[[package]] +name = "inout" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4250ce6452e92010fdf7268ccc5d14faa80bb12fc741938534c58f16804e03c7" +dependencies = [ + "hybrid-array", +] + [[package]] name = "ipnet" version = "2.12.1" @@ -2807,6 +2915,16 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "poly1305" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e2d0073b297041425c7c3df6eb4792d598a15323fe63346852b092eca02904c" +dependencies = [ + "cpufeatures 0.3.1", + "universal-hash", +] + [[package]] name = "portable-atomic" version = "1.15.0" @@ -2944,6 +3062,12 @@ version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + [[package]] name = "raw-window-handle" version = "0.6.2" @@ -3398,7 +3522,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.2.17", "digest", ] @@ -4396,6 +4520,16 @@ version = "1.13.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" +[[package]] +name = "universal-hash" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4987bdc12753382e0bec4a65c50738ffaabc998b9cdd1f952fb5f39b0048a96" +dependencies = [ + "crypto-common 0.2.2", + "ctutils", +] + [[package]] name = "url" version = "2.5.8" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 6cdf2a9..4555b87 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -25,4 +25,5 @@ serde_json = "1" cpal = "0.18.2" ringbuf = "0.5.1" rubato = "5.0.0" +chacha20poly1305 = "0.11.0" diff --git a/src-tauri/src/commands/audio.rs b/src-tauri/src/commands/audio.rs index 17f3eaf..7e6b5cf 100644 --- a/src-tauri/src/commands/audio.rs +++ b/src-tauri/src/commands/audio.rs @@ -9,6 +9,7 @@ use std::{ time::{Duration, Instant}, }; +use chacha20poly1305::{aead::KeyInit, ChaCha20Poly1305, Key}; use cpal::traits::{DeviceTrait, HostTrait, StreamTrait}; use ringbuf::{ storage::Heap, @@ -17,11 +18,10 @@ use ringbuf::{ }; use tauri::State; -use crate::commands::config::ConfigState; +use crate::{commands::config::ConfigState, crypto::SessionCipher}; const TARGET_SAMPLE_RATE: u32 = 48_000; -const PACKET_SAMPLES: usize = 960; // 20ms @ 48kHz mono -const HEADER_SIZE: usize = 4; +const PACKET_SAMPLES: usize = 960; const MAX_PACKET_SIZE: usize = 4096; const INITIAL_PACKET_CUSHION: usize = 3; @@ -345,11 +345,15 @@ pub fn disconnect_from_vc(voice_state: State<'_, VoiceState>) -> Result<(), Stri pub fn connect_to_vc( hostname: String, pin: u64, + shared_secret: Vec, // output of js `x25519.getSharedSecret` config_state: State<'_, ConfigState>, voice_state: State<'_, VoiceState>, ) -> Result<(), String> { disconnect_from_vc(voice_state.clone())?; + let key = Key::try_from(shared_secret.as_slice()).map_err(|v| v.to_string())?; + let cipher = Arc::new(Mutex::new(SessionCipher::new(ChaCha20Poly1305::new(&key)))); + let config = config_state.0.lock().unwrap().clone(); let state_inner = Arc::clone(&voice_state.inner); @@ -444,10 +448,10 @@ pub fn connect_to_vc( { let input_socket = socket.clone(); let shutdown = shutdown.clone(); + let cipher = cipher.clone(); thread::spawn(move || { let mut sequence = 0u32; - let mut net_packet = vec![0u8; HEADER_SIZE + PACKET_SAMPLES * 2]; let mut frame_buf = vec![0.0f32; PACKET_SAMPLES]; let mut hangover_counter = 0; @@ -469,13 +473,21 @@ pub fn connect_to_vc( }; if is_speaking { - net_packet[0..4].copy_from_slice(&sequence.to_be_bytes()); - for (i, sample) in frame_buf.iter().enumerate() { + // sequence goes INSIDE the plaintext now, prefixed before the PCM + let mut plaintext = Vec::with_capacity(4 + PACKET_SAMPLES * 2); + plaintext.extend_from_slice(&sequence.to_be_bytes()); + + for sample in frame_buf.iter() { let pcm = (sample.clamp(-1.0, 1.0) * 32767.0) as i16; - let offset = HEADER_SIZE + i * 2; - net_packet[offset..offset + 2].copy_from_slice(&pcm.to_be_bytes()); + plaintext.extend_from_slice(&pcm.to_be_bytes()); } + let Ok(net_packet) = cipher.lock().unwrap().encrypt(&plaintext) else { + eprintln!("[vc] failed to encrypt outgoing packet"); + sequence = sequence.wrapping_add(1); + continue; + }; + let _ = input_socket.send(&net_packet); sequence = sequence.wrapping_add(1); } @@ -490,6 +502,7 @@ pub fn connect_to_vc( { let socket = socket.clone(); let shutdown = shutdown.clone(); + let cipher = cipher.clone(); thread::spawn(move || { let mut packets: BTreeMap> = BTreeMap::new(); @@ -501,15 +514,25 @@ pub fn connect_to_vc( while !shutdown.load(Ordering::Relaxed) { if let Ok(len) = socket.recv(&mut udp_buffer) { - if len > HEADER_SIZE { - let seq = u32::from_be_bytes(udp_buffer[0..4].try_into().unwrap()); - let pcm = &udp_buffer[HEADER_SIZE..len]; - let samples: Vec = pcm - .chunks_exact(2) - .map(|c| i16::from_be_bytes([c[0], c[1]]) as f32 / 32768.0) - .collect(); + match cipher.lock().unwrap().decrypt(&udp_buffer[..len]) { + Ok(plaintext) => { + if plaintext.len() < 4 { + eprintln!("[vc] dropped packet: too short after decrypt"); + } else { + let seq = u32::from_be_bytes(plaintext[..4].try_into().unwrap()); + let pcm = &plaintext[4..]; - packets.insert(seq, samples); + let samples: Vec = pcm + .chunks_exact(2) + .map(|c| i16::from_be_bytes([c[0], c[1]]) as f32 / 32768.0) + .collect(); + + packets.insert(seq, samples); + } + } + Err(_) => { + eprintln!("[vc] dropped packet: decryption failed"); + } } } diff --git a/src-tauri/src/crypto.rs b/src-tauri/src/crypto.rs new file mode 100644 index 0000000..e9e6509 --- /dev/null +++ b/src-tauri/src/crypto.rs @@ -0,0 +1,62 @@ +use chacha20poly1305::{aead::Aead, ChaCha20Poly1305, Nonce}; + +pub struct SessionCipher { + cipher: ChaCha20Poly1305, + send_counter: u64, + recv_counter: u64, +} + +impl SessionCipher { + pub fn new(cipher: ChaCha20Poly1305) -> Self { + Self { + cipher, + send_counter: 0, + recv_counter: 0, + } + } + + pub fn next_send_nonce(&mut self) -> [u8; 12] { + let mut nonce = [0u8; 12]; + nonce[..8].copy_from_slice(&self.send_counter.to_be_bytes()); + // top bit distinguishes "send" direction from "recv" direction, + // so client-send and server-send counters never collide even if + // both happened to reach the same numeric value + nonce[11] |= 0b1000_0000; + self.send_counter += 1; + nonce + } + + pub fn next_recv_nonce(&mut self) -> [u8; 12] { + let mut nonce = [0u8; 12]; + nonce[..8].copy_from_slice(&self.recv_counter.to_be_bytes()); + self.recv_counter += 1; + nonce + } + + pub fn encrypt(&mut self, plaintext: &[u8]) -> Result, String> { + let nonce_bytes = self.next_send_nonce(); + let nonce = Nonce::try_from(nonce_bytes).map_err(|v| v.to_string())?; + + let ciphertext = self + .cipher + .encrypt(&nonce, plaintext) + .map_err(|v| v.to_string())?; + + // prepend the nonce so the other side can reconstruct it on decrypt + let mut out = nonce_bytes.to_vec(); + out.extend(ciphertext); + Ok(out) + } + + pub fn decrypt(&mut self, data: &[u8]) -> Result, String> { + if data.len() < 12 { + return Err("message too short to contain a nonce".to_string()); + } + let (nonce_bytes, ciphertext) = data.split_at(12); + let nonce = Nonce::try_from(nonce_bytes).map_err(|v| v.to_string())?; + + self.cipher + .decrypt(&nonce, ciphertext) + .map_err(|v| v.to_string()) + } +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index cd3b64b..73dbfc8 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -3,6 +3,7 @@ use std::sync::Mutex; use crate::commands::config::ConfigState; pub mod commands; +pub mod crypto; pub mod types; #[cfg_attr(mobile, tauri::mobile_entry_point)] diff --git a/src/app/app.ts b/src/app/app.ts index 2d354c3..5ca3c46 100644 --- a/src/app/app.ts +++ b/src/app/app.ts @@ -171,7 +171,11 @@ export default class Enclave

{ if (!server || channel.kind !== "voice") return; server.voiceJoin = (pin, channelId) => { - invoke("connect_to_vc", { hostname: server.hostname, pin, channelId }) + invoke("connect_to_vc", { + hostname: server.hostname, + pin, + sharedSecret: server.websocket?.sharedSecret, + }) .then(() => { server.isInVoiceChat = true; server.voiceChannelId = channelId; diff --git a/src/app/ws.ts b/src/app/ws.ts index 465640b..f3f2f9c 100644 --- a/src/app/ws.ts +++ b/src/app/ws.ts @@ -18,7 +18,7 @@ export default class EnclaveWebSocket { private sendCounter = 0n; private recvCounter = 0n; - private sharedSecret: Uint8Array | null = null; + public sharedSecret: Uint8Array | null = null; private readonly handshakeReady: Promise; private resolveHandshake!: () => void; From e280614829e8e88247177a7bfd115d2cb460b01e Mon Sep 17 00:00:00 2001 From: Klesti Selimaj Date: Fri, 28 Aug 2026 15:27:31 +0200 Subject: [PATCH 11/12] TLS recommendation --- src/App.tsx | 58 +++++++++++--------- src/components/dialog/AddServerDialog.tsx | 26 +++++++-- src/components/ui/tooltip.tsx | 64 +++++++++++++++++++++++ 3 files changed, 117 insertions(+), 31 deletions(-) create mode 100644 src/components/ui/tooltip.tsx diff --git a/src/App.tsx b/src/App.tsx index bf24892..423ecfb 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -11,6 +11,7 @@ import Sidebar from "./components/view/Sidebar"; import { ThemeProvider } from "next-themes"; import Page from "./components/page/PageView"; import { SettingsDialog } from "./components/settings/SettingsDialog"; +import { TooltipProvider } from "./components/ui/tooltip"; export default function App() { const appRef = useRef(null); @@ -31,35 +32,40 @@ export default function App() { return ( -

- {appRef.current?.accounts && - appRef.current.accounts.accounts.length === 0 ? ( - - ) : ( - <> - - +
+ {appRef.current?.accounts && + appRef.current.accounts.accounts.length === 0 ? ( + + ) : ( + <> + -
- - -
- - + +
+ + +
+
+ - - - -
+ + + + - - - )} -
+ + + )} +
+ ); } diff --git a/src/components/dialog/AddServerDialog.tsx b/src/components/dialog/AddServerDialog.tsx index d9de9c7..580a198 100644 --- a/src/components/dialog/AddServerDialog.tsx +++ b/src/components/dialog/AddServerDialog.tsx @@ -12,7 +12,8 @@ import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Switch } from "@/components/ui/switch"; import { Button } from "@/components/ui/button"; -import { PlusIcon } from "lucide-react"; +import { Info, PlusIcon } from "lucide-react"; +import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip"; // hostname[:port][/path] — explicitly no protocol/scheme allowed const HOSTNAME_PATTERN = @@ -38,7 +39,7 @@ type AddServerDialogProps = { export function AddServerDialog({ onAdd }: AddServerDialogProps) { const [open, setOpen] = useState(false); const [hostname, setHostname] = useState(""); - const [isSecure, setIsSecure] = useState(true); + const [isSecure, setIsSecure] = useState(false); const [error, setError] = useState(null); const [submitting, setSubmitting] = useState(false); @@ -106,9 +107,24 @@ export function AddServerDialog({ onAdd }: AddServerDialogProps) {
- + + + + + + +

+ TLS is not recommended because there is a encryption layer + already built in to Enclave +

+
+
+ ) +} + +function Tooltip({ ...props }: TooltipPrimitive.Root.Props) { + return +} + +function TooltipTrigger({ ...props }: TooltipPrimitive.Trigger.Props) { + return +} + +function TooltipContent({ + className, + side = "top", + sideOffset = 4, + align = "center", + alignOffset = 0, + children, + ...props +}: TooltipPrimitive.Popup.Props & + Pick< + TooltipPrimitive.Positioner.Props, + "align" | "alignOffset" | "side" | "sideOffset" + >) { + return ( + + + + {children} + + + + + ) +} + +export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider } From 4f128915d010ef91115b53d6c00cca2a8bff4c21 Mon Sep 17 00:00:00 2001 From: Klesti Selimaj Date: Fri, 28 Aug 2026 16:32:18 +0200 Subject: [PATCH 12/12] Better voice chat UI and fix bug --- src/app/app.ts | 7 +- src/app/server.ts | 2 - src/app/ws.ts | 9 -- src/components/page/channel/VoiceChannel.tsx | 152 ++++++++++++------- src/components/view/StatusCard.tsx | 2 +- 5 files changed, 97 insertions(+), 75 deletions(-) diff --git a/src/app/app.ts b/src/app/app.ts index 5ca3c46..a5abb25 100644 --- a/src/app/app.ts +++ b/src/app/app.ts @@ -170,6 +170,8 @@ export default class Enclave

{ if (!server || channel.kind !== "voice") return; + if (server.voiceChannelId) this.leaveVoice(); + server.voiceJoin = (pin, channelId) => { invoke("connect_to_vc", { hostname: server.hostname, @@ -177,14 +179,12 @@ export default class Enclave

{ sharedSecret: server.websocket?.sharedSecret, }) .then(() => { - server.isInVoiceChat = true; server.voiceChannelId = channelId; this.forceRender(); }) .catch(console.error); }; - server.isInVoiceChat = true; server.voiceChannelId = channel.id; server.websocket?.send({ method: "JoinVoice", channel_id: channel.id }); @@ -195,12 +195,11 @@ export default class Enclave

{ public leaveVoice() { const server = this.server; - if (!server || !server.isInVoiceChat) return; + if (!server || !server.voiceChannelId) return; server.websocket?.send({ method: "LeaveVoice" }); invoke("disconnect_from_vc").catch(console.error); - server.isInVoiceChat = false; server.voiceChannelId = undefined; server.voiceChatSpeakers = {}; diff --git a/src/app/server.ts b/src/app/server.ts index ca67ec3..4b27b4c 100644 --- a/src/app/server.ts +++ b/src/app/server.ts @@ -37,7 +37,6 @@ export default class EnclaveServer { public voiceJoin?: (pin: bigint, channelId: string) => void; public voiceChatUsers: Record; public voiceChatSpeakers: Record; - public isInVoiceChat: boolean; public voiceChannelId?: string; public constructor(hostname: string, isSecure: boolean) { @@ -47,7 +46,6 @@ export default class EnclaveServer { this.users = {}; this.voiceChatUsers = {}; this.voiceChatSpeakers = {}; - this.isInVoiceChat = false; } public disconnect() { diff --git a/src/app/ws.ts b/src/app/ws.ts index f3f2f9c..5e3e3b0 100644 --- a/src/app/ws.ts +++ b/src/app/ws.ts @@ -17,7 +17,6 @@ export default class EnclaveWebSocket { onOpenQueue: Array<() => void>; private sendCounter = 0n; - private recvCounter = 0n; public sharedSecret: Uint8Array | null = null; private readonly handshakeReady: Promise; @@ -78,14 +77,6 @@ export default class EnclaveWebSocket { return nonce; } - private nextRecvNonce(): Uint8Array { - const nonce = new Uint8Array(12); - const view = new DataView(nonce.buffer); - view.setBigUint64(0, this.recvCounter, false); - this.recvCounter += 1n; - return nonce; - } - private encrypt(plaintext: Uint8Array): Uint8Array { if (!this.sharedSecret) throw new Error("Handshake not complete"); diff --git a/src/components/page/channel/VoiceChannel.tsx b/src/components/page/channel/VoiceChannel.tsx index 2fe0fc1..9ff1d54 100644 --- a/src/components/page/channel/VoiceChannel.tsx +++ b/src/components/page/channel/VoiceChannel.tsx @@ -1,8 +1,9 @@ import Enclave from "@/app/app"; import { ChannelPageProps } from "../PageView"; -import { useEffect } from "react"; import { cn } from "@/lib/utils"; import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; +import { Button } from "@/components/ui/button"; +import { Phone, Volume2 } from "lucide-react"; export default function VoiceChannel({ appRef, @@ -11,23 +12,15 @@ export default function VoiceChannel({ }) { const channel = appRef.current?.page?.channel; - useEffect(() => { - if (!channel || channel.kind !== "voice") return; - - const server = appRef.current?.server; - - if (!server || server.voiceChannelId === channel.id) return; - - appRef.current?.joinVoice(channel); - }, [channel?.id]); - if (!channel) return null; - const speakers = - appRef.current?.server?.voiceChatSpeakers && - Object.keys(appRef.current?.server?.voiceChatSpeakers); + const server = appRef.current?.server; + const isConnected = server?.voiceChannelId === channel.id; - const users = appRef.current?.server?.voiceChatUsers[channel.id]; + const speakers = + server?.voiceChatSpeakers && Object.keys(server.voiceChatSpeakers); + + const users = server?.voiceChatUsers[channel.id]; const usersLength = users?.length || 0; // Dynamic grid column layout based on user count @@ -45,63 +38,104 @@ export default function VoiceChannel({ return "h-14 w-14 text-lg"; }; + const handleJoinVoice = () => { + appRef.current?.joinVoice(channel); + }; + return ( -

+
{/* Header */} -
+

{channel.name}

{usersLength} {usersLength === 1 ? "Participant" : "Participants"}
- {/* Main Grid */} -
- {users?.map((pubkey) => { - const user = appRef.current?.server?.users[pubkey]; - const isSpeaking = speakers?.includes(pubkey); - const displayName = user?.displayName || "Unknown User"; - const fallbackLetter = displayName.charAt(0).toUpperCase(); + {/* Main Grid Container */} +
+ {/* Active Grid - Fully readable at all times */} +
+ {users?.map((pubkey) => { + const user = server?.users[pubkey]; + const isSpeaking = speakers?.includes(pubkey); + const displayName = user?.displayName || "Unknown User"; + const fallbackLetter = displayName.charAt(0).toUpperCase(); - return ( -
- {/* Avatar Container */} -
- - - - {fallbackLetter} - - + return ( +
+ {/* Avatar Container */} +
+ + + + {fallbackLetter} + + +
+ + {/* Name Plate */} +
+ {displayName} +
+
+ ); + })} +
+ + {/* Floating Join CTA Overlay */} + {!isConnected && ( +
+
+
+
- {/* Name Plate */} -
- {displayName} +
+

+ Ready to join {channel.name}? +

+

+ {usersLength > 0 + ? `${usersLength} ${usersLength === 1 ? "participant is" : "participants are"} currently connected.` + : "Channel is currently empty."} +

+ +
- ); - })} +
+ )}
); -} \ No newline at end of file +} diff --git a/src/components/view/StatusCard.tsx b/src/components/view/StatusCard.tsx index faee4fb..4644de8 100644 --- a/src/components/view/StatusCard.tsx +++ b/src/components/view/StatusCard.tsx @@ -39,7 +39,7 @@ export default function StatusCard({ return (
- {server?.isInVoiceChat && voiceChannel && ( + {voiceChannel && (