diff --git a/README.md b/README.md index 102e366..5942f20 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,134 @@ -# Tauri + React + Typescript +# Enclave -This template should help get you started developing with Tauri, React and Typescript in Vite. +Privacy-focused messenger and voice client built by [ORUS](https://github.com/orus-dev). -## Recommended IDE Setup +Enclave is a desktop app for private, self-hosted community chat and voice. It +connects to servers that you or your community control — there is no central +company operating the network, and no third party that can reset your identity +or read your messages. Your identity is a cryptographic keypair generated +locally, and everything you send is signed so it can be verified to genuinely +come from you. -- [VS Code](https://code.visualstudio.com/) + [Tauri](https://marketplace.visualstudio.com/items?itemName=tauri-apps.tauri-vscode) + [rust-analyzer](https://marketplace.visualstudio.com/items?itemName=rust-lang.rust-analyzer) +Select a server from the left rail and join a channel to start chatting. + +## About this project + +Enclave is one of several privacy-focused, self-hosted applications built by +ORUS using encrypted, decentralized architecture. + +- **Self-hosted servers** — Enclave connects to servers you or your community + control. There is no central company operating the network; your messages + live where you choose. Servers are [trust-on-first-use pinned](#keypins) so + their identity can't quietly change. +- **Keypair identity** — Your identity is an [Ed25519](https://github.com/paulmillr/noble-ed25519) + keypair generated locally on first launch. There is no email or password, so + no third party can reset your account or impersonate you. +- **Signed, verifiable messages** — Every message carries an Ed25519 signature + over its content and timestamp. Anything you receive can be independently + verified to come from its author. +- **Encrypted realtime transport** — Client and server exchange a shared + secret over an encrypted WebSocket, and voice traffic is encrypted in transit. + +## How Enclave works + +Enclave is a [Tauri 2](https://v2.tauri.app/) desktop application with a React +front end and a Rust backend. + +| Layer | Tech | +| ------------- | -------------------------------------------------------------------------------------------------------------------------------------- | +| UI | React 19, TypeScript, Tailwind CSS 4, shadcn/ui, Base UI | +| App shell | Tauri 2 | +| Cryptography | [noble](https://github.com/paulmillr/noble-ed25519) (Ed25519, hashes, ciphers), [@scure/base](https://github.com/paulmillr/scure-base) | +| Realtime | WebSocket via `EnclaveServer` / `EnclaveWebSocket` | +| Audio & voice | Rust with `cpal`, `ringbuf`, `rubato`, `chacha20poly1305` | + +### Architecture + +The front end is a thin display of state owned by the `Enclave` class +(`src/app/app.ts`). It owns every active connection — one `EnclaveServer` per +connected server, keyed by server id — and is where all UI-facing logic lives +(connecting to a server, sending a message, joining voice). The UI never talks +directly to a server's WebSocket; it only reads and mutates what `Enclave` +exposes. + +- `src/app/` — top-level application state, server connection logic, and the + protocol between client and server. +- `src/lib/` — persistence (`accounts`, `serverList`, `config`) and shared + types. +- `src/components/` — UI: server list, sidebar, channels, settings, and dialogs. +- `src-tauri/` — the Rust backend: window setup, storage, and audio/voice + handling. + +### A privacy-focused protocol + +Enclave's protocol (`src/app/protocol.ts`) is built around public-key +cryptography rather than accounts: + +- **Local keypairs** — On first launch you generate an Ed25519 keypair. Your + public key is your handle; nothing is stored with a company, and there is no + password to leak or reset. +- **Trust on first use** — When you connect to a server, its public key is + pinned. If that server later presents a different key, Enclave refuses to + connect, so you always know you're talking to the server you chose. +- **Signed messages** — Message contents are signed with your key before being + sent (`src/app/app.ts`), and the server stores and relays the signatures so + recipients can verify origin and integrity. +- **End-to-end confidentiality** — Traffic flows over encrypted channels and, + when TLS is enabled on the server, an `https://` transport, so data in + transit stays protected. + +## Privacy & security + +- **Own your data** — your identity is a keypair you hold. No central service + stores your messages or your credentials. +- **Verify before you trust** — signatures and keypins mean you can confirm + every message is authentic and every server is the one you picked. +- **Minimal attack surface** — built on Tauri's small native shell and + dependency-light, audited noble cryptography primitives. +- **Self-hosting** — running your own server means you decide where data is + stored and who can access it. + +Your private key never leaves your device. Guard it like you would any secret — +back it up and keep it safe, and it is the only thing that proves who you are. + +## Contributing + +### Prerequisites + +- [Node.js](https://nodejs.org/) + npm +- [Rust](https://www.rust-lang.org/) toolchain +- Tauri's platform prerequisites (see the + [Tauri docs](https://v2.tauri.app/start/prerequisites/)) + +### Development + +```bash +npm install +npm run tauri dev +``` + +### Build + +```bash +npm run tauri build +``` + +### Scripts + +| Command | Description | +| --------------------- | ---------------------------------- | +| `npm run dev` | Run the Vite dev server | +| `npm run typecheck` | Type-check the TypeScript codebase | +| `npm run build` | Type-check and build the front end | +| `npm run tauri dev` | Run the desktop app in development | +| `npm run tauri build` | Build a release bundle | + +## Repo layout + +``` +src/ + app/ # Enclave state, server logic, client/server protocol + components/ # UI: server list, sidebar, channels, settings + lib/ # persistence helpers and shared types +src-tauri/ # Rust backend (window, storage, audio/voice) +``` diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 4198d48..a62fdb6 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -581,6 +581,12 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +[[package]] +name = "cfg_aliases" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" + [[package]] name = "chacha20" version = "0.10.2" @@ -590,6 +596,7 @@ dependencies = [ "cfg-if", "cipher", "cpufeatures 0.3.1", + "rand_core", ] [[package]] @@ -1165,6 +1172,8 @@ version = "0.1.0" dependencies = [ "chacha20poly1305", "cpal", + "quick-xml 0.37.5", + "reqwest 0.12.28", "ringbuf", "rubato", "serde", @@ -1172,6 +1181,16 @@ dependencies = [ "tauri", "tauri-build", "tauri-plugin-opener", + "tokio", +] + +[[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if", ] [[package]] @@ -1565,8 +1584,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" dependencies = [ "cfg-if", + "js-sys", "libc", "wasi", + "wasm-bindgen", ] [[package]] @@ -1588,9 +1609,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" dependencies = [ "cfg-if", + "js-sys", "libc", "r-efi 6.0.0", "rand_core", + "wasm-bindgen", ] [[package]] @@ -1741,6 +1764,25 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "h2" +version = "0.4.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef8e5e5a340588f4452631496976cf8636d4a7ecf600239fdc27615d2530bc16" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http", + "indexmap 2.14.0", + "slab", + "tokio", + "tokio-util", + "tracing", +] + [[package]] name = "hashbrown" version = "0.12.3" @@ -1845,6 +1887,7 @@ dependencies = [ "bytes", "futures-channel", "futures-core", + "h2", "http", "http-body", "httparse", @@ -1855,6 +1898,22 @@ dependencies = [ "want", ] +[[package]] +name = "hyper-rustls" +version = "0.27.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "tokio", + "tokio-rustls", + "tower-service", + "webpki-roots", +] + [[package]] name = "hyper-util" version = "0.1.20" @@ -2419,6 +2478,12 @@ version = "0.4.33" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + [[package]] name = "mach2" version = "0.6.0" @@ -3076,7 +3141,7 @@ checksum = "7da1d65da6dd5d1e44199ac0f58712d241c0f439f80adea8924d832384087f85" dependencies = [ "base64 0.22.1", "indexmap 2.14.0", - "quick-xml", + "quick-xml 0.41.0", "serde", "time", ] @@ -3254,6 +3319,15 @@ dependencies = [ "thiserror 1.0.69", ] +[[package]] +name = "quick-xml" +version = "0.37.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "331e97a1af0bf59823e6eadffe373d7b27f485be8748f71471c662c1f269b7fb" +dependencies = [ + "memchr", +] + [[package]] name = "quick-xml" version = "0.41.0" @@ -3263,6 +3337,62 @@ dependencies = [ "memchr", ] +[[package]] +name = "quinn" +version = "0.11.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c1a41e437b6bbd489372cd4971de128e85c855f56c57f283d20ff016cf7c0a8" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2", + "thiserror 2.0.20", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04759210543be93709136e28212294a659ef5001836ff4eab4d663e4529bba83" +dependencies = [ + "bytes", + "getrandom 0.4.3", + "lru-slab", + "rand", + "rand_pcg", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror 2.0.20", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35a133f956daabe89a61a685c2649f13d82d5aa4bd5d12d1277e1072a21c0694" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.61.2", +] + [[package]] name = "quote" version = "1.0.47" @@ -3284,12 +3414,32 @@ version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" +[[package]] +name = "rand" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" +dependencies = [ + "chacha20", + "getrandom 0.4.3", + "rand_core", +] + [[package]] name = "rand_core" version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" +[[package]] +name = "rand_pcg" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a" +dependencies = [ + "rand_core", +] + [[package]] name = "raw-window-handle" version = "0.6.2" @@ -3374,6 +3524,47 @@ version = "0.8.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64 0.22.1", + "bytes", + "encoding_rs", + "futures-core", + "h2", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "mime", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "webpki-roots", +] + [[package]] name = "reqwest" version = "0.13.4" @@ -3408,6 +3599,20 @@ dependencies = [ "web-sys", ] +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + [[package]] name = "ringbuf" version = "0.5.1" @@ -3477,12 +3682,53 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "rustls" +version = "0.23.43" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0283386ce02abc0151e1761d08802dfe86c173b0b494af5cbc086574e453da06" +dependencies = [ + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + [[package]] name = "rustversion" version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + [[package]] name = "same-file" version = "1.0.6" @@ -3673,6 +3919,18 @@ dependencies = [ "serde_core", ] +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + [[package]] name = "serde_with" version = "3.22.0" @@ -3910,6 +4168,12 @@ version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + [[package]] name = "swift-rs" version = "1.0.7" @@ -4092,7 +4356,7 @@ dependencies = [ "percent-encoding", "plist", "raw-window-handle", - "reqwest", + "reqwest 0.13.4", "serde", "serde_json", "serde_repr", @@ -4441,9 +4705,31 @@ dependencies = [ "mio", "pin-project-lite", "socket2", + "tokio-macros", "windows-sys 0.61.2", ] +[[package]] +name = "tokio-macros" +version = "2.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + [[package]] name = "tokio-util" version = "0.7.19" @@ -4453,6 +4739,7 @@ dependencies = [ "bytes", "futures-core", "futures-sink", + "libc", "pin-project-lite", "tokio", ] @@ -4783,6 +5070,12 @@ dependencies = [ "ctutils", ] +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + [[package]] name = "url" version = "2.5.8" @@ -4981,6 +5274,16 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + [[package]] name = "web_atoms" version = "0.2.6" @@ -5037,6 +5340,15 @@ dependencies = [ "system-deps 6.2.2", ] +[[package]] +name = "webpki-roots" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "webview2-com" version = "0.38.2" @@ -5318,6 +5630,15 @@ dependencies = [ "windows-targets 0.42.2", ] +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + [[package]] name = "windows-sys" version = "0.59.0" @@ -5709,6 +6030,12 @@ dependencies = [ "synstructure", ] +[[package]] +name = "zeroize" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" + [[package]] name = "zerotrie" version = "0.2.5" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 26f6187..5d5aa62 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -26,4 +26,7 @@ cpal = { version = "0.18.2", features = ["pipewire", "pulseaudio"] } ringbuf = "0.5.1" rubato = "5.0.0" chacha20poly1305 = "0.11.0" +reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "charset", "http2"] } +tokio = { version = "1", features = ["time", "macros"] } +quick-xml = "0.37" diff --git a/src-tauri/src/commands/audio.rs b/src-tauri/src/commands/audio.rs index 63c9a7f..916ab3b 100644 --- a/src-tauri/src/commands/audio.rs +++ b/src-tauri/src/commands/audio.rs @@ -245,7 +245,6 @@ where let mut resampler = LinearResampler::new(); let mut mono_buffer = Vec::with_capacity(2048); let mut resampled_buffer = Vec::with_capacity(2048); - let mut callback_count: u64 = 0; let inner_input_err = Arc::clone(&state_inner); @@ -253,20 +252,6 @@ where .build_input_stream( config, move |data: &[T], _| { - callback_count += 1; - if callback_count % 200 == 0 { - let peak: f32 = data - .iter() - .map(|&s| s.to_sample::().abs()) - .fold(0.0f32, f32::max); - eprintln!( - "[vc] input callback #{callback_count}: {} frames, {} ch, peak {:.4}", - data.len() / channels, - channels, - peak - ); - } - mono_buffer.clear(); resampled_buffer.clear(); @@ -352,7 +337,6 @@ where let mut raw_mono_samples = Vec::with_capacity(2048); let mut resampled_mono = Vec::with_capacity(2048); let mut last_sample = 0.0f32; - let mut callback_count: u64 = 0; let inner_output_err = Arc::clone(&state_inner); @@ -360,22 +344,12 @@ where .build_output_stream( config, move |data: &mut [T], _| { - callback_count += 1; - if callback_count % 200 == 0 { - eprintln!( - "[vc] output callback #{callback_count}: {} frames, {} ch", - data.len() / channels, - channels - ); - } - let required_mono_samples = (data.len() / channels) * TARGET_SAMPLE_RATE as usize / native_sample_rate as usize; raw_mono_samples.clear(); resampled_mono.clear(); - let mut underflow_count = 0usize; if let Ok(mut cons) = consumer.lock() { for _ in 0..required_mono_samples { if let Some(s) = cons.try_pop() { @@ -385,18 +359,9 @@ where // Exponential decay to prevent clicking when underflowing last_sample *= 0.92; raw_mono_samples.push(last_sample); - underflow_count += 1; } } } - if callback_count % 200 == 0 { - eprintln!( - "[vc] output: got {}/{} mono samples ({} underflow)", - required_mono_samples - underflow_count, - required_mono_samples, - underflow_count - ); - } // Resample from 48kHz mono to target native output rate resampler.process( @@ -635,12 +600,6 @@ pub fn connect_to_vc( }; loop_count += 1; - if loop_count % 200 == 0 { - eprintln!( - "[vc] sender: buffered {} samples, frame rms {rms:.4}, floor {noise_floor:.4}, thr {threshold:.4}, speaking {is_speaking}, seq {sequence}", - consumer_in.occupied_len() - ); - } if is_speaking { // sequence goes INSIDE the plaintext now, prefixed before the PCM @@ -690,8 +649,6 @@ pub fn connect_to_vc( let mut last_good_frame = vec![0.0f32; PACKET_SAMPLES]; let mut udp_buffer = [0u8; MAX_PACKET_SIZE]; let mut next_frame_time = Instant::now(); - let mut recv_count: u64 = 0; - let mut loop_count: u64 = 0; while !shutdown.load(Ordering::Relaxed) { let is_deaf = { backend_config.lock().unwrap().is_deaf }; @@ -704,9 +661,7 @@ pub fn connect_to_vc( continue; } - loop_count += 1; if let Ok(len) = socket.recv(&mut udp_buffer) { - recv_count += 1; match cipher.lock().unwrap().decrypt(&udp_buffer[..len]) { Ok(plaintext) => { if plaintext.len() < 4 { @@ -729,13 +684,6 @@ pub fn connect_to_vc( } } - if loop_count % 2000 == 0 { - eprintln!( - "[vc] receiver: received {recv_count} packets total, {} buffered, prebuffering {is_prebuffering}, expected {expected:?}", - packets.len() - ); - } - if is_prebuffering { if packets.len() >= INITIAL_PACKET_CUSHION { expected = packets.keys().next().copied(); diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index 62c6f59..965d450 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -1,4 +1,5 @@ pub mod accounts; pub mod audio; pub mod config; +pub mod news; pub mod server_list; diff --git a/src-tauri/src/commands/news.rs b/src-tauri/src/commands/news.rs new file mode 100644 index 0000000..dbaaf3d --- /dev/null +++ b/src-tauri/src/commands/news.rs @@ -0,0 +1,218 @@ +use quick_xml::events::Event; +use quick_xml::Reader; +use serde::Serialize; + +#[derive(Debug, Serialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct NewsItem { + pub title: String, + pub link: String, + pub source: String, + pub published_at: i64, +} + +struct Feed { + url: &'static str, + source: &'static str, +} + +const FEEDS: &[Feed] = &[ + Feed { + url: "https://feeds.feedburner.com/TheHackersNews", + source: "The Hacker News", + }, + Feed { + url: "https://www.cisa.gov/cybersecurity-advisories/all.xml", + source: "CISA", + }, + Feed { + url: "https://krebsonsecurity.com/feed/", + source: "Krebs on Security", + }, + Feed { + url: "https://www.bleepingcomputer.com/feed/", + source: "BleepingComputer", + }, +]; + +const MAX_ITEMS: usize = 15; + +#[derive(Default, Clone)] +struct RssItem { + title: String, + link: String, + pub_date: String, +} + +fn field_map() -> &'static [(&'static [u8], &'static str)] { + &[ + (b"title", "title"), + (b"link", "link"), + (b"pubDate", "pubDate"), + ] +} + +fn parse_feed(xml: &str) -> Vec { + let mut reader = Reader::from_str(xml); + reader.config_mut().trim_text(false); + + let mut items = Vec::new(); + let mut current: Option = None; + // Stack of open element names (lowercased) while inside an . + let mut stack: Vec> = Vec::new(); + let mut buf = Vec::new(); + + loop { + match reader.read_event_into(&mut buf) { + Ok(Event::Start(e)) => { + let name = e.local_name().as_ref().to_ascii_lowercase(); + if name == b"item" { + current = Some(RssItem::default()); + } + stack.push(name); + } + Ok(Event::End(e)) => { + let name = e.local_name().as_ref().to_ascii_lowercase(); + if name == b"item" { + if let Some(item) = current.take() { + if !item.title.is_empty() && !item.link.is_empty() { + items.push(item); + } + } + } + stack.pop(); + } + Ok(Event::Text(e)) => { + if let Some(item) = current.as_mut() { + if let Ok(text) = std::str::from_utf8(e.as_ref()) { + for (tag, field) in field_map() { + if stack.last().map(|s| s.as_slice()) == Some(*tag) { + match *field { + "title" => item.title.push_str(text), + "link" => item.link.push_str(text), + "pubDate" => item.pub_date.push_str(text), + _ => {} + } + } + } + } + } + } + Ok(Event::Eof) | Err(_) => break, + _ => {} + } + buf.clear(); + } + + for item in items.iter_mut() { + item.title = item.title.trim().to_string(); + item.link = item.link.trim().to_string(); + item.pub_date = item.pub_date.trim().to_string(); + } + + items +} + +/// Best-effort RFC 822 RSS date parsing; returns 0 on any failure. +fn parse_date(value: &str) -> i64 { + if value.is_empty() { + return 0; + } + let value = value.split_once(',').map(|(_, rest)| rest).unwrap_or(value); + let parts: Vec<&str> = value.split_whitespace().collect(); + if parts.len() < 5 { + return 0; + } + + let day: i64 = parts[0].parse().unwrap_or(0); + let month = match parts[1] { + "Jan" => 1, + "Feb" => 2, + "Mar" => 3, + "Apr" => 4, + "May" => 5, + "Jun" => 6, + "Jul" => 7, + "Aug" => 8, + "Sep" => 9, + "Oct" => 10, + "Nov" => 11, + "Dec" => 12, + _ => 0, + }; + let year: i64 = parts[2].parse().unwrap_or(0); + let hm: Vec<&str> = parts[3].split(':').collect(); + if hm.len() < 3 || year == 0 || month == 0 || day == 0 { + return 0; + } + let hour: i64 = hm[0].parse().unwrap_or(0); + let minute: i64 = hm[1].parse().unwrap_or(0); + let second: i64 = hm[2].parse().unwrap_or(0); + + let days_before_month = [0, 31, 59, 90, 120, 151, 181, 212, 243, 273, 304, 334]; + let leap = (year % 4 == 0 && year % 100 != 0) || year % 400 == 0; + let yday = days_before_month[(month - 1) as usize] + day + if leap && month > 2 { 1 } else { 0 }; + + let days = (year - 1970) * 365 + (year - 1969) / 4 + yday - 1; + days * 86_400 + hour * 3_600 + minute * 60 + second +} + +#[tauri::command] +pub async fn get_cyber_news() -> Result, String> { + let client = reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(10)) + .build() + .map_err(|e| format!("failed to build http client: {e}"))?; + + let mut all_items: Vec = Vec::new(); + + for feed in FEEDS { + let resp = client + .get(feed.url) + .header("User-Agent", "Mozilla/5.0") + .header( + "Accept", + "application/rss+xml, application/xml;q=0.9, text/xml;q=0.8, */*;q=0.7", + ) + .send() + .await; + + let Ok(resp) = resp else { continue }; + if !resp.status().is_success() { + continue; + } + let Ok(body) = resp.text().await else { + continue; + }; + + all_items.extend( + parse_feed(&body) + .into_iter() + .map(|item| NewsItem { + title: item.title, + link: item.link, + source: feed.source.to_string(), + published_at: parse_date(&item.pub_date), + }), + ); + } + + if all_items.is_empty() { + return Err("No cyber news available right now.".to_string()); + } + + all_items.sort_by(|a, b| b.published_at.cmp(&a.published_at)); + + let mut seen = std::collections::HashSet::new(); + let mut deduped = Vec::new(); + for item in all_items { + if seen.insert(item.link.clone()) { + deduped.push(item); + if deduped.len() >= MAX_ITEMS { + break; + } + } + } + + Ok(deduped) +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 69fb5d8..750c8da 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -25,6 +25,7 @@ pub fn run() { commands::config::save_config, commands::config::get_config, commands::config::update_backend_config, + commands::news::get_cyber_news, ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); diff --git a/src/App.tsx b/src/App.tsx index 423ecfb..52d1655 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -7,11 +7,11 @@ import { ResizablePanel, ResizablePanelGroup, } from "./components/ui/resizable"; -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"; +import Sidebar from "./components/sidebar/Sidebar"; export default function App() { const appRef = useRef(null); diff --git a/src/app/app.ts b/src/app/app.ts index c1f3a8b..0878c61 100644 --- a/src/app/app.ts +++ b/src/app/app.ts @@ -36,6 +36,7 @@ export default class Enclave

{ public isSettingsOpen: boolean; public forceRender: () => void; public page?: P; + public sidebarPageKind: "server" | "main"; public config?: Config; public backendConfig: BackendConfig; @@ -47,6 +48,7 @@ export default class Enclave

{ isMuted: false, isDeaf: false, }; + this.sidebarPageKind = "main"; } public async init() { @@ -106,6 +108,8 @@ export default class Enclave

{ } public async connectToServer(hostname: string, isSecure: boolean) { + this.sidebarPageKind = "server"; + if (this.server) { this.server.disconnect(); } @@ -263,7 +267,8 @@ export default class Enclave

{ const page = this.page as Page | undefined; - const isInvisibleChannel = page?.channel.id !== channelId; + const isInvisibleChannel = + page?.kind === "channel" && page?.channel.id !== channelId; return messages.map((message) => { if ( diff --git a/src/components/dialog/AddServerDialog.tsx b/src/components/dialog/AddServerDialog.tsx index 580a198..e04e328 100644 --- a/src/components/dialog/AddServerDialog.tsx +++ b/src/components/dialog/AddServerDialog.tsx @@ -77,7 +77,7 @@ export function AddServerDialog({ onAdd }: AddServerDialogProps) { render={() => ( + + + ); +} + +function ResourceSection({ + title, + subtitle, + resources, +}: { + title: string; + subtitle: string; + resources: Resource[]; +}) { + return ( +

+
+

{title}

+

{subtitle}

+
+
+ {resources.map((resource) => ( + + ))} +
+
+ ); +} + +export default function MainPage(_props: { + appRef: React.RefObject; +}) { + return ( +
+
+
+

+ Welcome to Enclave +

+

+ Enclave is a privacy-focused messenger and voice client built by{" "} + + ORUS + {" "} + using self-hosted servers, local keypair identities, and + cryptographic signatures. Nothing here requires an account with a + central company — you own your identity and your data. +

+

+ Select a server on the left and choose a channel to start chatting. + Below you'll find resources about how Enclave works, plus guides on + protecting your privacy and security. +

+
+ +
+ + + + + +
+
+
+ ); +} diff --git a/src/components/page/PageView.tsx b/src/components/page/PageView.tsx index 7ba5401..1969aa6 100644 --- a/src/components/page/PageView.tsx +++ b/src/components/page/PageView.tsx @@ -1,6 +1,7 @@ import Enclave from "@/app/app"; import ChannelPage from "./channel/ChannelPage"; import { Channel } from "@/lib/types"; +import MainPage from "./MainPage"; export type ChannelPageProps = { kind: "channel"; @@ -14,10 +15,14 @@ export default function Page({ }: { appRef: React.RefObject; }) { - if (!appRef.current?.page?.kind) return null; + if (!appRef.current?.page?.kind) return ; switch (appRef.current?.page?.kind) { case "channel": - return ; + return ( + | null>} + /> + ); } } diff --git a/src/components/sidebar/MainPageSidebar.tsx b/src/components/sidebar/MainPageSidebar.tsx new file mode 100644 index 0000000..0b72932 --- /dev/null +++ b/src/components/sidebar/MainPageSidebar.tsx @@ -0,0 +1,155 @@ +import { useCallback, useEffect, useState } from "react"; +import Enclave from "@/app/app"; +import { getCyberNews, NewsItem } from "@/lib/cyberNews"; +import { openUrl } from "@tauri-apps/plugin-opener"; +import { Button } from "@/components/ui/button"; +import { RefreshCw, Rss } from "lucide-react"; +import { cn } from "@/lib/utils"; + +function formatTime(publishedAt: number): string { + if (!publishedAt) return ""; + + const diff = Date.now() - publishedAt; + const minutes = Math.floor(diff / 60_000); + + if (minutes < 1) return "just now"; + if (minutes < 60) return `${minutes}m ago`; + + const hours = Math.floor(minutes / 60); + if (hours < 24) return `${hours}h ago`; + + const days = Math.floor(hours / 24); + return `${days}d ago`; +} + +function StaticFallback() { + const staticLinks = [ + { + title: "Surveillance Self-Defense", + source: "EFF", + href: "https://ssd.eff.org", + }, + { + title: "Privacy Guides", + source: "Community", + href: "https://www.privacyguides.org", + }, + { + title: "The Hacker News", + source: "News", + href: "https://thehackernews.com", + }, + { + title: "Krebs on Security", + source: "News", + href: "https://krebsonsecurity.com", + }, + { + title: "CISA Alerts", + source: "Gov", + href: "https://www.cisa.gov/news-events/cybersecurity-advisories", + }, + ]; + + return ( +
+

+ Live news couldn't be loaded right now. Here are security resources you + can open manually: +

+ {staticLinks.map((link) => ( + + ))} +
+ ); +} + +export default function MainPageSidebar(_props: { + appRef: React.RefObject; +}) { + const [items, setItems] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(false); + + const load = useCallback(() => { + setLoading(true); + setError(false); + + getCyberNews() + .then((news) => setItems(news)) + .catch(() => { + setItems(null); + setError(true); + }) + .finally(() => setLoading(false)); + }, []); + + useEffect(() => { + load(); + }, [load]); + + return ( +
+
+

+ + Security News +

+ +
+ +
+ {loading && items === null && ( +

+ Loading news… +

+ )} + + {error && !loading && } + + {items && ( +
+ {items.map((item) => ( + + ))} +
+ )} +
+
+ ); +} diff --git a/src/components/view/Sidebar.tsx b/src/components/sidebar/ServerSidebar.tsx similarity index 95% rename from src/components/view/Sidebar.tsx rename to src/components/sidebar/ServerSidebar.tsx index 5aed5af..3b20c19 100644 --- a/src/components/view/Sidebar.tsx +++ b/src/components/sidebar/ServerSidebar.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 StatusCard from "./StatusCard"; +import StatusCard from "../view/StatusCard"; import { cn } from "@/lib/utils"; import { Avatar, AvatarFallback, AvatarImage } from "../ui/avatar"; @@ -115,6 +115,11 @@ export function RenderChannels({ appRef: React.RefObject; channels: Channel[]; }) { + const currentChannel = + appRef.current?.page?.kind === "channel" + ? appRef.current?.page?.channel + : undefined; + return channels.map((channel) => channel.kind === "category" ? ( @@ -123,7 +128,7 @@ export function RenderChannels({
{ if (!appRef.current) { @@ -145,7 +150,7 @@ export function RenderChannels({ ); } -export default function Sidebar({ +export default function ServerSidebar({ appRef, }: { appRef: React.RefObject; diff --git a/src/components/sidebar/Sidebar.tsx b/src/components/sidebar/Sidebar.tsx new file mode 100644 index 0000000..2eca1ea --- /dev/null +++ b/src/components/sidebar/Sidebar.tsx @@ -0,0 +1,18 @@ +import Enclave from "@/app/app"; +import ServerSidebar from "./ServerSidebar"; +import MainPageSidebar from "./MainPageSidebar"; + +export default function Sidebar({ + appRef, +}: { + appRef: React.RefObject; +}) { + switch (appRef.current?.sidebarPageKind) { + case "main": + return ; + case "server": + return ; + } + + return ; +} diff --git a/src/components/view/ServerList.tsx b/src/components/view/ServerList.tsx index f23fd30..d6a8674 100644 --- a/src/components/view/ServerList.tsx +++ b/src/components/view/ServerList.tsx @@ -2,6 +2,8 @@ import Enclave from "@/app/app"; import { getHTTPUrl, saveServerList } from "@/lib/serverList"; import { AddServerDialog } from "../dialog/AddServerDialog"; import { cn } from "@/lib/utils"; +import { Button } from "../ui/button"; +import { CommandIcon } from "lucide-react"; export default function ServerList({ appRef, @@ -12,6 +14,19 @@ export default function ServerList({ return (