Merge pull request #11 from recurse-chat/feature/home-main-page-and-news-sidebar

Home main page, README, and cybersecurity news sidebar
This commit is contained in:
2026-08-29 09:08:03 -04:00
committed by GitHub
17 changed files with 1161 additions and 67 deletions
+131 -4
View File
@@ -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)
```
+329 -2
View File
@@ -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"
+3
View File
@@ -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"
-52
View File
@@ -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::<f32>().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();
+1
View File
@@ -1,4 +1,5 @@
pub mod accounts;
pub mod audio;
pub mod config;
pub mod news;
pub mod server_list;
+218
View File
@@ -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<RssItem> {
let mut reader = Reader::from_str(xml);
reader.config_mut().trim_text(false);
let mut items = Vec::new();
let mut current: Option<RssItem> = None;
// Stack of open element names (lowercased) while inside an <item>.
let mut stack: Vec<Vec<u8>> = 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<Vec<NewsItem>, 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<NewsItem> = 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)
}
+1
View File
@@ -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");
+1 -1
View File
@@ -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<Enclave | null>(null);
+6 -1
View File
@@ -36,6 +36,7 @@ export default class Enclave<P = Page> {
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<P = Page> {
isMuted: false,
isDeaf: false,
};
this.sidebarPageKind = "main";
}
public async init() {
@@ -106,6 +108,8 @@ export default class Enclave<P = Page> {
}
public async connectToServer(hostname: string, isSecure: boolean) {
this.sidebarPageKind = "server";
if (this.server) {
this.server.disconnect();
}
@@ -263,7 +267,8 @@ export default class Enclave<P = Page> {
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 (
+1 -1
View File
@@ -77,7 +77,7 @@ export function AddServerDialog({ onAdd }: AddServerDialogProps) {
render={() => (
<Button
variant="secondary"
className="aspect-square w-full h-auto rounded-full"
className="aspect-square w-full h-auto rounded-lg"
onClick={() => setOpen(true)}
>
<PlusIcon />
+255
View File
@@ -0,0 +1,255 @@
import Enclave from "@/app/app";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import {
ArrowUpRight,
BookOpen,
Fingerprint,
KeyRound,
LockKeyhole,
Network,
Server,
ShieldCheck,
} from "lucide-react";
import { openUrl } from "@tauri-apps/plugin-opener";
type Resource = {
title: string;
description: string;
url: string;
icon: React.ElementType;
};
const APP_RESOURCES: Resource[] = [
{
title: "Self-hosted servers",
description:
"Enclave connects to servers you or your community control. There is no central company operating the network — your messages live where you choose.",
url: "https://github.com/tauri-apps/tauri",
icon: Server,
},
{
title: "Keypair identity",
description:
"Your identity is an Ed25519 keypair generated locally. There is no email or password — no third party can reset your account or impersonate you.",
url: "https://github.com/paulmillr/noble-ed25519",
icon: Fingerprint,
},
{
title: "Signed, verifiable messages",
description:
"Every message carries an Ed25519 signature over its content and timestamp. Anything you receive can be verified to genuinely come from its author.",
url: "https://github.com/paulmillr/noble-ed25519",
icon: KeyRound,
},
{
title: "Built by ORUS",
description:
"One of many privacy-focused, self-hosted applications developed by ORUS using encrypted, decentralized architecture.",
url: "https://github.com/orus-dev",
icon: LockKeyhole,
},
];
const SECURITY_RESOURCES: Resource[] = [
{
title: "End-to-end encryption",
description:
"Encrypt data before it leaves your device so only intended recipients can read it. Learn how the model works and where it applies.",
url: "https://ssd.eff.org/module/why-should-i-use-encryption",
icon: ShieldCheck,
},
{
title: "Public-key cryptography",
description:
"Understand how keypairs sign and encrypt, and why your private key should never leave your device or be shared.",
url: "https://ssd.eff.org/module/deep-dive-end-end-encryption-how-do-public-key-encryption-systems-work",
icon: KeyRound,
},
{
title: "Self-hosting & data ownership",
description:
"Running your own services means you decide where data is stored and who can access it. Explore guides to getting started.",
url: "https://www.privacyguides.org/en/self-hosting/",
icon: Server,
},
{
title: "Transport security",
description:
"TLS and WebSocket handshakes protect data in transit. Learn how certificates and secure connections keep traffic safe.",
url: "https://www.cloudflare.com/learning/ssl/why-is-http-not-secure/",
icon: Network,
},
{
title: "Threat modeling",
description:
"Think about who you are protecting against and what you are protecting. Privacy starts with knowing your threat model.",
url: "https://www.privacyguides.org/en/basics/threat-modeling/",
icon: BookOpen,
},
{
title: "Password hygiene & key management",
description:
"Good habits for protecting accounts and credentials — and why your cryptographic keys deserve even more care.",
url: "https://ssd.eff.org/en/module/creating-strong-passwords",
icon: LockKeyhole,
},
{
title: "Tauri security",
description:
"Enclave is built on Tauri, a native desktop shell designed with a minimal attack surface. Review its security guidance.",
url: "https://v2.tauri.app/security/",
icon: ShieldCheck,
},
{
title: "Encryption libraries",
description:
"Enclave uses audited, dependency-light cryptographic primitives from the noble libraries. Read their documentation.",
url: "https://github.com/paulmillr/noble-hashes",
icon: KeyRound,
},
];
const LEARNING_RESOURCES: Resource[] = [
{
title: "Surveillance Self-Defense",
description:
"The Electronic Frontier Foundation's free how-to guides for safer online communication.",
url: "https://ssd.eff.org",
icon: BookOpen,
},
{
title: "Privacy Guides",
description:
"A community hub with practical recommendations for privacy tools and self-hosting.",
url: "https://www.privacyguides.org",
icon: ShieldCheck,
},
{
title: "Let's Encrypt",
description:
"Free, automated TLS certificates so anyone can secure their own server with HTTPS.",
url: "https://letsencrypt.org",
icon: Network,
},
{
title: "The Tor Project",
description:
"Research and tools for anonymous, censorship-resistant communication.",
url: "https://www.torproject.org",
icon: KeyRound,
},
];
function ResourceCard({ resource }: { resource: Resource }) {
const Icon = resource.icon;
return (
<Card className="flex flex-col">
<CardHeader className="flex-1">
<div className="mb-2 flex h-9 w-9 items-center justify-center rounded-lg bg-primary/10 text-primary">
<Icon className="h-4.5 w-4.5" />
</div>
<CardTitle className="flex items-center gap-1.5">
{resource.title}
</CardTitle>
<CardDescription>{resource.description}</CardDescription>
</CardHeader>
<CardContent>
<Button
variant="outline"
size="sm"
className="w-full"
onClick={() => openUrl(resource.url)}
>
Learn more
<ArrowUpRight data-icon="inline-end" />
</Button>
</CardContent>
</Card>
);
}
function ResourceSection({
title,
subtitle,
resources,
}: {
title: string;
subtitle: string;
resources: Resource[];
}) {
return (
<section className="space-y-4">
<div>
<h2 className="text-lg font-semibold">{title}</h2>
<p className="text-sm text-muted-foreground">{subtitle}</p>
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-4">
{resources.map((resource) => (
<ResourceCard key={resource.title} resource={resource} />
))}
</div>
</section>
);
}
export default function MainPage(_props: {
appRef: React.RefObject<Enclave | null>;
}) {
return (
<div className="h-screen w-full overflow-y-auto">
<div className="mx-auto w-full max-w-6xl px-6 py-10">
<header className="mb-10 flex flex-col items-start gap-4">
<h1 className="text-3xl font-semibold leading-tight">
Welcome to Enclave
</h1>
<p className="max-w-2xl text-sm text-muted-foreground">
Enclave is a privacy-focused messenger and voice client built by{" "}
<a
className="font-medium text-foreground underline"
href="https://github.com/orus-dev"
target="_blank"
>
ORUS
</a>{" "}
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.
</p>
<p className="max-w-2xl text-sm text-muted-foreground">
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.
</p>
</header>
<div className="space-y-10">
<ResourceSection
title="About this app"
subtitle="How Enclave keeps your conversations private."
resources={APP_RESOURCES}
/>
<ResourceSection
title="Privacy & security fundamentals"
subtitle="The concepts and guides behind trustworthy communication."
resources={SECURITY_RESOURCES}
/>
<ResourceSection
title="Learn more"
subtitle="Organizations and projects worth following."
resources={LEARNING_RESOURCES}
/>
</div>
</div>
</div>
);
}
+7 -2
View File
@@ -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<Enclave | null>;
}) {
if (!appRef.current?.page?.kind) return null;
if (!appRef.current?.page?.kind) return <MainPage appRef={appRef} />;
switch (appRef.current?.page?.kind) {
case "channel":
return <ChannelPage appRef={appRef} />;
return (
<ChannelPage
appRef={appRef as React.RefObject<Enclave<ChannelPageProps> | null>}
/>
);
}
}
+155
View File
@@ -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 (
<div className="flex flex-col gap-2.5 px-3 py-4">
<p className="text-xs text-muted-foreground">
Live news couldn't be loaded right now. Here are security resources you
can open manually:
</p>
{staticLinks.map((link) => (
<button
key={link.href}
className="group w-full rounded-md px-2 py-1.5 text-left hover:bg-accent"
onClick={() => openUrl(link.href)}
>
<span className="block truncate text-sm text-foreground whitespace-normal leading-tight group-hover:text-foreground">
{link.title}
</span>
<span className="text-xs text-muted-foreground">{link.source}</span>
</button>
))}
</div>
);
}
export default function MainPageSidebar(_props: {
appRef: React.RefObject<Enclave | null>;
}) {
const [items, setItems] = useState<NewsItem[] | null>(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 (
<div className="flex h-screen w-full flex-col">
<header className="flex items-center justify-between px-3 py-2.5 border-b border-b-border">
<h1 className="text-lg font-semibold flex items-center gap-2">
<Rss className="h-4 w-4 text-primary" />
Security News
</h1>
<Button
variant="ghost"
size="icon-sm"
disabled={loading}
onClick={load}
>
<RefreshCw className={cn("h-4 w-4", loading && "animate-spin")} />
</Button>
</header>
<section className="flex-1 overflow-y-scroll scrollbar-none [scrollbar-width:none] [&::-webkit-scrollbar]:hidden px-2 py-2">
{loading && items === null && (
<p className="px-2 py-4 text-sm text-muted-foreground">
Loading news…
</p>
)}
{error && !loading && <StaticFallback />}
{items && (
<div className="flex flex-col gap-0.5">
{items.map((item) => (
<button
key={item.link}
className="group flex w-full flex-col gap-1 rounded-md px-2 py-1.5 text-left hover:bg-accent"
onClick={() => openUrl(item.link)}
>
<span className="text-sm text-foreground leading-tight">
{item.title}
</span>
<span className="flex items-center gap-1.5 text-xs text-muted-foreground">
<span className="truncate">{item.source}</span>
{item.publishedAt > 0 && (
<>
<span aria-hidden>·</span>
<span className="shrink-0">
{formatTime(item.publishedAt)}
</span>
</>
)}
</span>
</button>
))}
</div>
)}
</section>
</div>
);
}
@@ -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<Enclave | null>;
channels: Channel[];
}) {
const currentChannel =
appRef.current?.page?.kind === "channel"
? appRef.current?.page?.channel
: undefined;
return channels.map((channel) =>
channel.kind === "category" ? (
<RenderCategory key={channel.id} appRef={appRef} channel={channel} />
@@ -123,7 +128,7 @@ export function RenderChannels({
<div
className={cn(
"w-full hover:bg-accent px-2.5 py-1.5 rounded-md flex gap-2.5 items-center select-none cursor-default text-sm text-muted-foreground",
channel.id === appRef.current?.page?.channel.id,
channel.id === currentChannel?.id,
)}
onClick={() => {
if (!appRef.current) {
@@ -145,7 +150,7 @@ export function RenderChannels({
);
}
export default function Sidebar({
export default function ServerSidebar({
appRef,
}: {
appRef: React.RefObject<Enclave | null>;
+18
View File
@@ -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<Enclave | null>;
}) {
switch (appRef.current?.sidebarPageKind) {
case "main":
return <MainPageSidebar appRef={appRef} />;
case "server":
return <ServerSidebar appRef={appRef} />;
}
return <MainPageSidebar appRef={appRef} />;
}
+15 -1
View File
@@ -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 (
<aside className="w-16 h-screen p-2 flex flex-col gap-2.5 border-r border-r-border shrink-0">
<Button
variant="secondary"
className="aspect-square w-full h-auto rounded-full"
onClick={() => {
if (appRef.current) {
appRef.current.sidebarPageKind = "main";
appRef.current.page = undefined;
appRef.current.forceRender();
}
}}
>
<CommandIcon />
</Button>
{appRef.current.serverList.map((server) => {
return (
<img
@@ -34,7 +49,6 @@ export default function ServerList({
/>
);
})}
<AddServerDialog
onAdd={(hostname, isSecure) => {
if (!appRef.current) {
+12
View File
@@ -0,0 +1,12 @@
import { invoke } from "@tauri-apps/api/core";
export type NewsItem = {
title: string;
link: string;
source: string;
publishedAt: number;
};
export async function getCyberNews(): Promise<NewsItem[]> {
return await invoke<NewsItem[]>("get_cyber_news");
}