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"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
|
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cfg_aliases"
|
||||||
|
version = "0.2.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "chacha20"
|
name = "chacha20"
|
||||||
version = "0.10.2"
|
version = "0.10.2"
|
||||||
@@ -590,6 +596,7 @@ dependencies = [
|
|||||||
"cfg-if",
|
"cfg-if",
|
||||||
"cipher",
|
"cipher",
|
||||||
"cpufeatures 0.3.1",
|
"cpufeatures 0.3.1",
|
||||||
|
"rand_core",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1165,6 +1172,8 @@ version = "0.1.0"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"chacha20poly1305",
|
"chacha20poly1305",
|
||||||
"cpal",
|
"cpal",
|
||||||
|
"quick-xml 0.37.5",
|
||||||
|
"reqwest 0.12.28",
|
||||||
"ringbuf",
|
"ringbuf",
|
||||||
"rubato",
|
"rubato",
|
||||||
"serde",
|
"serde",
|
||||||
@@ -1172,6 +1181,16 @@ dependencies = [
|
|||||||
"tauri",
|
"tauri",
|
||||||
"tauri-build",
|
"tauri-build",
|
||||||
"tauri-plugin-opener",
|
"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]]
|
[[package]]
|
||||||
@@ -1565,8 +1584,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
|
checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"cfg-if",
|
"cfg-if",
|
||||||
|
"js-sys",
|
||||||
"libc",
|
"libc",
|
||||||
"wasi",
|
"wasi",
|
||||||
|
"wasm-bindgen",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1588,9 +1609,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099"
|
checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"cfg-if",
|
"cfg-if",
|
||||||
|
"js-sys",
|
||||||
"libc",
|
"libc",
|
||||||
"r-efi 6.0.0",
|
"r-efi 6.0.0",
|
||||||
"rand_core",
|
"rand_core",
|
||||||
|
"wasm-bindgen",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1741,6 +1764,25 @@ dependencies = [
|
|||||||
"syn 2.0.119",
|
"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]]
|
[[package]]
|
||||||
name = "hashbrown"
|
name = "hashbrown"
|
||||||
version = "0.12.3"
|
version = "0.12.3"
|
||||||
@@ -1845,6 +1887,7 @@ dependencies = [
|
|||||||
"bytes",
|
"bytes",
|
||||||
"futures-channel",
|
"futures-channel",
|
||||||
"futures-core",
|
"futures-core",
|
||||||
|
"h2",
|
||||||
"http",
|
"http",
|
||||||
"http-body",
|
"http-body",
|
||||||
"httparse",
|
"httparse",
|
||||||
@@ -1855,6 +1898,22 @@ dependencies = [
|
|||||||
"want",
|
"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]]
|
[[package]]
|
||||||
name = "hyper-util"
|
name = "hyper-util"
|
||||||
version = "0.1.20"
|
version = "0.1.20"
|
||||||
@@ -2419,6 +2478,12 @@ version = "0.4.33"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
|
checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "lru-slab"
|
||||||
|
version = "0.1.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "mach2"
|
name = "mach2"
|
||||||
version = "0.6.0"
|
version = "0.6.0"
|
||||||
@@ -3076,7 +3141,7 @@ checksum = "7da1d65da6dd5d1e44199ac0f58712d241c0f439f80adea8924d832384087f85"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"base64 0.22.1",
|
"base64 0.22.1",
|
||||||
"indexmap 2.14.0",
|
"indexmap 2.14.0",
|
||||||
"quick-xml",
|
"quick-xml 0.41.0",
|
||||||
"serde",
|
"serde",
|
||||||
"time",
|
"time",
|
||||||
]
|
]
|
||||||
@@ -3254,6 +3319,15 @@ dependencies = [
|
|||||||
"thiserror 1.0.69",
|
"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]]
|
[[package]]
|
||||||
name = "quick-xml"
|
name = "quick-xml"
|
||||||
version = "0.41.0"
|
version = "0.41.0"
|
||||||
@@ -3263,6 +3337,62 @@ dependencies = [
|
|||||||
"memchr",
|
"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]]
|
[[package]]
|
||||||
name = "quote"
|
name = "quote"
|
||||||
version = "1.0.47"
|
version = "1.0.47"
|
||||||
@@ -3284,12 +3414,32 @@ version = "6.0.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
|
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]]
|
[[package]]
|
||||||
name = "rand_core"
|
name = "rand_core"
|
||||||
version = "0.10.1"
|
version = "0.10.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69"
|
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]]
|
[[package]]
|
||||||
name = "raw-window-handle"
|
name = "raw-window-handle"
|
||||||
version = "0.6.2"
|
version = "0.6.2"
|
||||||
@@ -3374,6 +3524,47 @@ version = "0.8.11"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4"
|
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]]
|
[[package]]
|
||||||
name = "reqwest"
|
name = "reqwest"
|
||||||
version = "0.13.4"
|
version = "0.13.4"
|
||||||
@@ -3408,6 +3599,20 @@ dependencies = [
|
|||||||
"web-sys",
|
"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]]
|
[[package]]
|
||||||
name = "ringbuf"
|
name = "ringbuf"
|
||||||
version = "0.5.1"
|
version = "0.5.1"
|
||||||
@@ -3477,12 +3682,53 @@ dependencies = [
|
|||||||
"windows-sys 0.61.2",
|
"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]]
|
[[package]]
|
||||||
name = "rustversion"
|
name = "rustversion"
|
||||||
version = "1.0.23"
|
version = "1.0.23"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f"
|
checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ryu"
|
||||||
|
version = "1.0.23"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "same-file"
|
name = "same-file"
|
||||||
version = "1.0.6"
|
version = "1.0.6"
|
||||||
@@ -3673,6 +3919,18 @@ dependencies = [
|
|||||||
"serde_core",
|
"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]]
|
[[package]]
|
||||||
name = "serde_with"
|
name = "serde_with"
|
||||||
version = "3.22.0"
|
version = "3.22.0"
|
||||||
@@ -3910,6 +4168,12 @@ version = "0.11.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
|
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "subtle"
|
||||||
|
version = "2.6.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "swift-rs"
|
name = "swift-rs"
|
||||||
version = "1.0.7"
|
version = "1.0.7"
|
||||||
@@ -4092,7 +4356,7 @@ dependencies = [
|
|||||||
"percent-encoding",
|
"percent-encoding",
|
||||||
"plist",
|
"plist",
|
||||||
"raw-window-handle",
|
"raw-window-handle",
|
||||||
"reqwest",
|
"reqwest 0.13.4",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"serde_repr",
|
"serde_repr",
|
||||||
@@ -4441,9 +4705,31 @@ dependencies = [
|
|||||||
"mio",
|
"mio",
|
||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
"socket2",
|
"socket2",
|
||||||
|
"tokio-macros",
|
||||||
"windows-sys 0.61.2",
|
"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]]
|
[[package]]
|
||||||
name = "tokio-util"
|
name = "tokio-util"
|
||||||
version = "0.7.19"
|
version = "0.7.19"
|
||||||
@@ -4453,6 +4739,7 @@ dependencies = [
|
|||||||
"bytes",
|
"bytes",
|
||||||
"futures-core",
|
"futures-core",
|
||||||
"futures-sink",
|
"futures-sink",
|
||||||
|
"libc",
|
||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
"tokio",
|
"tokio",
|
||||||
]
|
]
|
||||||
@@ -4783,6 +5070,12 @@ dependencies = [
|
|||||||
"ctutils",
|
"ctutils",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "untrusted"
|
||||||
|
version = "0.9.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "url"
|
name = "url"
|
||||||
version = "2.5.8"
|
version = "2.5.8"
|
||||||
@@ -4981,6 +5274,16 @@ dependencies = [
|
|||||||
"wasm-bindgen",
|
"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]]
|
[[package]]
|
||||||
name = "web_atoms"
|
name = "web_atoms"
|
||||||
version = "0.2.6"
|
version = "0.2.6"
|
||||||
@@ -5037,6 +5340,15 @@ dependencies = [
|
|||||||
"system-deps 6.2.2",
|
"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]]
|
[[package]]
|
||||||
name = "webview2-com"
|
name = "webview2-com"
|
||||||
version = "0.38.2"
|
version = "0.38.2"
|
||||||
@@ -5318,6 +5630,15 @@ dependencies = [
|
|||||||
"windows-targets 0.42.2",
|
"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]]
|
[[package]]
|
||||||
name = "windows-sys"
|
name = "windows-sys"
|
||||||
version = "0.59.0"
|
version = "0.59.0"
|
||||||
@@ -5709,6 +6030,12 @@ dependencies = [
|
|||||||
"synstructure",
|
"synstructure",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "zeroize"
|
||||||
|
version = "1.9.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "zerotrie"
|
name = "zerotrie"
|
||||||
version = "0.2.5"
|
version = "0.2.5"
|
||||||
|
|||||||
@@ -26,4 +26,7 @@ cpal = { version = "0.18.2", features = ["pipewire", "pulseaudio"] }
|
|||||||
ringbuf = "0.5.1"
|
ringbuf = "0.5.1"
|
||||||
rubato = "5.0.0"
|
rubato = "5.0.0"
|
||||||
chacha20poly1305 = "0.11.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 resampler = LinearResampler::new();
|
||||||
let mut mono_buffer = Vec::with_capacity(2048);
|
let mut mono_buffer = Vec::with_capacity(2048);
|
||||||
let mut resampled_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);
|
let inner_input_err = Arc::clone(&state_inner);
|
||||||
|
|
||||||
@@ -253,20 +252,6 @@ where
|
|||||||
.build_input_stream(
|
.build_input_stream(
|
||||||
config,
|
config,
|
||||||
move |data: &[T], _| {
|
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();
|
mono_buffer.clear();
|
||||||
resampled_buffer.clear();
|
resampled_buffer.clear();
|
||||||
|
|
||||||
@@ -352,7 +337,6 @@ where
|
|||||||
let mut raw_mono_samples = Vec::with_capacity(2048);
|
let mut raw_mono_samples = Vec::with_capacity(2048);
|
||||||
let mut resampled_mono = Vec::with_capacity(2048);
|
let mut resampled_mono = Vec::with_capacity(2048);
|
||||||
let mut last_sample = 0.0f32;
|
let mut last_sample = 0.0f32;
|
||||||
let mut callback_count: u64 = 0;
|
|
||||||
|
|
||||||
let inner_output_err = Arc::clone(&state_inner);
|
let inner_output_err = Arc::clone(&state_inner);
|
||||||
|
|
||||||
@@ -360,22 +344,12 @@ where
|
|||||||
.build_output_stream(
|
.build_output_stream(
|
||||||
config,
|
config,
|
||||||
move |data: &mut [T], _| {
|
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
|
let required_mono_samples = (data.len() / channels) * TARGET_SAMPLE_RATE as usize
|
||||||
/ native_sample_rate as usize;
|
/ native_sample_rate as usize;
|
||||||
|
|
||||||
raw_mono_samples.clear();
|
raw_mono_samples.clear();
|
||||||
resampled_mono.clear();
|
resampled_mono.clear();
|
||||||
|
|
||||||
let mut underflow_count = 0usize;
|
|
||||||
if let Ok(mut cons) = consumer.lock() {
|
if let Ok(mut cons) = consumer.lock() {
|
||||||
for _ in 0..required_mono_samples {
|
for _ in 0..required_mono_samples {
|
||||||
if let Some(s) = cons.try_pop() {
|
if let Some(s) = cons.try_pop() {
|
||||||
@@ -385,18 +359,9 @@ where
|
|||||||
// Exponential decay to prevent clicking when underflowing
|
// Exponential decay to prevent clicking when underflowing
|
||||||
last_sample *= 0.92;
|
last_sample *= 0.92;
|
||||||
raw_mono_samples.push(last_sample);
|
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
|
// Resample from 48kHz mono to target native output rate
|
||||||
resampler.process(
|
resampler.process(
|
||||||
@@ -635,12 +600,6 @@ pub fn connect_to_vc(
|
|||||||
};
|
};
|
||||||
|
|
||||||
loop_count += 1;
|
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 {
|
if is_speaking {
|
||||||
// sequence goes INSIDE the plaintext now, prefixed before the PCM
|
// 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 last_good_frame = vec![0.0f32; PACKET_SAMPLES];
|
||||||
let mut udp_buffer = [0u8; MAX_PACKET_SIZE];
|
let mut udp_buffer = [0u8; MAX_PACKET_SIZE];
|
||||||
let mut next_frame_time = Instant::now();
|
let mut next_frame_time = Instant::now();
|
||||||
let mut recv_count: u64 = 0;
|
|
||||||
let mut loop_count: u64 = 0;
|
|
||||||
|
|
||||||
while !shutdown.load(Ordering::Relaxed) {
|
while !shutdown.load(Ordering::Relaxed) {
|
||||||
let is_deaf = { backend_config.lock().unwrap().is_deaf };
|
let is_deaf = { backend_config.lock().unwrap().is_deaf };
|
||||||
@@ -704,9 +661,7 @@ pub fn connect_to_vc(
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
loop_count += 1;
|
|
||||||
if let Ok(len) = socket.recv(&mut udp_buffer) {
|
if let Ok(len) = socket.recv(&mut udp_buffer) {
|
||||||
recv_count += 1;
|
|
||||||
match cipher.lock().unwrap().decrypt(&udp_buffer[..len]) {
|
match cipher.lock().unwrap().decrypt(&udp_buffer[..len]) {
|
||||||
Ok(plaintext) => {
|
Ok(plaintext) => {
|
||||||
if plaintext.len() < 4 {
|
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 is_prebuffering {
|
||||||
if packets.len() >= INITIAL_PACKET_CUSHION {
|
if packets.len() >= INITIAL_PACKET_CUSHION {
|
||||||
expected = packets.keys().next().copied();
|
expected = packets.keys().next().copied();
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
pub mod accounts;
|
pub mod accounts;
|
||||||
pub mod audio;
|
pub mod audio;
|
||||||
pub mod config;
|
pub mod config;
|
||||||
|
pub mod news;
|
||||||
pub mod server_list;
|
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::save_config,
|
||||||
commands::config::get_config,
|
commands::config::get_config,
|
||||||
commands::config::update_backend_config,
|
commands::config::update_backend_config,
|
||||||
|
commands::news::get_cyber_news,
|
||||||
])
|
])
|
||||||
.run(tauri::generate_context!())
|
.run(tauri::generate_context!())
|
||||||
.expect("error while running tauri application");
|
.expect("error while running tauri application");
|
||||||
|
|||||||
+1
-1
@@ -7,11 +7,11 @@ import {
|
|||||||
ResizablePanel,
|
ResizablePanel,
|
||||||
ResizablePanelGroup,
|
ResizablePanelGroup,
|
||||||
} from "./components/ui/resizable";
|
} from "./components/ui/resizable";
|
||||||
import Sidebar from "./components/view/Sidebar";
|
|
||||||
import { ThemeProvider } from "next-themes";
|
import { ThemeProvider } from "next-themes";
|
||||||
import Page from "./components/page/PageView";
|
import Page from "./components/page/PageView";
|
||||||
import { SettingsDialog } from "./components/settings/SettingsDialog";
|
import { SettingsDialog } from "./components/settings/SettingsDialog";
|
||||||
import { TooltipProvider } from "./components/ui/tooltip";
|
import { TooltipProvider } from "./components/ui/tooltip";
|
||||||
|
import Sidebar from "./components/sidebar/Sidebar";
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
const appRef = useRef<Enclave | null>(null);
|
const appRef = useRef<Enclave | null>(null);
|
||||||
|
|||||||
+6
-1
@@ -36,6 +36,7 @@ export default class Enclave<P = Page> {
|
|||||||
public isSettingsOpen: boolean;
|
public isSettingsOpen: boolean;
|
||||||
public forceRender: () => void;
|
public forceRender: () => void;
|
||||||
public page?: P;
|
public page?: P;
|
||||||
|
public sidebarPageKind: "server" | "main";
|
||||||
public config?: Config;
|
public config?: Config;
|
||||||
public backendConfig: BackendConfig;
|
public backendConfig: BackendConfig;
|
||||||
|
|
||||||
@@ -47,6 +48,7 @@ export default class Enclave<P = Page> {
|
|||||||
isMuted: false,
|
isMuted: false,
|
||||||
isDeaf: false,
|
isDeaf: false,
|
||||||
};
|
};
|
||||||
|
this.sidebarPageKind = "main";
|
||||||
}
|
}
|
||||||
|
|
||||||
public async init() {
|
public async init() {
|
||||||
@@ -106,6 +108,8 @@ export default class Enclave<P = Page> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public async connectToServer(hostname: string, isSecure: boolean) {
|
public async connectToServer(hostname: string, isSecure: boolean) {
|
||||||
|
this.sidebarPageKind = "server";
|
||||||
|
|
||||||
if (this.server) {
|
if (this.server) {
|
||||||
this.server.disconnect();
|
this.server.disconnect();
|
||||||
}
|
}
|
||||||
@@ -263,7 +267,8 @@ export default class Enclave<P = Page> {
|
|||||||
|
|
||||||
const page = this.page as Page | undefined;
|
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) => {
|
return messages.map((message) => {
|
||||||
if (
|
if (
|
||||||
|
|||||||
@@ -77,7 +77,7 @@ export function AddServerDialog({ onAdd }: AddServerDialogProps) {
|
|||||||
render={() => (
|
render={() => (
|
||||||
<Button
|
<Button
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
className="aspect-square w-full h-auto rounded-full"
|
className="aspect-square w-full h-auto rounded-lg"
|
||||||
onClick={() => setOpen(true)}
|
onClick={() => setOpen(true)}
|
||||||
>
|
>
|
||||||
<PlusIcon />
|
<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 Enclave from "@/app/app";
|
||||||
import ChannelPage from "./channel/ChannelPage";
|
import ChannelPage from "./channel/ChannelPage";
|
||||||
import { Channel } from "@/lib/types";
|
import { Channel } from "@/lib/types";
|
||||||
|
import MainPage from "./MainPage";
|
||||||
|
|
||||||
export type ChannelPageProps = {
|
export type ChannelPageProps = {
|
||||||
kind: "channel";
|
kind: "channel";
|
||||||
@@ -14,10 +15,14 @@ export default function Page({
|
|||||||
}: {
|
}: {
|
||||||
appRef: React.RefObject<Enclave | null>;
|
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) {
|
switch (appRef.current?.page?.kind) {
|
||||||
case "channel":
|
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 { Channel, ChannelKind } from "@/lib/types";
|
||||||
import { ChevronDown, ChevronUp, HashIcon, Volume2Icon } from "lucide-react";
|
import { ChevronDown, ChevronUp, HashIcon, Volume2Icon } from "lucide-react";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import StatusCard from "./StatusCard";
|
import StatusCard from "../view/StatusCard";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { Avatar, AvatarFallback, AvatarImage } from "../ui/avatar";
|
import { Avatar, AvatarFallback, AvatarImage } from "../ui/avatar";
|
||||||
|
|
||||||
@@ -115,6 +115,11 @@ export function RenderChannels({
|
|||||||
appRef: React.RefObject<Enclave | null>;
|
appRef: React.RefObject<Enclave | null>;
|
||||||
channels: Channel[];
|
channels: Channel[];
|
||||||
}) {
|
}) {
|
||||||
|
const currentChannel =
|
||||||
|
appRef.current?.page?.kind === "channel"
|
||||||
|
? appRef.current?.page?.channel
|
||||||
|
: undefined;
|
||||||
|
|
||||||
return channels.map((channel) =>
|
return channels.map((channel) =>
|
||||||
channel.kind === "category" ? (
|
channel.kind === "category" ? (
|
||||||
<RenderCategory key={channel.id} appRef={appRef} channel={channel} />
|
<RenderCategory key={channel.id} appRef={appRef} channel={channel} />
|
||||||
@@ -123,7 +128,7 @@ export function RenderChannels({
|
|||||||
<div
|
<div
|
||||||
className={cn(
|
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",
|
"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={() => {
|
onClick={() => {
|
||||||
if (!appRef.current) {
|
if (!appRef.current) {
|
||||||
@@ -145,7 +150,7 @@ export function RenderChannels({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Sidebar({
|
export default function ServerSidebar({
|
||||||
appRef,
|
appRef,
|
||||||
}: {
|
}: {
|
||||||
appRef: React.RefObject<Enclave | null>;
|
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 { getHTTPUrl, saveServerList } from "@/lib/serverList";
|
||||||
import { AddServerDialog } from "../dialog/AddServerDialog";
|
import { AddServerDialog } from "../dialog/AddServerDialog";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
import { Button } from "../ui/button";
|
||||||
|
import { CommandIcon } from "lucide-react";
|
||||||
|
|
||||||
export default function ServerList({
|
export default function ServerList({
|
||||||
appRef,
|
appRef,
|
||||||
@@ -12,6 +14,19 @@ export default function ServerList({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<aside className="w-16 h-screen p-2 flex flex-col gap-2.5 border-r border-r-border shrink-0">
|
<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) => {
|
{appRef.current.serverList.map((server) => {
|
||||||
return (
|
return (
|
||||||
<img
|
<img
|
||||||
@@ -34,7 +49,6 @@ export default function ServerList({
|
|||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|
||||||
<AddServerDialog
|
<AddServerDialog
|
||||||
onAdd={(hostname, isSecure) => {
|
onAdd={(hostname, isSecure) => {
|
||||||
if (!appRef.current) {
|
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