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-tauri/Cargo.lock b/src-tauri/Cargo.lock index e5acfe6..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" @@ -229,6 +239,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" @@ -286,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" @@ -475,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" @@ -487,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" @@ -609,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" @@ -643,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" @@ -682,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" @@ -800,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]] @@ -961,9 +1082,10 @@ checksum = "4ef6b89e5b37196644d8796de5268852ff179b44e96276cf4290264843743bb7" name = "enclave" version = "0.1.0" dependencies = [ + "chacha20poly1305", "cpal", - "resampler", "ringbuf", + "rubato", "serde", "serde_json", "tauri", @@ -1359,6 +1481,7 @@ dependencies = [ "cfg-if", "libc", "r-efi 6.0.0", + "rand_core", ] [[package]] @@ -1594,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" @@ -1813,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" @@ -2239,6 +2380,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 +2406,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" @@ -2756,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" @@ -2792,6 +2961,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" @@ -2884,12 +3062,27 @@ 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" 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 +3186,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 +3197,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 +3228,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" @@ -3305,7 +3522,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.2.17", "digest", ] @@ -3429,6 +3646,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 +4406,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" @@ -4287,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" @@ -4342,6 +4585,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 +4866,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..4555b87 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -23,6 +23,7 @@ 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" +chacha20poly1305 = "0.11.0" diff --git a/src-tauri/src/commands/audio.rs b/src-tauri/src/commands/audio.rs index b8a2ac3..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,22 +18,17 @@ use ringbuf::{ }; use tauri::State; -use crate::commands::config::ConfigState; +use crate::{commands::config::ConfigState, crypto::SessionCipher}; -const PACKET_SAMPLES: usize = 960; // 20ms @ 48kHz -const HEADER_SIZE: usize = 4; +const TARGET_SAMPLE_RATE: u32 = 48_000; +const PACKET_SAMPLES: usize = 960; 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,19 +341,19 @@ pub fn disconnect_from_vc(voice_state: State<'_, VoiceState>) -> Result<(), Stri Ok(()) } -// ============================================================================ -// CONNECT -// ============================================================================ - #[tauri::command] 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); @@ -280,7 +406,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 +428,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,42 +437,21 @@ 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(); + 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; @@ -369,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); } @@ -386,10 +498,11 @@ pub fn connect_to_vc( }); } - // UDP Receiver Thread + // Receiver Thread { let socket = socket.clone(); let shutdown = shutdown.clone(); + let cipher = cipher.clone(); thread::spawn(move || { let mut packets: BTreeMap> = BTreeMap::new(); @@ -401,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"); + } } } @@ -477,65 +600,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); 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-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.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/app/app.ts b/src/app/app.ts index 55efdec..a5abb25 100644 --- a/src/app/app.ts +++ b/src/app/app.ts @@ -7,7 +7,10 @@ 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"; ed.hashes.sha512 = sha512; @@ -31,6 +34,7 @@ export default class Enclave

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

{ public async init() { this.serverList = await getServerList(); this.accounts = await getAccounts(); + this.config = await getConfig(); } public getAccount(): Account | null { @@ -129,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); }; } @@ -149,6 +165,47 @@ export default class Enclave

{ this.forceRender(); } + public joinVoice(channel: Channel) { + const server = this.server; + + if (!server || channel.kind !== "voice") return; + + if (server.voiceChannelId) this.leaveVoice(); + + server.voiceJoin = (pin, channelId) => { + invoke("connect_to_vc", { + hostname: server.hostname, + pin, + sharedSecret: server.websocket?.sharedSecret, + }) + .then(() => { + server.voiceChannelId = channelId; + this.forceRender(); + }) + .catch(console.error); + }; + + server.voiceChannelId = channel.id; + + server.websocket?.send({ method: "JoinVoice", channel_id: channel.id }); + + this.forceRender(); + } + + public leaveVoice() { + const server = this.server; + + if (!server || !server.voiceChannelId) return; + + server.websocket?.send({ method: "LeaveVoice" }); + invoke("disconnect_from_vc").catch(console.error); + + 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..4b27b4c 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; @@ -36,6 +37,7 @@ export default class EnclaveServer { public voiceJoin?: (pin: bigint, channelId: string) => void; public voiceChatUsers: Record; public voiceChatSpeakers: Record; + public voiceChannelId?: string; public constructor(hostname: string, isSecure: boolean) { this.hostname = hostname; @@ -57,6 +59,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..5e3e3b0 100644 --- a/src/app/ws.ts +++ b/src/app/ws.ts @@ -1,46 +1,136 @@ 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; + public 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()); }; + + // 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); + } + + private handleHandshakeMessage(msg: MessageEvent) { + const serverPubkey = new Uint8Array(msg.data as ArrayBuffer); + + this.sharedSecret = x25519.getSharedSecret( + this.myX25519PrivateKey, + 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(); + } + + private nextSendNonce(): Uint8Array { + const nonce = new Uint8Array(12); + const view = new DataView(nonce.buffer); + 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; + } + + 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); + + 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 = data.slice(0, 12); + const ciphertext = data.slice(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); + const encrypted = new Uint8Array(msg.data as ArrayBuffer); + const plaintext = this.decrypt(encrypted); + const data = JSON.parse(new TextDecoder().decode(plaintext)); ok(data); this.websocket.onmessage = null; }; 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 +

+
+
| null>; }) { 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 server = appRef.current?.server; + const isConnected = server?.voiceChannelId === channel.id; const speakers = - appRef.current?.server?.voiceChatSpeakers && - Object.keys(appRef.current?.server?.voiceChatSpeakers); + server?.voiceChatSpeakers && Object.keys(server.voiceChatSpeakers); - const users = appRef.current?.server?.voiceChatUsers[channel.id]; + const users = server?.voiceChatUsers[channel.id]; const usersLength = users?.length || 0; // Dynamic grid column layout based on user count @@ -82,86 +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."} +

+ +
- ); - })} -
- - {/* Bottom Control Bar */} -
- {isConnected ? ( - - ) : ( - +
)} - +
); } diff --git a/src/components/ui/tooltip.tsx b/src/components/ui/tooltip.tsx new file mode 100644 index 0000000..96a9ec2 --- /dev/null +++ b/src/components/ui/tooltip.tsx @@ -0,0 +1,64 @@ +import { Tooltip as TooltipPrimitive } from "@base-ui/react/tooltip" + +import { cn } from "@/lib/utils" + +function TooltipProvider({ + delay = 0, + ...props +}: TooltipPrimitive.Provider.Props) { + return ( + + ) +} + +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 } 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..5aed5af 100644 --- a/src/components/view/Sidebar.tsx +++ b/src/components/view/Sidebar.tsx @@ -2,8 +2,9 @@ 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"; +import { Avatar, AvatarFallback, AvatarImage } from "../ui/avatar"; export function ChannelIcon({ kind }: { kind: ChannelKind["kind"] }) { switch (kind) { @@ -49,6 +50,64 @@ 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]; + const isCurrentVc = channel.id === appRef.current?.server?.voiceChannelId; + + return ( + users && ( +
+ {(isCurrentVc ? users : users.slice(0, 15)).map((pubkey) => { + 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() || + pubkey.slice(0, 2).toUpperCase()} + + + + + {user.displayName} + +
+ ); + })} + {!isCurrentVc && users.length > 15 && ( + + and {users.length - 15} others + + )} +
+ ) + ); + default: + return null; + } +} + export function RenderChannels({ appRef, channels, @@ -60,25 +119,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} +
+
), ); @@ -97,7 +158,7 @@ export default function Sidebar({

{appRef.current.server.meta.name}

-
+
)} - +
); } diff --git a/src/components/view/StatusCard.tsx b/src/components/view/StatusCard.tsx new file mode 100644 index 0000000..4644de8 --- /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 ( +
+ + {voiceChannel && ( +
+ +
+ Voice Connected + + {voiceChannel.name} + +
+ +
+ )} +
+
+ + + {account.meta.displayName[0]} + +
+ { + navigator.clipboard.writeText( + base58.encode( + ed.getPublicKey(base58.decode(account.privateKey)), + ), + ); + }} + > +

{account.meta.displayName}

+ +
+ Online +
+
+
+ +
+
+
+
+ ); +}