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:
@@ -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)
|
||||
```
|
||||
|
||||
Generated
+329
-2
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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,4 +1,5 @@
|
||||
pub mod accounts;
|
||||
pub mod audio;
|
||||
pub mod config;
|
||||
pub mod news;
|
||||
pub mod server_list;
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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 (
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>;
|
||||
@@ -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} />;
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
Reference in New Issue
Block a user