add docs, protocol, plugins, README, CHANGELOG
This commit is contained in:
parent
1d2209bfac
commit
f262da222b
44 changed files with 9269 additions and 0 deletions
151
docs/00-overview.md
Normal file
151
docs/00-overview.md
Normal file
|
|
@ -0,0 +1,151 @@
|
|||
# Overview
|
||||
|
||||
> Start with [00-status.md](00-status.md) for what is implemented in v0.1.x vs what is only specified.
|
||||
|
||||
## What is VNOX
|
||||
|
||||
VNOX is a **self-hosted, federated, encrypted, plugin-first** realtime communication platform for communities and gamers.
|
||||
It handles voice, text chat, and presence — without requiring any central infrastructure.
|
||||
|
||||
Every VNOX deployment is a node. Nodes can federate with each other.
|
||||
You own your data, your identity, your infrastructure.
|
||||
|
||||
## What is LNEx
|
||||
|
||||
LNEx (Loki Network Exchange) is the protocol layer VNOX runs on.
|
||||
|
||||
It sits above TCP and UDP and handles:
|
||||
|
||||
- packet structure and framing
|
||||
- encryption
|
||||
- routing between nodes
|
||||
- federation identity
|
||||
|
||||
LNEx is versioned independently from VNOX clients and server implementations.
|
||||
Current version: `LNEx v1`.
|
||||
|
||||
## What VNOX is not
|
||||
|
||||
VNOX is not a Discord clone. It does not have a central server, centralized accounts,
|
||||
or a cloud dashboard. There is no VNOX Inc. managing your community.
|
||||
|
||||
VNOX is not a TeamSpeak clone. The architecture is different:
|
||||
federated, modular, and built for modern async networking.
|
||||
|
||||
VNOX is not SaaS. There is no subscription. There is no hosted version.
|
||||
You run it, you own it.
|
||||
|
||||
---
|
||||
|
||||
## What does VNOX mean
|
||||
|
||||
**Self-hosted** — you run it, you own it. No cloud, no vendor lock-in, no subscription.
|
||||
|
||||
**Federated** — nodes can talk to each other. Your community isn't an island (Phase 3).
|
||||
|
||||
**Encrypted** — all traffic is encrypted with ChaCha20-Poly1305 AEAD + X25519 ECDH key exchange. Forward secrecy by default.
|
||||
|
||||
**Plugin-first** — extend the platform with plugins via WebSocket RPC. Bots, moderation, automations. Sandboxed by design.
|
||||
|
||||
---
|
||||
|
||||
## Core Philosophy
|
||||
|
||||
### 1. Self-hosted first
|
||||
|
||||
Users host their own nodes. There is no required central infrastructure.
|
||||
A VNOX network can exist entirely between privately operated machines.
|
||||
|
||||
Typical deployment:
|
||||
- community server (gateway + voice node)
|
||||
- private relay node
|
||||
- federation gateway (Phase 3)
|
||||
|
||||
### 2. Realtime first
|
||||
|
||||
Voice latency is the primary performance target. Everything else is secondary.
|
||||
|
||||
Targets:
|
||||
- end-to-end voice latency < 50ms on local network
|
||||
- jitter buffer adaptive, default 40ms
|
||||
- packet loss concealment via Opus
|
||||
|
||||
### 3. Modular architecture
|
||||
|
||||
Every function is a separate module:
|
||||
|
||||
```
|
||||
gateway — auth, channels, sessions, permissions, federation routing
|
||||
voice-node — Opus encoding, UDP relay, jitter buffer, packet sequencing
|
||||
overlay — in-game HUD, speaking indicators
|
||||
plugins — bots, automations, moderation
|
||||
```
|
||||
|
||||
Modules can be deployed together or separately.
|
||||
|
||||
### 4. Developer-first
|
||||
|
||||
VNOX ships with:
|
||||
- protocol specification (LNEx)
|
||||
- plugin API (WebSocket RPC)
|
||||
- SDK
|
||||
- full configuration reference
|
||||
|
||||
Third-party clients and server implementations are explicitly supported.
|
||||
|
||||
---
|
||||
|
||||
## Branding
|
||||
|
||||
### Product names
|
||||
|
||||
```
|
||||
VNOX — the platform
|
||||
VNOX Client — desktop application
|
||||
VNOX Node — server / gateway instance
|
||||
VNOX Overlay — in-game HUD
|
||||
LNEx — the protocol layer
|
||||
LNEx v1 — current protocol version
|
||||
LNEx Relay — relay node
|
||||
LNEx Federation — cross-node federation
|
||||
```
|
||||
|
||||
### URL scheme
|
||||
|
||||
```
|
||||
vnox://server/channel — connect to channel
|
||||
lnex://node — raw node address
|
||||
```
|
||||
|
||||
### Identity format
|
||||
|
||||
```
|
||||
user@node — federated identity
|
||||
4f3a8b2c… — local pubkey short form
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Comparison
|
||||
|
||||
| | VNOX | Discord | TeamSpeak | Mumble | Matrix |
|
||||
|---|---|---|---|---|---|
|
||||
| Self-hosted | required | no | yes | yes | yes |
|
||||
| Federated | Phase 3 | no | no | no | yes |
|
||||
| No central accounts | yes | no | no | yes | no |
|
||||
| Native client | yes | custom desktop stack | yes | yes | Electron |
|
||||
| Plugin API | yes | yes | yes | no | no |
|
||||
| Voice codec | Opus | Opus | Opus | Opus | Opus |
|
||||
| Protocol | LNEx (custom) | proprietary | proprietary | MUMBLE | Matrix |
|
||||
| Open source | yes | no | no | yes | yes |
|
||||
|
||||
### Use cases
|
||||
|
||||
**Gaming clan / CS2 / shooter community**
|
||||
Low latency voice, push-to-talk, overlay, private node. Phase 1 covers this fully.
|
||||
|
||||
**OSS project community**
|
||||
Self-hosted, no vendor dependency, plugin API for bots and CI integrations.
|
||||
|
||||
**Private team / corp**
|
||||
On-premise deployment, no data leaving your infrastructure, identity without email.
|
||||
119
docs/00-status.md
Normal file
119
docs/00-status.md
Normal file
|
|
@ -0,0 +1,119 @@
|
|||
# Current status (Phase 2 prep / v0.1.x)
|
||||
|
||||
This file describes what the repository actually does today.
|
||||
Protocol and security docs may describe future phases. When in doubt, trust this file.
|
||||
|
||||
Last updated: 2026-06-29.
|
||||
|
||||
---
|
||||
|
||||
## What works
|
||||
|
||||
- **Gateway (TCP):** Ed25519 client auth, sessions, channel join/leave, text chat, SQLite history, guilds, roles, permissions, invites, audit log, rate limiting.
|
||||
- **Gateway (HTTP admin):** `GET /health`, `GET /version`, `GET /metrics` (Prometheus exposition). Default bind 0.0.0.0:7601.
|
||||
- **Voice node (UDP):** relay between clients in the same channel; members expire after 30 s idle; jitter buffer wired into relay path.
|
||||
- **Client:** connect UI, channel list, text chat, net layer, voice capture/playback wired to UI, friends/DM panel, guild bar with create + invite-accept popups, right-side member list panel, message context menu (react/edit/delete/reply/copy), custom status + activity status, block list, channel create UI, encrypted identity vault.
|
||||
- **Server identity:** gateway generates or loads `server_identity.json` in the configured data directory.
|
||||
- **UI refactoring:** modular structure (`app/`, `chat/`, `connect/`, `sidebar/`, `members/`, `state/`, `settings/`).
|
||||
- **Session management:** reconnect with exponential backoff, node switching.
|
||||
- **Audio pipeline:** PTT / VAD / always-on modes, configurable bitrate, jitter buffer.
|
||||
- **Noise suppression:** RNNoise (feature-gated, off by default).
|
||||
- **Bookmarks:** save/remove nodes in connect screen.
|
||||
- **Docker:** multi-stage builds for gateway + voice-node, docker-compose.
|
||||
- **UDP voice encryption:** ChaCha20-Poly1305 AEAD on voice packets (Phase 1.1 — DONE).
|
||||
- **Direct Messages:** 1:1 DM with persistent history, canonical DM ID format (Phase 1.1 — DONE).
|
||||
- Protocol: DmStart (0x0060), DmMessage (0x0061), DmHistory (0x0062), DmReadAck, DmSearch.
|
||||
- Database: `direct_messages` and `dm_messages` tables with indexes.
|
||||
- Handlers: dm_start, dm_send, dm_history with server-authoritative timestamps and sender validation.
|
||||
- Delivery: targeted broadcast to online recipient; offline messages persisted to DB.
|
||||
- Last 50 messages returned on DM open and history fetch.
|
||||
- UI: sidebar DM list with unread badges, DM conversation panel, search bar.
|
||||
- **Community model (Phase 1.2 — DONE):** guilds, roles with permission bits, invites (permanent + temporary), guild member kick, audit log on every guild mutation.
|
||||
- **Friends system (Phase 1.2 — DONE):** friend requests, accept/decline, friend list with Online/All/Pending/Blocked tabs, pending count badge, Add Friend popup, per-friend DM shortcut and remove.
|
||||
- **Block list (Phase 1.3 — DONE):** block/unblock commands wired end-to-end, Blocked tab UI with input + Unblock button.
|
||||
- **Presence system (Phase 1.2/1.3 — DONE):** ONLINE / IDLE / DND / INVISIBLE status cycler in user bar, custom status text, activity type (playing/listening/watching/streaming) + activity text, broadcast on change, presence sync on connect.
|
||||
- **Phase 1.3 chat polish:** message reactions (emoji), message editing (with "(edited)" marker), message deletion, typing indicators (multi-user), read receipts, **replies** (with italic reply indicator showing original author + snippet), **context menu** (quick-react, reply, copy, edit, delete).
|
||||
- **Speaking indicators (Phase 1.3 — DONE):** green dot/ring on local user when transmitting (PTT/VAD), green highlight on remote voice panel members when voice packets arrive, voice activity banner with speaker attribution ("🔊 alice is talking").
|
||||
- **Per-user speaking attribution (Phase 1.3 — DONE):** voice packet plaintext extended with sender_id (raw Ed25519 pubkey), receivers attribute activity to specific user.
|
||||
- **Right-side member list panel:** shows online members of the active text channel with avatars and status dots.
|
||||
- **Rate limiting (Phase 2 — DONE):** per-session token bucket on chat/DM messages (default 5/s, burst 10), `ErrorCode::RateLimited` reply on exceed.
|
||||
- **Prometheus metrics (Phase 2 — DONE):** counters for messages, DMs, voice packets, connections, auth failures, rate-limited events, errors, guilds, friends requests, sessions, channels, uptime.
|
||||
- **Identity encryption at rest (Phase 2 — DONE):** Argon2id passphrase + ChaCha20-Poly1305 AEAD, opt-in via passphrase; backward-compatible with legacy plain `identity.json`.
|
||||
- **Channel creation UI (Phase 1.3 — DONE):** "Create a Channel" popup with name + type (text/voice) selector — adds channel to local sidebar.
|
||||
|
||||
---
|
||||
|
||||
## Implemented in code but incomplete
|
||||
|
||||
| Area | Reality |
|
||||
|------|---------|
|
||||
| Voice capture/playback | Starts when a voice channel is selected; Opus over UDP via net layer, encrypted. |
|
||||
| SDK crate | Present as a stub; not a usable public API yet. |
|
||||
| Federation crate | Stub only (`// TODO`). |
|
||||
| RNNoise | Feature-gated, no-op stub by default. Enable with `--features rnnoise`. |
|
||||
| Slint migration | Plan exists at `docs/superpowers/plans/2026-05-31-slint-migration.md`; deferred — egui UI continues to be improved. |
|
||||
| Server-side channel creation | Client-side channels work locally; gateway still needs `ChannelCreate` packet + storage (Phase 2). |
|
||||
| Identity export/import UI | Vault API is ready (`identity::save(identity, Some(passphrase))`), but no UI modal yet. |
|
||||
|
||||
---
|
||||
|
||||
## Specified in docs, not implemented yet
|
||||
|
||||
| Feature | Target phase |
|
||||
|---------|--------------|
|
||||
| TLS 1.3 on TCP | Phase 2 |
|
||||
| Seed phrase / encrypted keyfile export UI | Phase 2 |
|
||||
| Gateway to voice-node membership signaling | Phase 2 |
|
||||
| Protobuf payloads (replacing JSON) | Phase 2 |
|
||||
| `/health` HTTP endpoint on gateway | ✅ DONE |
|
||||
| Rate limiting | ✅ DONE |
|
||||
| Prometheus metrics | ✅ DONE |
|
||||
| Identity keypair encryption at rest (Argon2id passphrase) | ✅ DONE |
|
||||
| Published Docker images (`ghcr.io/vnox/...`) | Not published yet |
|
||||
| Server-side channel create/edit/delete | Phase 2 |
|
||||
| Federation protocol spec | Phase 3 |
|
||||
|
||||
---
|
||||
|
||||
## Security notes
|
||||
|
||||
- **TCP control plane:** Encrypted with ChaCha20-Poly1305 (✅ Phase 1.1)
|
||||
- **UDP voice data:** Encrypted with ChaCha20-Poly1305 (✅ Phase 1.1)
|
||||
- **Client auth:** Ed25519 challenge-response; identity proven, transport encrypted.
|
||||
- **Server pubkey:** Real (Ed25519), verified during handshake.
|
||||
- **Ephemeral keys:** X25519 ECDH with HKDF-SHA256 key derivation for forward secrecy.
|
||||
- **Voice node:** Transparent relay (does not decrypt voice — encrypted end-to-end between clients).
|
||||
- **Identity at rest:** Optional Argon2id passphrase + ChaCha20-Poly1305 AEAD vault (✅ Phase 2).
|
||||
- **Rate limiting:** Per-session token bucket on chat + DM (✅ Phase 2).
|
||||
|
||||
---
|
||||
|
||||
## Local development
|
||||
|
||||
Use `dev/config.toml`. See [dev/README.md](../dev/README.md).
|
||||
|
||||
Quick start:
|
||||
|
||||
```sh
|
||||
cargo run -p vnox-gateway -- --config dev/config.toml
|
||||
cargo run -p vnox-voice-node -- --config dev/config.toml
|
||||
cargo run -p vnox-client
|
||||
```
|
||||
|
||||
Automated voice relay check (gateway + voice-node must already be running):
|
||||
|
||||
```sh
|
||||
cargo run -p vnox-client --bin vnox-e2e-voice
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Where to read more
|
||||
|
||||
- Architecture: [01-architecture.md](01-architecture.md)
|
||||
- Protocol (target design): [02-protocol/README.md](02-protocol/README.md)
|
||||
- Community Model: [07-community-model.md](07-community-model.md)
|
||||
- Gateway Events: [08-gateway-events.md](08-gateway-events.md)
|
||||
- Database Schema: [10-database.md](10-database.md)
|
||||
- Roadmap: [06-roadmap.md](06-roadmap.md)
|
||||
- Releases: [CHANGELOG.md](../CHANGELOG.md)
|
||||
300
docs/01-architecture.md
Normal file
300
docs/01-architecture.md
Normal file
|
|
@ -0,0 +1,300 @@
|
|||
# Architecture
|
||||
|
||||
## High-level layout
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────┐
|
||||
│ VNOX Client │
|
||||
│ (Rust + egui + wgpu) │
|
||||
└────────────────────┬────────────────────────────────┘
|
||||
│ LNEx v1
|
||||
┌──────────┴──────────┐
|
||||
│ TCP │ UDP
|
||||
▼ ▼
|
||||
┌─────────────────┐ ┌──────────────────┐
|
||||
│ Gateway │ │ Voice Node │
|
||||
│ (Rust/Tokio) │ │ (Rust) │
|
||||
│ │ │ │
|
||||
│ auth │ │ Opus encode │
|
||||
│ channels │ │ UDP relay │
|
||||
│ sessions │ │ jitter buffer │
|
||||
│ permissions │ │ packet seq │
|
||||
│ federation │ │ audio routing │
|
||||
└────────┬────────┘ └──────────────────┘
|
||||
│
|
||||
│ LNEx Federation (Phase 3)
|
||||
▼
|
||||
┌─────────────────┐
|
||||
│ Other nodes │
|
||||
│ (federated) │
|
||||
└─────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Hybrid networking model
|
||||
|
||||
VNOX uses two transports simultaneously. They serve different purposes and
|
||||
are never mixed.
|
||||
|
||||
### TCP — control plane
|
||||
|
||||
Used for everything that requires ordering and reliability:
|
||||
|
||||
- authentication and session setup
|
||||
- channel join / leave events
|
||||
- text chat messages
|
||||
- permission checks
|
||||
- federation routing signals
|
||||
|
||||
### UDP — data plane
|
||||
|
||||
Used for everything where latency matters more than reliability:
|
||||
|
||||
- voice packets (Opus frames)
|
||||
- realtime presence state
|
||||
|
||||
Voice packets that are lost are not retransmitted. Loss is handled by
|
||||
Opus packet loss concealment and the jitter buffer.
|
||||
|
||||
### LNEx layer
|
||||
|
||||
LNEx sits above both transports. It defines:
|
||||
|
||||
- packet format and framing
|
||||
- compression
|
||||
- encryption (ChaCha20, see `02-protocol/security.md`)
|
||||
- routing between nodes
|
||||
- federation identity resolution
|
||||
|
||||
LNEx is not tied to a specific transport. The same packet schema is used
|
||||
over both TCP and UDP, with transport-appropriate flags.
|
||||
|
||||
---
|
||||
|
||||
## Modules
|
||||
|
||||
### gateway
|
||||
|
||||
Language: Rust
|
||||
Runtime: Tokio (async)
|
||||
|
||||
Responsibilities:
|
||||
- client authentication via identity keypair
|
||||
- channel and session management
|
||||
- permission enforcement
|
||||
- federation routing (Phase 3)
|
||||
- text chat delivery
|
||||
|
||||
Entry point for all TCP connections from clients.
|
||||
|
||||
### voice-node
|
||||
|
||||
Language: Rust
|
||||
|
||||
Responsibilities:
|
||||
- receive UDP voice packets from clients
|
||||
- decode Opus frames
|
||||
- apply jitter buffer
|
||||
- relay to other clients in the same channel
|
||||
- handle packet sequencing and reordering
|
||||
|
||||
Can run on the same machine as the gateway or separately.
|
||||
|
||||
### client
|
||||
|
||||
Language: Rust
|
||||
UI: egui
|
||||
Renderer: wgpu
|
||||
Audio: cpal + rodio + opus
|
||||
Networking: tokio
|
||||
|
||||
The desktop application. Connects to a gateway via TCP (LNEx) and to a
|
||||
voice-node via UDP (LNEx). Handles all UI, audio capture, Opus encoding,
|
||||
and local identity management.
|
||||
|
||||
Web client: not planned. Native only.
|
||||
|
||||
### overlay
|
||||
|
||||
Language: Rust (separate process or injected)
|
||||
Status: Phase 2
|
||||
|
||||
In-game HUD showing:
|
||||
- who is speaking
|
||||
- current channel
|
||||
- latency
|
||||
- hotkey state
|
||||
|
||||
### plugins
|
||||
|
||||
Language: TypeScript / JavaScript
|
||||
Runtime: Deno or QuickJS (not finalized)
|
||||
API: WebSocket RPC
|
||||
|
||||
Sandboxed plugin environment. Plugins cannot access internals directly —
|
||||
only through the documented RPC API.
|
||||
|
||||
### federation (Phase 3)
|
||||
|
||||
Handles cross-node communication:
|
||||
- node discovery
|
||||
- identity bridging
|
||||
- voice relay across nodes
|
||||
- channel bridging
|
||||
|
||||
---
|
||||
|
||||
## Tech stack summary
|
||||
|
||||
| Component | Language | Key deps |
|
||||
|-----------|----------|----------|
|
||||
| Gateway | Rust | Tokio, serde, sqlx |
|
||||
| Voice node | Rust | opus, tokio |
|
||||
| Client | Rust | egui, wgpu, cpal, tokio |
|
||||
| Overlay | Rust | TBD |
|
||||
| Plugins | TypeScript | Deno / QuickJS |
|
||||
|
||||
### Why Rust
|
||||
|
||||
- async performance via Tokio: handles thousands of concurrent connections
|
||||
- memory safety without GC: no pauses during voice transmission
|
||||
- strong UDP networking ecosystem
|
||||
- cross-platform native binary: Windows, macOS, Linux from one codebase
|
||||
- no Electron: the client is a real native application
|
||||
|
||||
### Serialization
|
||||
|
||||
JSON for Phase 1 LNEx packets — simple, debuggable, no build-time codegen.
|
||||
Protobuf schemas live in `protocol/` and will replace JSON in Phase 2
|
||||
without changing the wire framing (header stays identical).
|
||||
|
||||
---
|
||||
|
||||
## File structure
|
||||
|
||||
```
|
||||
vnox/
|
||||
├── client/ # Desktop client (Rust + egui)
|
||||
├── gateway/ # TCP gateway (Rust + Tokio)
|
||||
├── voice-node/ # UDP voice relay (Rust)
|
||||
├── federation/ # Federation layer (Phase 3)
|
||||
├── protocol/ # LNEx .proto schemas + spec
|
||||
├── plugins/ # Plugin runtime + example plugins
|
||||
├── sdk/ # Client SDK
|
||||
└── docs/ # This documentation
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Source layout conventions
|
||||
|
||||
### Rules
|
||||
|
||||
- **≤ 200 lines per file.** If a file grows past that, split it.
|
||||
- **≤ 6 files per directory.** If a directory has more, introduce a subdirectory.
|
||||
- **KISS / DRY / SOLID.** One file = one clear responsibility.
|
||||
- Subdirectory always has a `mod.rs` that re-exports the public surface.
|
||||
Internal files are `pub(super)` or `pub(crate)` — never `pub` unless
|
||||
they are part of the module's public API.
|
||||
|
||||
### gateway/src
|
||||
|
||||
```
|
||||
gateway/src/
|
||||
├── main.rs # startup: config, storage, TCP listener, spawn tasks
|
||||
├── domain/ # pure business logic, no I/O
|
||||
│ ├── mod.rs
|
||||
│ ├── auth.rs # Ed25519 verify, challenge generation
|
||||
│ ├── channels.rs # ChannelStore: join/leave/members
|
||||
│ ├── config.rs # Config structs + load()
|
||||
│ ├── session.rs # SessionStore: create/get/remove
|
||||
│ └── storage.rs # SQLite via sqlx: messages, users, bans
|
||||
├── net/ # network I/O
|
||||
│ ├── mod.rs
|
||||
│ ├── handshake.rs # HELLO → AUTH → SESSION exchange
|
||||
│ ├── io.rs # send_packet / read_packet / send_error
|
||||
│ └── state.rs # State (shared Arc clone) + BroadcastMsg
|
||||
├── proto/ # wire protocol
|
||||
│ ├── mod.rs # re-exports everything
|
||||
│ ├── packet.rs # PacketId, ErrorCode, PacketHeader
|
||||
│ ├── payloads.rs # all JSON payload structs
|
||||
│ └── framing.rs # encode_packet, to_payload
|
||||
└── handler/ # per-connection request handling
|
||||
├── mod.rs # run_session loop + dispatch + deliver
|
||||
├── channel.rs # JOIN_CHANNEL / LEAVE_CHANNEL / broadcast_leave
|
||||
└── chat.rs # CHAT_MESSAGE persist + broadcast
|
||||
```
|
||||
|
||||
**Dependency direction:** `main` → `net` → `domain` + `proto`.
|
||||
`handler` → `net` + `domain` + `proto`. No cycles.
|
||||
|
||||
### voice-node/src
|
||||
|
||||
```
|
||||
voice-node/src/
|
||||
├── main.rs # config, UdpSocket::bind, recv loop
|
||||
├── relay.rs # relay_packet, add_member, remove_member
|
||||
└── jitter.rs # JitterBuffer (reorder by voice_seq)
|
||||
```
|
||||
|
||||
Small crate — no subdirectories needed yet.
|
||||
|
||||
### client/src
|
||||
|
||||
```
|
||||
client/src/
|
||||
├── main.rs # tokio runtime, identity load, eframe::run_native
|
||||
├── identity.rs # keypair gen/load, Identity struct
|
||||
├── net/ # LNEx TCP + UDP networking
|
||||
│ ├── mod.rs # NetHandle, spawn(), session_loop
|
||||
│ ├── types.rs # NetCommand, NetEvent, MemberInfo, ChatMsg
|
||||
│ ├── wire.rs # PID_* constants + all wire payload structs
|
||||
│ ├── framing.rs # read() / write() raw packets over TcpStream
|
||||
│ ├── handshake.rs # HELLO → AUTH → SESSION
|
||||
│ ├── dispatch.rs # incoming packet → NetEvent mapping
|
||||
│ └── voice.rs # build_packet(), spawn_recv() UDP loop
|
||||
├── audio/ # cpal + Opus pipeline
|
||||
│ ├── mod.rs # start(), AudioPipeline, EncodedFrame, DecodedFrame
|
||||
│ ├── config.rs # find() best StreamConfig for device
|
||||
│ ├── capture.rs # cpal input → Opus encode → EncodedFrame channel
|
||||
│ └── playback.rs # Opus decode → cpal output
|
||||
└── ui/ # egui layout
|
||||
├── mod.rs # VnoxApp, eframe::App impl, poll_net()
|
||||
├── state.rs # UiState, ConnState, Channel, ChatMessage
|
||||
├── theme.rs # color constants (BG_BASE, ACCENT, …)
|
||||
├── widgets.rs # channel_row(), message_row(), fmt_ts()
|
||||
├── sidebar.rs # node switcher strip + channel list panel
|
||||
├── chat.rs # chat area: header, scroll, input box
|
||||
└── connect.rs # connect screen (shown when disconnected)
|
||||
```
|
||||
|
||||
**Dependency direction:** `main` → `net` + `audio` + `ui`.
|
||||
`ui` → `net::types` (read-only). `audio` is standalone.
|
||||
`net` → `identity`. No cycles.
|
||||
|
||||
### Adding a new feature — checklist
|
||||
|
||||
1. Decide which crate owns it (`gateway`, `voice-node`, `client`).
|
||||
2. Decide which layer it belongs to:
|
||||
- pure logic with no I/O → `domain/`
|
||||
- network I/O → `net/`
|
||||
- UI rendering → `ui/`
|
||||
- audio → `audio/`
|
||||
3. Create a new file in the right subdirectory.
|
||||
4. If the subdirectory now has > 6 files, split into a deeper level.
|
||||
5. Re-export from `mod.rs` only what callers actually need.
|
||||
6. Keep the file under 200 lines. If it grows, extract a helper module.
|
||||
7. Run `cargo check` before committing.
|
||||
|
||||
### What goes where — quick reference
|
||||
|
||||
| Thing | File |
|
||||
|-------|------|
|
||||
| New packet type | `gateway/src/proto/payloads.rs` + `client/src/net/wire.rs` |
|
||||
| New gateway handler | `gateway/src/handler/` (new file if > 200 lines) |
|
||||
| New domain rule (ban, rate limit…) | `gateway/src/domain/` |
|
||||
| New UI panel | `client/src/ui/` (new file) |
|
||||
| New audio processing step | `client/src/audio/` (new file) |
|
||||
| Config field | `gateway/src/domain/config.rs` |
|
||||
| DB schema change | `gateway/src/domain/storage.rs` → `migrate()` |
|
||||
85
docs/02-protocol/README.md
Normal file
85
docs/02-protocol/README.md
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
# LNEx Protocol
|
||||
|
||||
> **Phase 1:** JSON over plain TCP, raw Opus over UDP. Wire encryption is Phase 2.
|
||||
> See [00-status.md](../00-status.md).
|
||||
|
||||
LNEx (Loki Network Exchange) is the networking protocol used by VNOX.
|
||||
|
||||
It is a custom application-layer protocol that runs over TCP and UDP.
|
||||
It defines how VNOX clients and servers communicate — packet format,
|
||||
encryption, routing, identity, and federation.
|
||||
|
||||
---
|
||||
|
||||
## Design goals
|
||||
|
||||
- low latency for voice packets (UDP path)
|
||||
- reliable delivery for control messages (TCP path)
|
||||
- encrypted by default in production (Phase 2 target; plaintext in v0.1.x dev builds)
|
||||
- identity without central authority: keypair-based, no email, no server account registry
|
||||
- federation between independent nodes (Phase 3)
|
||||
|
||||
## Non-goals
|
||||
|
||||
- backward compatibility with Discord, TeamSpeak, or Matrix
|
||||
- browser / WebSocket native support (use a gateway adapter if needed)
|
||||
- guaranteed ordering on the UDP voice path
|
||||
|
||||
---
|
||||
|
||||
## Version
|
||||
|
||||
Current version: `LNEx v1`
|
||||
|
||||
Version is negotiated during handshake. Clients and servers advertise
|
||||
their supported versions. If no common version exists, the connection
|
||||
is rejected with `ERR_VERSION_MISMATCH`.
|
||||
|
||||
LNEx versioning is independent from VNOX client and server versioning.
|
||||
A new VNOX release may or may not bump the LNEx version.
|
||||
|
||||
---
|
||||
|
||||
## Transport mapping
|
||||
|
||||
| Path | Transport | Used for |
|
||||
|------|-----------|---------|
|
||||
| Control | TCP | auth, chat, channels, events, federation |
|
||||
| Voice | UDP | audio frames, realtime state |
|
||||
|
||||
Both paths use LNEx packet framing. The packet format is the same;
|
||||
the transport-specific behavior (ordering, retransmit) is left to TCP/UDP.
|
||||
|
||||
---
|
||||
|
||||
## Connection lifecycle
|
||||
|
||||
```
|
||||
Client Gateway
|
||||
│ │
|
||||
│──── TCP connect ───────────────▶│
|
||||
│◀─── HELLO (server pubkey) ──────│
|
||||
│──── AUTH (client pubkey + sig) ─▶│
|
||||
│◀─── SESSION (session_id, token) ─│
|
||||
│ │
|
||||
│ [control channel established] │
|
||||
│ │
|
||||
│──── JOIN_CHANNEL ──────────────▶│
|
||||
│◀─── CHANNEL_STATE ──────────────│
|
||||
│ │
|
||||
│ [UDP voice path] │
|
||||
│──── UDP VOICE_PACKET ──────────▶│ Voice Node
|
||||
│◀─── UDP VOICE_PACKET ───────────│
|
||||
```
|
||||
|
||||
Full state machine: see individual spec files.
|
||||
|
||||
---
|
||||
|
||||
## Sections
|
||||
|
||||
- [packets.md](packets.md) — packet format, base and voice packets, Protobuf schema
|
||||
- [voice-pipeline.md](voice-pipeline.md) — Opus codec, UDP relay, jitter buffer
|
||||
- [federation/README.md](federation/README.md) — node discovery, cross-node routing
|
||||
- [identity.md](identity.md) — keypair model, auth flow, permissions
|
||||
- [security.md](security.md) — encryption, threat model
|
||||
174
docs/02-protocol/federation/README.md
Normal file
174
docs/02-protocol/federation/README.md
Normal file
|
|
@ -0,0 +1,174 @@
|
|||
# Federation
|
||||
|
||||
> Status: Phase 3 — design draft, not yet implemented.
|
||||
> This document describes the intended design. Details may change.
|
||||
|
||||
---
|
||||
|
||||
## Goal
|
||||
|
||||
Federation allows independent VNOX nodes to communicate with each other.
|
||||
Users on one node can join channels, send messages, and voice-chat with
|
||||
users on a different node — without either node being subordinate to the other.
|
||||
|
||||
There is no central federation server. Every node is equal.
|
||||
|
||||
---
|
||||
|
||||
## Identity in federation
|
||||
|
||||
Federated identity format:
|
||||
|
||||
```
|
||||
user@node
|
||||
```
|
||||
|
||||
Examples:
|
||||
```
|
||||
raven@nightcore.lnex
|
||||
0xmist@dev.vnox.io
|
||||
you@192.168.1.10
|
||||
```
|
||||
|
||||
The `node` part is a resolvable address: domain, IP, or LNEx node ID.
|
||||
The `user` part is the short form of the user's pubkey (or their chosen nickname,
|
||||
disambiguated by pubkey if collision).
|
||||
|
||||
---
|
||||
|
||||
## What can be federated
|
||||
|
||||
| Feature | Status |
|
||||
|---------|--------|
|
||||
| Text chat bridging | Phase 3 |
|
||||
| Voice relay across nodes | Phase 3 |
|
||||
| Channel sharing | Phase 3 |
|
||||
| Identity sync | Phase 3 |
|
||||
| Permissions across nodes | Phase 4 |
|
||||
| Encrypted federation | Phase 4 |
|
||||
|
||||
---
|
||||
|
||||
## Node discovery
|
||||
|
||||
Three strategies are planned. A node may use any combination.
|
||||
|
||||
### 1. Bootstrap list
|
||||
|
||||
A static list of known nodes in `config.toml`:
|
||||
|
||||
```toml
|
||||
[federation]
|
||||
bootstrap = [
|
||||
"relay.nightcore.lnex",
|
||||
"192.168.1.20:7700",
|
||||
]
|
||||
```
|
||||
|
||||
Simple and predictable. Suitable for private networks.
|
||||
|
||||
### 2. DNS SRV records
|
||||
|
||||
A node publishes its LNEx endpoint via DNS:
|
||||
|
||||
```
|
||||
_lnex._udp.nightcore.lnex. SRV 0 0 7700 nightcore.lnex.
|
||||
```
|
||||
|
||||
Allows discovery via domain name without hardcoded IPs.
|
||||
|
||||
### 3. DHT (future)
|
||||
|
||||
For fully decentralized discovery without any bootstrap list or DNS.
|
||||
Not planned until Phase 4.
|
||||
|
||||
---
|
||||
|
||||
## Federation routing
|
||||
|
||||
When a client on node A wants to reach node B:
|
||||
|
||||
```
|
||||
Client (node A)
|
||||
│
|
||||
▼
|
||||
Gateway A ──── resolves node B address (DNS / bootstrap)
|
||||
│
|
||||
▼
|
||||
LNEx Federation handshake (node A → node B)
|
||||
│ mutual auth: exchange pubkeys
|
||||
│ negotiate: shared channels, relay policy
|
||||
▼
|
||||
Established federation link
|
||||
│
|
||||
├── text: messages forwarded via TCP federation channel
|
||||
└── voice: packets relayed via UDP federation relay path
|
||||
```
|
||||
|
||||
### Federation link lifecycle
|
||||
|
||||
1. Node A initiates connection to node B
|
||||
2. Both nodes authenticate using their node keypairs
|
||||
3. Nodes exchange a list of channels available for bridging
|
||||
4. A federation session is established with a session token
|
||||
5. Session is kept alive with periodic PING/PONG
|
||||
6. On disconnect, pending messages are queued for retry
|
||||
|
||||
---
|
||||
|
||||
## Voice relay across nodes
|
||||
|
||||
```
|
||||
Client A (node A)
|
||||
│ UDP
|
||||
▼
|
||||
Voice Node A
|
||||
│ UDP (federation relay path)
|
||||
▼
|
||||
Voice Node B
|
||||
│ UDP
|
||||
▼
|
||||
Client B (node B)
|
||||
```
|
||||
|
||||
Voice packets are forwarded between voice nodes using the same
|
||||
LNEx UDP packet format. The federation relay path adds one hop
|
||||
and ~1–10ms additional latency depending on network distance.
|
||||
|
||||
---
|
||||
|
||||
## Channel bridging
|
||||
|
||||
A channel can be bridged between two nodes. Bridged channels appear
|
||||
in both node's channel lists. Messages and voice from either side
|
||||
flow through the federation link.
|
||||
|
||||
```toml
|
||||
# On node A config
|
||||
[federation.bridges]
|
||||
"#general" = "nightcore.lnex/#general"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Anti-spam and rate limiting
|
||||
|
||||
Federation links are subject to rate limiting to prevent a rogue node
|
||||
from flooding a target node.
|
||||
|
||||
Limits (configurable):
|
||||
- max federation connections per node: 16
|
||||
- max bridged channels: 64
|
||||
- message rate per federation link: 100/s
|
||||
- voice packet rate per relayed user: standard voice rate
|
||||
|
||||
A node can block or denylist other nodes in `config.toml`.
|
||||
|
||||
---
|
||||
|
||||
## Open questions (TODO)
|
||||
|
||||
- How to handle split-brain: node B is temporarily unreachable during a conversation
|
||||
- Message history sync across federated nodes
|
||||
- Permission model for federated users (what can a `user@external-node` do on your node)
|
||||
- Trust levels: `trusted` / `untrusted` / `blocked` per federation link
|
||||
169
docs/02-protocol/identity.md
Normal file
169
docs/02-protocol/identity.md
Normal file
|
|
@ -0,0 +1,169 @@
|
|||
# Identity
|
||||
|
||||
> **Phase 1 note:** the client stores the keypair as plain JSON in the data directory.
|
||||
> Passphrase encryption (Argon2id), seed phrase UI, and keyfile export are Phase 2.
|
||||
|
||||
## Model
|
||||
|
||||
VNOX has no accounts. No email. No phone number. No central registry.
|
||||
|
||||
On first launch, the client generates a keypair locally:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "4f3a8b2c9d1e7a0f3b5c8d2e1a9f4b7c2d8e3f1a",
|
||||
"nickname": "user",
|
||||
"pubkey": "ed25519:4f3a8b2c...",
|
||||
"created_at": 1716000000
|
||||
}
|
||||
```
|
||||
|
||||
The private key never leaves the device (unless explicitly exported by the user).
|
||||
The public key is the identity. Everything — permissions, history, session tokens —
|
||||
is tied to the pubkey.
|
||||
|
||||
---
|
||||
|
||||
## Keypair
|
||||
|
||||
Algorithm: Ed25519
|
||||
|
||||
- fast signature verification
|
||||
- small key size (32 bytes public, 64 bytes private)
|
||||
- widely supported in Rust ecosystem (dalek-cryptography)
|
||||
|
||||
The keypair is stored locally in the client's data directory.
|
||||
|
||||
**Phase 1 (current):** plain JSON file (`identity.json`).
|
||||
|
||||
**Phase 2 (planned):** encrypted at rest with a user-chosen passphrase (Argon2id KDF).
|
||||
|
||||
---
|
||||
|
||||
## Auth flow
|
||||
|
||||
```
|
||||
Client Gateway
|
||||
│ │
|
||||
│── TCP connect ────────────────────▶│
|
||||
│ │
|
||||
│◀── HELLO { server_pubkey, │
|
||||
│ challenge_nonce } ──────│
|
||||
│ │
|
||||
│── AUTH { │
|
||||
│ client_pubkey, │
|
||||
│ nickname, │
|
||||
│ lnex_version, │
|
||||
│ sig: sign(challenge_nonce, │
|
||||
│ client_privkey) │
|
||||
│ } ──────────────────────────────▶│
|
||||
│ │ verify signature
|
||||
│ │ check if banned
|
||||
│◀── SESSION { │
|
||||
│ session_id, │
|
||||
│ token, │
|
||||
│ expires_at │
|
||||
│ } ──────────────────────────────│
|
||||
│ │
|
||||
│ [authenticated] │
|
||||
```
|
||||
|
||||
The gateway verifies the signature against the challenge nonce.
|
||||
If valid, a session token is issued. The token is used for subsequent
|
||||
requests within the TCP session.
|
||||
|
||||
The gateway does not store private keys. It only stores public keys
|
||||
of users who have connected (for banning and permission assignment).
|
||||
|
||||
---
|
||||
|
||||
## Session
|
||||
|
||||
Sessions are in-memory on the gateway. They expire when the TCP connection closes
|
||||
or after a configurable idle timeout.
|
||||
|
||||
There is no persistent login. Each connection requires a fresh auth exchange.
|
||||
The session token is not a password — it proves nothing without the underlying
|
||||
keypair.
|
||||
|
||||
---
|
||||
|
||||
## Nickname
|
||||
|
||||
Nickname is a human-readable label, not unique. Two users may have the same nickname.
|
||||
The pubkey (or its short form) is the unique identifier.
|
||||
|
||||
Nickname is self-asserted and can be changed at any time.
|
||||
Servers may impose length or character limits.
|
||||
|
||||
---
|
||||
|
||||
## Permissions
|
||||
|
||||
Permissions are assigned to pubkeys on each node independently.
|
||||
There is no global permission registry.
|
||||
|
||||
Permission levels (example model — configurable per node):
|
||||
|
||||
```
|
||||
guest — read channels, no voice
|
||||
member — read + write + voice
|
||||
moderator — member + kick + mute others + manage channels
|
||||
admin — full control of the node
|
||||
owner — same as admin, cannot be demoted by admins
|
||||
```
|
||||
|
||||
A user's permission level on node A has no bearing on their level on node B.
|
||||
Federated permission model is a Phase 4 design question.
|
||||
|
||||
---
|
||||
|
||||
## Keypair backup
|
||||
|
||||
> **Phase 2.** UI and export flows below are not implemented in v0.1.x.
|
||||
|
||||
If the keypair is lost, the identity is lost. There is no recovery
|
||||
without a backup. This is intentional. There is no central authority
|
||||
to reset your account.
|
||||
|
||||
Two backup methods are planned:
|
||||
|
||||
### Seed phrase
|
||||
|
||||
A 24-word BIP39-compatible mnemonic derived from the private key entropy.
|
||||
|
||||
```
|
||||
word1 word2 word3 ... word24
|
||||
```
|
||||
|
||||
The seed phrase can regenerate the keypair deterministically.
|
||||
Store it offline. Never share it.
|
||||
|
||||
To enable: Settings → Identity → Seed phrase backup → Show phrase.
|
||||
|
||||
### Encrypted keyfile
|
||||
|
||||
Export the keypair as a `.vnox` file, encrypted with a passphrase
|
||||
(AES-256-GCM, passphrase stretched with Argon2id).
|
||||
|
||||
```
|
||||
Settings → Identity → Export keypair → Save as .vnox
|
||||
```
|
||||
|
||||
To restore: launch client → Import keypair → select .vnox file → enter passphrase.
|
||||
|
||||
---
|
||||
|
||||
## Rotating the keypair
|
||||
|
||||
If a keypair is compromised, the user can generate a new one.
|
||||
The old keypair becomes inactive immediately.
|
||||
|
||||
Effects:
|
||||
- new identity on all nodes (the old pubkey is a different person)
|
||||
- permissions tied to old pubkey remain on the server (admins can clean up)
|
||||
- history attributed to old pubkey is not migrated
|
||||
|
||||
Rotation is permanent and cannot be undone.
|
||||
|
||||
`Settings → Identity → Rotate keypair → Confirm`
|
||||
266
docs/02-protocol/packets.md
Normal file
266
docs/02-protocol/packets.md
Normal file
|
|
@ -0,0 +1,266 @@
|
|||
# Packets
|
||||
|
||||
> **Phase 1 note:** TCP control payloads are serialized as **JSON**, not Protobuf.
|
||||
> Protobuf is the Phase 2 target. See [01-architecture.md](../01-architecture.md).
|
||||
|
||||
## Base packet
|
||||
|
||||
All LNEx packets share a common header, regardless of transport.
|
||||
|
||||
```
|
||||
┌──────────────┬──────────────┬──────────────┬────────────────┬─────────┐
|
||||
│ packet_id │ flags │ sequence │ payload_length │ payload │
|
||||
│ (2 bytes) │ (2 bytes) │ (4 bytes) │ (4 bytes) │ (var) │
|
||||
└──────────────┴──────────────┴──────────────┴────────────────┴─────────┘
|
||||
```
|
||||
|
||||
### Fields
|
||||
|
||||
`packet_id` — identifies the packet type. See packet type registry below.
|
||||
|
||||
`flags` — bitmask:
|
||||
|
||||
```
|
||||
bit 0 — COMPRESSED payload is zstd compressed
|
||||
bit 1 — ENCRYPTED payload is encrypted (ChaCha20-Poly1305)
|
||||
bit 2 — FRAGMENTED this is a fragment of a larger payload
|
||||
bit 3 — LAST_FRAG this is the last fragment
|
||||
bit 4 — ACK_REQ sender requests acknowledgement (TCP path only)
|
||||
bit 5-15 — reserved
|
||||
```
|
||||
|
||||
`sequence` — monotonically increasing per-session counter.
|
||||
On the UDP voice path, gaps in sequence indicate packet loss.
|
||||
|
||||
`payload_length` — byte length of the payload that follows.
|
||||
|
||||
`payload` - packet-type-specific data. **Phase 1:** JSON. **Phase 2:** Protobuf (planned).
|
||||
|
||||
---
|
||||
|
||||
## Voice packet
|
||||
|
||||
Voice packets are sent over UDP. They extend the base header with
|
||||
audio-specific fields before the Opus payload.
|
||||
|
||||
```
|
||||
┌──────────────┬──────────────┬──────────────┬────────────────┬────────────────┬────────────┐
|
||||
│ packet_id │ flags │ voice_seq │ timestamp │ channel_id │ opus_data │
|
||||
│ = 0x0010 │ (2 bytes) │ (4 bytes) │ (4 bytes) │ (8 bytes) │ (var) │
|
||||
└──────────────┴──────────────┴──────────────┴────────────────┴────────────────┴────────────┘
|
||||
```
|
||||
|
||||
### Fields
|
||||
|
||||
`voice_seq` — separate sequence counter for the voice stream.
|
||||
Resets per channel join. Used by jitter buffer for reordering.
|
||||
|
||||
`timestamp` — RTP-style timestamp in samples (48000 Hz clock).
|
||||
Used for jitter buffer and playout scheduling.
|
||||
|
||||
`channel_id` — identifies which voice channel this frame belongs to.
|
||||
Allows a single UDP socket to carry multiple channels.
|
||||
|
||||
`opus_data` — raw Opus-encoded frame. Length derived from `payload_length`
|
||||
minus the fixed voice header (16 bytes).
|
||||
|
||||
---
|
||||
|
||||
## Packet type registry
|
||||
|
||||
```
|
||||
0x0001 HELLO server → client on connect
|
||||
0x0002 AUTH client → server, identity + signature
|
||||
0x0003 SESSION server → client, session established
|
||||
0x0004 PING either direction
|
||||
0x0005 PONG reply to PING
|
||||
|
||||
0x0010 VOICE_PACKET UDP, client ↔ voice-node
|
||||
0x0011 VOICE_STATE speaking / silent / muted
|
||||
|
||||
0x0020 CHAT_MESSAGE text message in channel
|
||||
0x0021 CHAT_HISTORY batch of historical messages
|
||||
|
||||
0x0030 JOIN_CHANNEL client → gateway
|
||||
0x0031 LEAVE_CHANNEL client → gateway
|
||||
0x0032 CHANNEL_STATE gateway → client, full channel snapshot
|
||||
|
||||
0x0040 USER_JOIN broadcast to channel members
|
||||
0x0041 USER_LEAVE broadcast to channel members
|
||||
|
||||
0x0050 PERMISSION_CHECK gateway → client
|
||||
0x0051 PERMISSION_DENY gateway → client
|
||||
|
||||
0x0060 DM_START client → gateway, initiate 1:1 DM conversation
|
||||
0x0061 DM_MESSAGE client ↔ gateway, individual DM message
|
||||
0x0062 DM_HISTORY client → gateway, fetch last 50 messages
|
||||
|
||||
0x0100 GUILD_CREATE client → gateway
|
||||
0x0101 GUILD_DELETE client → gateway
|
||||
0x0102 GUILD_LIST client → gateway
|
||||
0x0103 GUILD_MEMBER_JOIN client → gateway
|
||||
0x0104 GUILD_MEMBER_LEAVE client → gateway
|
||||
0x0105 GUILD_MEMBER_KICK client → gateway
|
||||
0x0106 ROLE_CREATE client → gateway
|
||||
0x0107 ROLE_DELETE client → gateway
|
||||
0x0108 INVITE_CREATE client → gateway
|
||||
0x0109 INVITE_ACCEPT client → gateway
|
||||
0x010A INVITE_DELETE client → gateway
|
||||
|
||||
0x0140 PRESENCE_UPDATE client → gateway
|
||||
0x0141 PRESENCE_SYNC gateway → client
|
||||
0x0142 PRESENCE_EVENT gateway → client
|
||||
|
||||
0x0150 FRIEND_REQUEST client → gateway
|
||||
0x0151 FRIEND_ACCEPT client → gateway
|
||||
0x0152 FRIEND_DECLINE client → gateway
|
||||
0x0153 FRIEND_REMOVE client → gateway
|
||||
0x0154 FRIEND_LIST client → gateway
|
||||
0x0155 BLOCK_USER client → gateway
|
||||
0x0156 UNBLOCK_USER client → gateway
|
||||
0x0157 BLOCK_LIST client → gateway
|
||||
|
||||
0x00F0 ERROR any direction, see error codes
|
||||
0x00FF DISCONNECT graceful close
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Direct Messages (Phase 1.1)
|
||||
|
||||
DM support enables 1:1 private messaging with persistent history. DMs are identified by a canonical ID format:
|
||||
`dm_{lexicographically_smaller_uid}_{larger_uid}`. This ensures both participants reference the same conversation.
|
||||
|
||||
### DmStart (0x0060)
|
||||
|
||||
Client initiates or opens an existing DM conversation with another user.
|
||||
|
||||
```json
|
||||
{
|
||||
"target_user_id": "user_pubkey_b64"
|
||||
}
|
||||
```
|
||||
|
||||
Response (server sends back as 0x0060):
|
||||
|
||||
```json
|
||||
{
|
||||
"dm_id": "dm_userid1_userid2",
|
||||
"other_user_id": "user_pubkey_b64",
|
||||
"other_nickname": "Alice",
|
||||
"messages": [
|
||||
{
|
||||
"dm_id": "dm_userid1_userid2",
|
||||
"sender_id": "user_pubkey_b64",
|
||||
"content": "Hello!",
|
||||
"timestamp": 1234567890000
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### DmMessage (0x0061)
|
||||
|
||||
Send a new DM or receive one from another user.
|
||||
|
||||
```json
|
||||
{
|
||||
"dm_id": "dm_userid1_userid2",
|
||||
"sender_id": "user_pubkey_b64",
|
||||
"content": "Hello Alice!",
|
||||
"timestamp": 1234567890000
|
||||
}
|
||||
```
|
||||
|
||||
Server-authoritative: sender_id and timestamp are set by the server based on the authenticated session.
|
||||
|
||||
### DmHistory (0x0062)
|
||||
|
||||
Fetch historical messages from a DM.
|
||||
|
||||
Request:
|
||||
```json
|
||||
{
|
||||
"dm_id": "dm_userid1_userid2"
|
||||
}
|
||||
```
|
||||
|
||||
Response:
|
||||
```json
|
||||
{
|
||||
"dm_id": "dm_userid1_userid2",
|
||||
"messages": [
|
||||
{ "dm_id": "...", "sender_id": "...", "content": "...", "timestamp": 1234567890000 }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Protobuf schema
|
||||
|
||||
Payload of each packet is a serialized Protobuf message.
|
||||
Schemas live in `protocol/` at the repository root.
|
||||
|
||||
Example — `ChatMessage`:
|
||||
|
||||
```protobuf
|
||||
syntax = "proto3";
|
||||
|
||||
message ChatMessage {
|
||||
string message_id = 1; // UUID v4
|
||||
string channel_id = 2;
|
||||
string sender_id = 3; // sender pubkey (short form)
|
||||
string content = 4;
|
||||
int64 timestamp = 5; // Unix ms
|
||||
}
|
||||
```
|
||||
|
||||
Example — `VoiceState`:
|
||||
|
||||
```protobuf
|
||||
message VoiceState {
|
||||
string user_id = 1;
|
||||
string channel_id = 2;
|
||||
|
||||
enum State {
|
||||
SILENT = 0;
|
||||
SPEAKING = 1;
|
||||
MUTED = 2;
|
||||
DEAFENED = 3;
|
||||
}
|
||||
|
||||
State state = 3;
|
||||
}
|
||||
```
|
||||
|
||||
Full schema reference: `protocol/*.proto`
|
||||
|
||||
---
|
||||
|
||||
## Fragmentation
|
||||
|
||||
Payloads larger than 1400 bytes on the UDP path are fragmented.
|
||||
Each fragment carries the same `sequence`, with `FRAGMENTED` and optionally
|
||||
`LAST_FRAG` flags set. The receiver reassembles before passing to the
|
||||
application layer.
|
||||
|
||||
On TCP, fragmentation is not used — TCP handles it at the transport layer.
|
||||
|
||||
---
|
||||
|
||||
## Error codes
|
||||
|
||||
```
|
||||
0x01 ERR_VERSION_MISMATCH unsupported LNEx version
|
||||
0x02 ERR_AUTH_FAILED invalid signature or unknown identity
|
||||
0x03 ERR_SESSION_EXPIRED session token no longer valid
|
||||
0x04 ERR_PERMISSION_DENIED insufficient permissions for action
|
||||
0x05 ERR_CHANNEL_NOT_FOUND channel does not exist on this node
|
||||
0x06 ERR_RATE_LIMITED too many packets in window
|
||||
0x07 ERR_INVALID_PACKET malformed header or payload
|
||||
0x08 ERR_NODE_UNAVAILABLE federation target node unreachable
|
||||
0x09 ERR_GUILD_NOT_FOUND guild does not exist
|
||||
0x0A ERR_BLOCKED you are blocked by this user
|
||||
0xFF ERR_INTERNAL server-side error
|
||||
```
|
||||
150
docs/02-protocol/security.md
Normal file
150
docs/02-protocol/security.md
Normal file
|
|
@ -0,0 +1,150 @@
|
|||
# Security
|
||||
|
||||
> **Phase 1.1 status.** TCP traffic is encrypted with ChaCha20-Poly1305 AEAD.
|
||||
> UDP voice path is still plaintext (Phase 2). See
|
||||
> [features/encryption.md](../05-features/encryption.md) for details.
|
||||
|
||||
## Threat model
|
||||
|
||||
VNOX is designed for self-hosted deployments where the node operator is trusted.
|
||||
The threat model covers:
|
||||
|
||||
| Threat | Mitigated by |
|
||||
|--------|--------------|
|
||||
| MITM on client-server connection | LNEx packet encryption (ChaCha20-Poly1305) active on TCP |
|
||||
| MITM on UDP voice path | LNEx packet encryption (Phase 2; not active in v0.1.x) |
|
||||
| Rogue node in federation | Mutual keypair authentication on federation handshake (Phase 3) |
|
||||
| Identity spoofing | Ed25519 signature on every auth (challenge-response) |
|
||||
| Metadata leakage (who talks to whom) | Phase 2 |
|
||||
| Passive voice interception | Packet encryption (Phase 2; not active in v0.1.x) |
|
||||
| Replay attacks | Sequence number + nonce per packet (Phase 2 wire encryption) |
|
||||
| Brute-force on session | Sessions are short-lived, token is not a password |
|
||||
|
||||
### Out of scope
|
||||
|
||||
- Physical access to the server
|
||||
- Compromised node operator (they own the node, this is by design)
|
||||
- E2EE between clients (planned Phase 2, not in v1)
|
||||
- Anonymity / traffic analysis resistance
|
||||
|
||||
---
|
||||
|
||||
## Encryption
|
||||
|
||||
> **Phase 1.1 target.** See [features/encryption.md](../05-features/encryption.md) for the implementation plan.
|
||||
> Phase 1 uses JSON over plain TCP and raw Opus over UDP.
|
||||
|
||||
### LNEx packet encryption
|
||||
|
||||
All LNEx packets (both TCP and UDP paths) are encrypted at the LNEx layer.
|
||||
|
||||
Algorithm: **ChaCha20-Poly1305** (AEAD)
|
||||
|
||||
- ChaCha20 for stream cipher
|
||||
- Poly1305 for authentication tag (16 bytes)
|
||||
- 96-bit nonce, derived from: `session_id || sequence`
|
||||
- Key derived from ECDH exchange during handshake (X25519)
|
||||
|
||||
ChaCha20-Poly1305 is chosen over AES-GCM because:
|
||||
- constant-time on all platforms (no hardware AES requirement)
|
||||
- faster in software on hardware without AES-NI (common on ARM)
|
||||
- simpler nonce management
|
||||
|
||||
### Key exchange
|
||||
|
||||
During the LNEx handshake:
|
||||
|
||||
1. Server sends its ephemeral X25519 public key in HELLO
|
||||
2. Client generates its own ephemeral X25519 keypair
|
||||
3. Both compute the shared secret via X25519 ECDH
|
||||
4. Shared secret is passed through HKDF-SHA256 to derive:
|
||||
- client → server encryption key
|
||||
- server → client encryption key
|
||||
|
||||
Ephemeral keys are discarded after the session. This provides
|
||||
**forward secrecy** — compromising the long-term identity keypair
|
||||
does not expose past sessions.
|
||||
|
||||
### TCP transport
|
||||
|
||||
TCP connections additionally use TLS 1.3.
|
||||
LNEx packet encryption runs inside TLS — defense in depth.
|
||||
|
||||
TLS certificate: self-signed by default, pinned on first connect (TOFU).
|
||||
Node operators may configure a proper CA-signed certificate.
|
||||
|
||||
### UDP transport
|
||||
|
||||
UDP has no TLS. LNEx packet encryption (ChaCha20-Poly1305) is the only
|
||||
protection layer on the voice path. This is standard practice for
|
||||
real-time voice protocols (SRTP, DTLS-SRTP follow the same model).
|
||||
|
||||
---
|
||||
|
||||
## E2EE (Phase 2)
|
||||
|
||||
In v1, encryption is between client and server (node). The node operator
|
||||
can theoretically decrypt voice and text in transit.
|
||||
|
||||
Phase 2 will introduce optional end-to-end encryption for:
|
||||
- direct messages
|
||||
- private channels (opt-in)
|
||||
|
||||
E2EE for voice is significantly harder (requires key distribution to all
|
||||
channel members in real time) and is a Phase 4 design question.
|
||||
|
||||
---
|
||||
|
||||
## Identity verification
|
||||
|
||||
When user A sees a message from `raven@nightcore.lnex`, how do they know
|
||||
it's the same raven they spoke to yesterday?
|
||||
|
||||
In v1: the gateway enforces that a connected user's pubkey matches their
|
||||
asserted identity. The client can verify the server's claim by checking
|
||||
the pubkey shown in the UI against a known value.
|
||||
|
||||
Future: out-of-band key verification (QR code, safety number, similar to Signal).
|
||||
|
||||
---
|
||||
|
||||
## Rate limiting and anti-flood
|
||||
|
||||
Applied at the gateway level:
|
||||
|
||||
| Limit | Default | Configurable |
|
||||
|-------|---------|-------------|
|
||||
| Auth attempts per IP | 5 / minute | yes |
|
||||
| Messages per user per second | 10 | yes |
|
||||
| Voice packet rate per user | ~50/s (20ms frames) | no (codec-determined) |
|
||||
| Federation connection attempts | 3 / minute per remote | yes |
|
||||
| Max concurrent connections per IP | 4 | yes |
|
||||
|
||||
Exceeding a rate limit returns `ERR_RATE_LIMITED` and may trigger a
|
||||
temporary ban depending on node configuration.
|
||||
|
||||
---
|
||||
|
||||
## Node operator notes
|
||||
|
||||
### What the operator can see
|
||||
|
||||
- IP addresses of connected clients
|
||||
- usernames (nicknames) and pubkeys
|
||||
- channel activity (who joined when)
|
||||
- message content (in v1, no E2EE)
|
||||
|
||||
### What the operator cannot do (by design)
|
||||
|
||||
- impersonate a user's identity (requires their private key)
|
||||
- forge signatures on behalf of a user
|
||||
|
||||
### Recommended hardening
|
||||
|
||||
- run gateway behind a reverse proxy (nginx / caddy) for TLS termination
|
||||
- restrict UDP port to known IP ranges if possible
|
||||
- enable fail2ban or equivalent on auth failure logs
|
||||
- back up the node keypair (used for federation identity)
|
||||
- rotate node keypair on suspected compromise
|
||||
|
||||
See `03-server/operations.md` for hardening checklist.
|
||||
217
docs/02-protocol/state-machine.md
Normal file
217
docs/02-protocol/state-machine.md
Normal file
|
|
@ -0,0 +1,217 @@
|
|||
# State Machine
|
||||
|
||||
> **Phase 1 note:** the gateway and voice-node do not share membership state.
|
||||
> The voice node registers a client on the first UDP packet and drops idle addresses
|
||||
> after 30 seconds. Diagrams that show `gateway notify voice node` describe the Phase 2 target.
|
||||
|
||||
Client and server maintain synchronized state machines over the LNEx connection.
|
||||
This document describes the states, transitions, and what triggers them.
|
||||
|
||||
---
|
||||
|
||||
## Client states
|
||||
|
||||
```
|
||||
┌─────────────────┐
|
||||
│ DISCONNECTED │ ◄─── initial state / after disconnect
|
||||
└────────┬────────┘
|
||||
│ connect(address)
|
||||
▼
|
||||
┌─────────────────┐
|
||||
│ CONNECTING │ TCP handshake in progress
|
||||
└────────┬────────┘
|
||||
│ TCP established
|
||||
▼
|
||||
┌─────────────────┐
|
||||
│ HANDSHAKING │ HELLO received, sending AUTH
|
||||
└────────┬────────┘
|
||||
│ SESSION received
|
||||
▼
|
||||
┌─────────────────┐
|
||||
│ CONNECTED │ ◄─── idle, no channel joined
|
||||
└────────┬────────┘
|
||||
│ join_channel()
|
||||
▼
|
||||
┌─────────────────┐
|
||||
┌────►│ IN_CHANNEL │ text + voice available
|
||||
│ └────────┬────────┘
|
||||
│ │ leave_channel() / switch channel
|
||||
└──────────────┘
|
||||
│ disconnect() / TCP drop
|
||||
▼
|
||||
┌─────────────────┐
|
||||
│ RECONNECTING │ exponential backoff (Phase 2)
|
||||
└────────┬────────┘
|
||||
│ max retries exceeded
|
||||
▼
|
||||
┌─────────────────┐
|
||||
│ DISCONNECTED │
|
||||
└─────────────────┘
|
||||
```
|
||||
|
||||
Any state can transition to `DISCONNECTED` on:
|
||||
- TCP connection drop
|
||||
- `DISCONNECT` packet received
|
||||
- auth failure (`ERR_AUTH_FAILED`)
|
||||
- version mismatch (`ERR_VERSION_MISMATCH`)
|
||||
|
||||
---
|
||||
|
||||
## Server session states
|
||||
|
||||
Per-client session on the gateway:
|
||||
|
||||
```
|
||||
┌─────────────────┐
|
||||
│ ACCEPTING │ TCP connection received, sending HELLO
|
||||
└────────┬────────┘
|
||||
│ AUTH received
|
||||
▼
|
||||
┌─────────────────┐
|
||||
│ AUTHENTICATING │ verifying Ed25519 signature
|
||||
└────────┬────────┘
|
||||
│ signature valid + not banned
|
||||
▼
|
||||
┌─────────────────┐
|
||||
│ ESTABLISHED │ SESSION sent, client is authenticated
|
||||
└────────┬────────┘
|
||||
│ JOIN_CHANNEL received
|
||||
▼
|
||||
┌─────────────────┐
|
||||
┌────►│ IN_CHANNEL │ forwarding messages + voice state
|
||||
│ └────────┬────────┘
|
||||
│ │ LEAVE_CHANNEL / JOIN different channel
|
||||
└──────────────┘
|
||||
│ TCP drop / DISCONNECT / idle timeout
|
||||
▼
|
||||
┌─────────────────┐
|
||||
│ TERMINATED │ session cleaned up, resources freed
|
||||
└─────────────────┘
|
||||
```
|
||||
|
||||
### Auth failure paths
|
||||
|
||||
```
|
||||
AUTHENTICATING
|
||||
│ invalid signature → send ERR_AUTH_FAILED → TERMINATED
|
||||
│ banned pubkey → send ERR_AUTH_FAILED → TERMINATED
|
||||
│ unsupported version → send ERR_VERSION_MISMATCH → TERMINATED
|
||||
│ rate limit on auth → send ERR_RATE_LIMITED → TERMINATED
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Voice session states (per user, on voice-node)
|
||||
|
||||
> **Phase 1:** transition from `INACTIVE` to `JOINING` happens when the first UDP
|
||||
> packet arrives from a client address, not when the gateway sends a signal.
|
||||
|
||||
```
|
||||
┌─────────────────┐
|
||||
│ INACTIVE │ user not in a voice channel
|
||||
└────────┬────────┘
|
||||
│ first UDP packet from client (Phase 1)
|
||||
│ gateway signals join (Phase 2, not implemented)
|
||||
▼
|
||||
┌─────────────────┐
|
||||
│ JOINING │ UDP path being established
|
||||
└────────┬────────┘
|
||||
│ first UDP packet received from client
|
||||
▼
|
||||
┌─────────────────┐
|
||||
│ SILENT │ in channel, not transmitting
|
||||
└────────┬────────┘
|
||||
│ voice packets arriving
|
||||
▼
|
||||
┌─────────────────┐
|
||||
┌────►│ SPEAKING │ relaying to other channel members
|
||||
│ └────────┬────────┘
|
||||
│ │ DTX / push-to-talk released / silence
|
||||
└──────────────┘
|
||||
┌─────────────────┐
|
||||
│ MUTED │ server-side mute (moderator action)
|
||||
└────────┬────────┘
|
||||
│ unmuted by moderator
|
||||
▼
|
||||
┌─────────────────┐
|
||||
│ SILENT │
|
||||
└─────────────────┘
|
||||
```
|
||||
|
||||
Voice state is broadcast to all channel members as `VOICE_STATE` packets
|
||||
on every transition: `SILENT → SPEAKING`, `SPEAKING → SILENT`, `MUTED`, `DEAFENED`.
|
||||
|
||||
---
|
||||
|
||||
## Channel join sequence (detailed)
|
||||
|
||||
Full flow from `join_channel()` call to receiving audio:
|
||||
|
||||
```
|
||||
Client Gateway Voice Node
|
||||
│ │ │
|
||||
│── JOIN_CHANNEL ─────────►│ │
|
||||
│ │ check permissions │
|
||||
│ │ check channel exists │
|
||||
│◄─ CHANNEL_STATE ─────────│ │
|
||||
│ { members, │ │
|
||||
│ voice_endpoint, │ (Phase 2: gateway │
|
||||
│ channel_id } │ notifies voice node) │
|
||||
│ │ │
|
||||
│ [start UDP] │ │
|
||||
│── UDP VOICE_PACKET ──────────────────────────────── ►│
|
||||
│ │ │ relay to others
|
||||
│◄─ UDP VOICE_PACKET ───────────────────────────────── │
|
||||
│ │ │
|
||||
│◄─ USER_JOIN broadcast ───│ │
|
||||
│ (sent to all members) │ │
|
||||
```
|
||||
|
||||
If `ERR_PERMISSION_DENIED` is returned on `JOIN_CHANNEL`, the client
|
||||
stays in `CONNECTED` state and does not attempt UDP.
|
||||
|
||||
---
|
||||
|
||||
## Reconnect logic (Phase 2)
|
||||
|
||||
On unexpected TCP drop from `IN_CHANNEL` or `CONNECTED`:
|
||||
|
||||
```
|
||||
disconnect detected
|
||||
│
|
||||
▼
|
||||
RECONNECTING state
|
||||
│
|
||||
├── attempt 1: wait 1s
|
||||
├── attempt 2: wait 2s
|
||||
├── attempt 3: wait 4s
|
||||
├── attempt 4: wait 8s
|
||||
├── attempt 5: wait 16s
|
||||
└── attempt 6+: wait 30s (cap)
|
||||
```
|
||||
|
||||
On successful reconnect:
|
||||
- full auth exchange (new session token)
|
||||
- if user was `IN_CHANNEL` before disconnect: auto-rejoin same channel
|
||||
|
||||
On reconnect failure after N attempts (configurable, default 10):
|
||||
- transition to `DISCONNECTED`
|
||||
- notify user in UI
|
||||
|
||||
---
|
||||
|
||||
## Packet validity by state
|
||||
|
||||
Packets received in an unexpected state are dropped with `ERR_INVALID_PACKET`.
|
||||
|
||||
| Packet | Valid in states |
|
||||
|--------|----------------|
|
||||
| `HELLO` | server sends in `ACCEPTING` |
|
||||
| `AUTH` | client sends in `HANDSHAKING` |
|
||||
| `SESSION` | server sends in `AUTHENTICATING` |
|
||||
| `JOIN_CHANNEL` | `CONNECTED`, `IN_CHANNEL` |
|
||||
| `LEAVE_CHANNEL` | `IN_CHANNEL` |
|
||||
| `CHAT_MESSAGE` | `IN_CHANNEL` |
|
||||
| `VOICE_PACKET` | UDP, user in voice channel |
|
||||
| `PING` / `PONG` | any authenticated state |
|
||||
| `DISCONNECT` | any state |
|
||||
197
docs/02-protocol/voice-pipeline.md
Normal file
197
docs/02-protocol/voice-pipeline.md
Normal file
|
|
@ -0,0 +1,197 @@
|
|||
# Voice Pipeline
|
||||
|
||||
> **Phase 1 note:** capture, Opus encode/decode, and UDP relay exist in the repo.
|
||||
> RNNoise, echo cancellation, VAD, and jitter buffer integration are Phase 2 or not wired yet.
|
||||
> See [00-status.md](../00-status.md).
|
||||
|
||||
## Overview
|
||||
|
||||
```
|
||||
Microphone
|
||||
│
|
||||
▼
|
||||
PCM Capture (cpal)
|
||||
│ 48000 Hz, mono, f32
|
||||
▼
|
||||
Pre-processing
|
||||
│ noise suppression (RNNoise) - Phase 2
|
||||
│ echo cancellation - Phase 2
|
||||
│ VAD (voice activity detection) - Phase 2
|
||||
▼
|
||||
Opus Encode
|
||||
│ frame: 10 / 20 / 40ms
|
||||
│ bitrate: 8–128 kbps (default 64k)
|
||||
│ mode: VOIP (optimized for speech)
|
||||
▼
|
||||
LNEx Voice Packet
|
||||
│ header + opus_data
|
||||
│ encrypted + compressed (Phase 2; plaintext in v0.1.x)
|
||||
▼
|
||||
UDP → Voice Node
|
||||
│
|
||||
▼
|
||||
Jitter Buffer
|
||||
│ reorder by voice_seq (code exists; not used in relay yet)
|
||||
│ schedule by timestamp
|
||||
│ adaptive size: 20–80ms
|
||||
▼
|
||||
Opus Decode
|
||||
│ packet loss concealment if gap in sequence
|
||||
▼
|
||||
PCM Output
|
||||
│
|
||||
▼
|
||||
Playback (cpal / rodio)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Codec
|
||||
|
||||
### Opus
|
||||
|
||||
VNOX uses Opus exclusively for voice encoding.
|
||||
|
||||
Parameters:
|
||||
|
||||
| Setting | Value | Notes |
|
||||
|---------|-------|-------|
|
||||
| Sample rate | 48000 Hz | Opus native rate |
|
||||
| Channels | 1 (mono) | stereo optional in Phase 2 |
|
||||
| Application | VOIP | optimized for speech, lower complexity |
|
||||
| Bitrate | 8–128 kbps | default 64k, user-configurable |
|
||||
| Frame size | 20ms default | configurable: 10 / 20 / 40ms |
|
||||
| FEC | enabled | forward error correction for packet loss |
|
||||
| DTX | enabled | discontinuous transmission, silence suppression |
|
||||
|
||||
Lower frame size = lower latency, higher CPU and packet rate.
|
||||
Recommended: 20ms for balance, 10ms for ultra-low latency setups.
|
||||
|
||||
### Why Opus
|
||||
|
||||
- royalty-free
|
||||
- outperforms MP3/AAC at low bitrates for speech
|
||||
- built-in packet loss concealment
|
||||
- adaptive bitrate
|
||||
- widely supported (libopus, bindings for every language)
|
||||
|
||||
---
|
||||
|
||||
## Pre-processing
|
||||
|
||||
> **Phase 2.** Not implemented in the current client (`client/src/audio/`).
|
||||
|
||||
Applied before Opus encoding on the capture path (target design).
|
||||
|
||||
### Noise suppression
|
||||
|
||||
Implementation: RNNoise (ML-based, ~2% CPU)
|
||||
Applied to raw PCM before encoding.
|
||||
Configurable: on / off.
|
||||
|
||||
### Echo cancellation
|
||||
|
||||
Removes microphone pickup of speaker output.
|
||||
Implementation: platform AEC or software fallback.
|
||||
Configurable: on / off.
|
||||
|
||||
### Voice activity detection (VAD)
|
||||
|
||||
Detects when the user is speaking to avoid sending silence packets.
|
||||
Used in "voice activity" mode (alternative to push-to-talk).
|
||||
|
||||
Threshold: configurable 0–100%, default 40%.
|
||||
|
||||
In push-to-talk mode, VAD is bypassed — packets are sent only while
|
||||
the hotkey is held.
|
||||
|
||||
DTX in Opus also provides a secondary layer of silence suppression
|
||||
at the encoder level.
|
||||
|
||||
---
|
||||
|
||||
## UDP relay
|
||||
|
||||
### Path
|
||||
|
||||
```
|
||||
Client A ──UDP──▶ Voice Node ──UDP──▶ Client B
|
||||
│
|
||||
──UDP──▶ Client C
|
||||
│
|
||||
──UDP──▶ Client D
|
||||
```
|
||||
|
||||
The voice node receives packets from each speaker and relays them
|
||||
to all other clients in the same channel.
|
||||
|
||||
No mixing is done on the server. Clients receive separate streams
|
||||
per speaker and mix locally. This allows per-speaker volume control
|
||||
on the client side.
|
||||
|
||||
### Direct P2P (future)
|
||||
|
||||
In Phase 4, direct P2P paths may be established between clients
|
||||
to skip the relay hop. The relay remains as fallback.
|
||||
|
||||
---
|
||||
|
||||
## Jitter buffer
|
||||
|
||||
The jitter buffer absorbs network jitter and reorders out-of-order packets
|
||||
before passing them to the decoder.
|
||||
|
||||
### Operation
|
||||
|
||||
1. Packets arrive with `voice_seq` and `timestamp`
|
||||
2. Buffer holds packets for a configurable window
|
||||
3. Packets are released in sequence order at scheduled playout time
|
||||
4. If a packet is missing when due: Opus PLC generates a concealment frame
|
||||
5. If a late packet arrives after playout: discarded
|
||||
|
||||
### Configuration
|
||||
|
||||
| Setting | Default | Range | Notes |
|
||||
|---------|---------|-------|-------|
|
||||
| Buffer size | 40ms | 20–80ms | lower = less latency, more glitches |
|
||||
| Adaptive mode | on | on/off | auto-adjusts based on observed jitter |
|
||||
| Max late tolerance | 80ms | — | packets older than this are discarded |
|
||||
|
||||
Adaptive mode measures jitter over a rolling window and expands/shrinks
|
||||
the buffer target accordingly. On a stable LAN, buffer converges to ~20ms.
|
||||
On a lossy WAN, it may expand to 60–80ms.
|
||||
|
||||
---
|
||||
|
||||
## Packet loss concealment
|
||||
|
||||
When a voice_seq gap is detected, Opus generates a concealment frame
|
||||
using the previous frame's data. This produces a short fade or
|
||||
interpolated audio rather than a click or silence.
|
||||
|
||||
FEC (Forward Error Correction) in Opus encodes redundant data from
|
||||
the previous frame into the current packet. If the previous packet was
|
||||
lost but the current one arrives, the previous frame can be recovered.
|
||||
|
||||
---
|
||||
|
||||
## Latency budget (target)
|
||||
|
||||
```
|
||||
Microphone capture latency ~5ms
|
||||
Pre-processing (RNNoise, AEC) ~2ms
|
||||
Opus encode (20ms frame) ~20ms
|
||||
UDP tx ~1–5ms (local)
|
||||
Voice node relay ~0.5ms
|
||||
UDP rx ~1–5ms (local)
|
||||
Jitter buffer (adaptive) ~20–40ms
|
||||
Opus decode ~1ms
|
||||
Playback buffer ~5ms
|
||||
──────────────────────────────────────
|
||||
Total (local network) ~55–80ms
|
||||
Target (good conditions) < 60ms
|
||||
```
|
||||
|
||||
For ultra-low latency setups (LAN gaming): use 10ms frame size,
|
||||
reduce jitter buffer to 20ms, disable adaptive mode.
|
||||
Expected total: ~35–45ms.
|
||||
34
docs/03-server/README.md
Normal file
34
docs/03-server/README.md
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
# Server / Gateway
|
||||
|
||||
A VNOX node consists of two processes:
|
||||
|
||||
```
|
||||
gateway — TCP, handles auth / chat / channels / permissions
|
||||
voice-node — UDP, handles voice relay
|
||||
```
|
||||
|
||||
Both are written in Rust. They can run on the same machine or separately.
|
||||
For most self-hosted setups, running both on one machine is fine.
|
||||
|
||||
---
|
||||
|
||||
## Minimum requirements
|
||||
|
||||
| | Minimum | Recommended |
|
||||
|---|---|---|
|
||||
| CPU | 1 core | 2+ cores |
|
||||
| RAM | 256 MB | 512 MB |
|
||||
| Bandwidth | 1 Mbps up | 10 Mbps up |
|
||||
| OS | Linux x86_64 | Linux x86_64 |
|
||||
| Ports | TCP 7600, UDP 7700 | configurable |
|
||||
|
||||
Voice bandwidth scales with concurrent speakers:
|
||||
~64 kbps per active speaker (default 64k bitrate, 20ms frames).
|
||||
|
||||
---
|
||||
|
||||
## Sections
|
||||
|
||||
- [deployment.md](deployment.md) — how to install and run a node
|
||||
- [configuration.md](configuration.md) — full config.toml reference
|
||||
- [operations.md](operations.md) — metrics, logs, backup, upgrades
|
||||
220
docs/03-server/configuration.md
Normal file
220
docs/03-server/configuration.md
Normal file
|
|
@ -0,0 +1,220 @@
|
|||
# Configuration
|
||||
|
||||
All configuration lives in a single `config.toml` file.
|
||||
Path: `/etc/vnox/config.toml` (or passed via `--config`).
|
||||
|
||||
For local development, see [dev/README.md](../../dev/README.md) and `dev/config.toml`.
|
||||
|
||||
Environment variables prefixed with `VNOX_` override any config file value.
|
||||
Example: `VNOX_NODE_NAME=my-node` overrides `[node] name`.
|
||||
|
||||
---
|
||||
|
||||
## Full reference
|
||||
|
||||
```toml
|
||||
# ─── NODE ──────────────────────────────────────────────────────────────────
|
||||
|
||||
[node]
|
||||
# Human-readable name shown to connecting clients.
|
||||
name = "my-node"
|
||||
|
||||
# Public address of this node. Used by federation and relay routing.
|
||||
# Can be domain name or IP address.
|
||||
address = "my-node.example.com"
|
||||
|
||||
# LNEx protocol version this node advertises.
|
||||
# Do not change unless you know what you're doing.
|
||||
lnex_version = "v1"
|
||||
|
||||
|
||||
# ─── GATEWAY ───────────────────────────────────────────────────────────────
|
||||
|
||||
[gateway]
|
||||
# Address and port to listen for TCP client connections.
|
||||
bind = "0.0.0.0:7600"
|
||||
|
||||
# Maximum concurrent client connections.
|
||||
max_connections = 1000
|
||||
|
||||
# Idle session timeout in seconds. Sessions older than this
|
||||
# without activity are terminated.
|
||||
session_timeout = 300
|
||||
|
||||
# Maximum message size in bytes (text chat).
|
||||
max_message_size = 4096
|
||||
|
||||
# Rate limits
|
||||
[gateway.rate_limits]
|
||||
auth_attempts_per_minute = 5 # per IP
|
||||
messages_per_second = 10 # per user
|
||||
connections_per_ip = 4 # concurrent
|
||||
|
||||
|
||||
# ─── VOICE ─────────────────────────────────────────────────────────────────
|
||||
|
||||
[voice]
|
||||
# Address and port to listen for UDP voice packets.
|
||||
bind = "0.0.0.0:7700"
|
||||
|
||||
# Maximum concurrent voice sessions.
|
||||
max_sessions = 500
|
||||
|
||||
# Jitter buffer default size in milliseconds.
|
||||
# Clients may override this locally.
|
||||
jitter_buffer_ms = 40
|
||||
|
||||
# Maximum relay packet rate per user (packets per second).
|
||||
# At 20ms frames: 50 pps. Increase only for 10ms frame configs.
|
||||
max_pps_per_user = 60
|
||||
|
||||
|
||||
# ─── IDENTITY / TLS ────────────────────────────────────────────────────────
|
||||
|
||||
[identity]
|
||||
# Path to the node's keypair file (Ed25519).
|
||||
# Generated automatically on first start if not present.
|
||||
keypair_path = "/var/lib/vnox/node.key"
|
||||
|
||||
# TLS certificate for the TCP gateway.
|
||||
# If not set, a self-signed certificate is generated.
|
||||
# tls_cert = "/etc/vnox/tls/cert.pem"
|
||||
# tls_key = "/etc/vnox/tls/key.pem"
|
||||
|
||||
|
||||
# ─── CHANNELS ──────────────────────────────────────────────────────────────
|
||||
|
||||
[channels]
|
||||
# Default channels created on first start.
|
||||
# After that, channels are managed via the admin API or client.
|
||||
defaults = [
|
||||
{ name = "general", type = "text" },
|
||||
{ name = "lobby", type = "voice" },
|
||||
]
|
||||
|
||||
# Maximum channels per node.
|
||||
max_channels = 256
|
||||
|
||||
# Maximum users per voice channel.
|
||||
max_voice_per_channel = 50
|
||||
|
||||
|
||||
# ─── PERMISSIONS ───────────────────────────────────────────────────────────
|
||||
|
||||
[permissions]
|
||||
# Default permission level for new users connecting for the first time.
|
||||
# Options: guest | member | moderator | admin
|
||||
default_role = "member"
|
||||
|
||||
# If true, new connections are allowed by default.
|
||||
# If false, users must be explicitly whitelisted.
|
||||
open = true
|
||||
|
||||
|
||||
# ─── STORAGE ───────────────────────────────────────────────────────────────
|
||||
|
||||
[storage]
|
||||
# Directory for persistent data (message history, user records, etc.)
|
||||
data_dir = "/var/lib/vnox"
|
||||
|
||||
# Database backend.
|
||||
# Options: sqlite (default), postgres
|
||||
backend = "sqlite"
|
||||
|
||||
# SQLite database path (used if backend = "sqlite")
|
||||
sqlite_path = "/var/lib/vnox/vnox.db"
|
||||
|
||||
# PostgreSQL connection string (used if backend = "postgres")
|
||||
# postgres_url = "postgresql://user:pass@localhost/vnox"
|
||||
|
||||
# Message history retention in days. 0 = keep forever.
|
||||
history_retention_days = 90
|
||||
|
||||
|
||||
# ─── FEDERATION ────────────────────────────────────────────────────────────
|
||||
|
||||
[federation]
|
||||
# Enable federation with other nodes.
|
||||
# Phase 3 feature — disabled by default.
|
||||
enabled = false
|
||||
|
||||
# Static list of trusted nodes to connect to on startup.
|
||||
# bootstrap = [
|
||||
# "relay.other-node.example.com",
|
||||
# "192.168.1.20:7700",
|
||||
# ]
|
||||
|
||||
# Maximum incoming federation connections.
|
||||
max_incoming = 16
|
||||
|
||||
# Maximum bridged channels.
|
||||
max_bridges = 64
|
||||
|
||||
# Message rate limit per federation link (messages per second).
|
||||
rate_limit_msgs = 100
|
||||
|
||||
# Denylist — nodes that will never be allowed to federate.
|
||||
# denylist = ["bad-node.example.com"]
|
||||
|
||||
|
||||
# ─── RELAY ─────────────────────────────────────────────────────────────────
|
||||
|
||||
[relay]
|
||||
# This node acts as a relay for other nodes' voice traffic.
|
||||
# Useful for nodes behind NAT.
|
||||
enabled = false
|
||||
|
||||
# relay_secret = "shared-secret-with-trusted-nodes"
|
||||
|
||||
|
||||
# ─── LOGGING ───────────────────────────────────────────────────────────────
|
||||
|
||||
[logging]
|
||||
# Log level: error | warn | info | debug | trace
|
||||
level = "info"
|
||||
|
||||
# Log format: text | json
|
||||
format = "text"
|
||||
|
||||
# Log file path. If not set, logs go to stdout.
|
||||
# file = "/var/log/vnox/gateway.log"
|
||||
|
||||
# Log voice packet events (very verbose, debug only).
|
||||
log_voice_packets = false
|
||||
|
||||
|
||||
# ─── METRICS ───────────────────────────────────────────────────────────────
|
||||
|
||||
[metrics]
|
||||
# Expose Prometheus metrics endpoint.
|
||||
enabled = false
|
||||
bind = "127.0.0.1:9090"
|
||||
path = "/metrics"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Environment variable overrides
|
||||
|
||||
Any config key can be overridden with an env var using the pattern:
|
||||
`VNOX_` + section + `_` + key, uppercased, dots replaced with `_`.
|
||||
|
||||
Examples:
|
||||
|
||||
```bash
|
||||
VNOX_NODE_NAME=my-node
|
||||
VNOX_GATEWAY_BIND=0.0.0.0:7600
|
||||
VNOX_LOGGING_LEVEL=debug
|
||||
VNOX_STORAGE_BACKEND=postgres
|
||||
VNOX_STORAGE_POSTGRES_URL=postgresql://user:pass@db/vnox
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Validating config
|
||||
|
||||
```bash
|
||||
vnox-gateway --config /etc/vnox/config.toml --check
|
||||
```
|
||||
|
||||
Exits 0 if config is valid, prints errors otherwise.
|
||||
221
docs/03-server/deployment.md
Normal file
221
docs/03-server/deployment.md
Normal file
|
|
@ -0,0 +1,221 @@
|
|||
# Deployment
|
||||
|
||||
> **Phase 1 note:** published container images and HTTP health checks are not available yet.
|
||||
> For local development, build from source and use [dev/README.md](../../dev/README.md).
|
||||
|
||||
## Option A - Docker (when images are published)
|
||||
|
||||
The fastest way to get a node running once official images exist.
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- Docker 24+
|
||||
- Docker Compose v2
|
||||
|
||||
### docker-compose.yml
|
||||
|
||||
```yaml
|
||||
version: "3.9"
|
||||
|
||||
services:
|
||||
gateway:
|
||||
# Build locally until ghcr.io/vnox images are published:
|
||||
# build: { context: ../.., dockerfile: docker/gateway.Dockerfile }
|
||||
image: ghcr.io/vnox/gateway:latest
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "7600:7600" # TCP — client connections
|
||||
volumes:
|
||||
- ./config.toml:/etc/vnox/config.toml:ro
|
||||
- vnox-data:/var/lib/vnox
|
||||
environment:
|
||||
- VNOX_LOG=info
|
||||
|
||||
voice-node:
|
||||
image: ghcr.io/vnox/voice-node:latest
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "7700:7700/udp" # UDP — voice packets
|
||||
volumes:
|
||||
- ./config.toml:/etc/vnox/config.toml:ro
|
||||
environment:
|
||||
- VNOX_LOG=info
|
||||
|
||||
volumes:
|
||||
vnox-data:
|
||||
```
|
||||
|
||||
### Start
|
||||
|
||||
```bash
|
||||
docker compose up -d
|
||||
docker compose logs -f
|
||||
```
|
||||
|
||||
### Verify
|
||||
|
||||
```bash
|
||||
# Phase 1: no /health endpoint yet. Check TCP instead:
|
||||
nc -zv localhost 7600
|
||||
|
||||
# Or watch gateway logs after start:
|
||||
# VNOX_LOG=info cargo run -p vnox-gateway -- --config dev/config.toml
|
||||
```
|
||||
|
||||
When an HTTP health endpoint ships (Phase 2), it may look like:
|
||||
|
||||
```bash
|
||||
curl http://localhost:7600/health
|
||||
# {"status":"ok","version":"LNEx v1","node":"your-node-name"}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Option B - systemd (bare binary)
|
||||
|
||||
For servers where Docker is not available or not desired.
|
||||
|
||||
### Download
|
||||
|
||||
```bash
|
||||
# Replace VERSION with the latest release tag
|
||||
VERSION=0.1.0
|
||||
curl -L https://github.com/vnox/vnox/releases/download/v${VERSION}/vnox-linux-x86_64.tar.gz \
|
||||
| tar xz -C /usr/local/bin/
|
||||
```
|
||||
|
||||
Binaries installed:
|
||||
- `/usr/local/bin/vnox-gateway`
|
||||
- `/usr/local/bin/vnox-voice-node`
|
||||
|
||||
### Config
|
||||
|
||||
```bash
|
||||
mkdir -p /etc/vnox /var/lib/vnox
|
||||
cp config.example.toml /etc/vnox/config.toml
|
||||
$EDITOR /etc/vnox/config.toml
|
||||
```
|
||||
|
||||
### systemd units
|
||||
|
||||
`/etc/systemd/system/vnox-gateway.service`:
|
||||
|
||||
```ini
|
||||
[Unit]
|
||||
Description=VNOX Gateway
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
ExecStart=/usr/local/bin/vnox-gateway --config /etc/vnox/config.toml
|
||||
Restart=on-failure
|
||||
RestartSec=5s
|
||||
User=vnox
|
||||
Group=vnox
|
||||
StateDirectory=vnox
|
||||
RuntimeDirectory=vnox
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
```
|
||||
|
||||
`/etc/systemd/system/vnox-voice-node.service`:
|
||||
|
||||
```ini
|
||||
[Unit]
|
||||
Description=VNOX Voice Node
|
||||
After=network.target vnox-gateway.service
|
||||
|
||||
[Service]
|
||||
ExecStart=/usr/local/bin/vnox-voice-node --config /etc/vnox/config.toml
|
||||
Restart=on-failure
|
||||
RestartSec=5s
|
||||
User=vnox
|
||||
Group=vnox
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
```
|
||||
|
||||
### Enable and start
|
||||
|
||||
```bash
|
||||
# Create service user
|
||||
useradd -r -s /sbin/nologin vnox
|
||||
|
||||
# Enable services
|
||||
systemctl daemon-reload
|
||||
systemctl enable --now vnox-gateway vnox-voice-node
|
||||
|
||||
# Check status
|
||||
systemctl status vnox-gateway
|
||||
systemctl status vnox-voice-node
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Option C - bare binary (dev / test)
|
||||
|
||||
For local development and testing without systemd, see [dev/README.md](../../dev/README.md).
|
||||
|
||||
```bash
|
||||
# Terminal 1 - gateway
|
||||
cargo run -p vnox-gateway -- --config dev/config.toml
|
||||
|
||||
# Terminal 2 - voice node
|
||||
cargo run -p vnox-voice-node -- --config dev/config.toml
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Minimal config.toml
|
||||
|
||||
The minimum required configuration to get a node running:
|
||||
|
||||
```toml
|
||||
[node]
|
||||
name = "my-node" # displayed to clients
|
||||
address = "my-node.example.com" # public address (domain or IP)
|
||||
|
||||
[gateway]
|
||||
bind = "0.0.0.0:7600"
|
||||
|
||||
[voice]
|
||||
bind = "0.0.0.0:7700"
|
||||
|
||||
[identity]
|
||||
# Generated on first start if not present
|
||||
# keypair_path = "/var/lib/vnox/node.key"
|
||||
```
|
||||
|
||||
See [configuration.md](configuration.md) for the full reference.
|
||||
|
||||
---
|
||||
|
||||
## Firewall
|
||||
|
||||
Open the following ports:
|
||||
|
||||
```bash
|
||||
# TCP — client connections
|
||||
ufw allow 7600/tcp
|
||||
|
||||
# UDP — voice packets
|
||||
ufw allow 7700/udp
|
||||
```
|
||||
|
||||
If running behind a reverse proxy (nginx / caddy), only expose port 443 externally
|
||||
and proxy to 7600 internally. UDP 7700 must be directly accessible.
|
||||
|
||||
---
|
||||
|
||||
## First connection test
|
||||
|
||||
1. Download the VNOX client
|
||||
2. Open VNOX → Add node → enter your server address
|
||||
3. The client will generate an identity on first launch
|
||||
4. Connect — you should see your node's channels
|
||||
|
||||
If connection fails, check:
|
||||
- gateway is running: `systemctl status vnox-gateway`
|
||||
- port 7600 is open: `nc -zv your-server 7600`
|
||||
- logs: `journalctl -u vnox-gateway -f`
|
||||
218
docs/03-server/operations.md
Normal file
218
docs/03-server/operations.md
Normal file
|
|
@ -0,0 +1,218 @@
|
|||
# Operations
|
||||
|
||||
## Health check
|
||||
|
||||
```bash
|
||||
curl http://localhost:7600/health
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "ok",
|
||||
"version": "LNEx v1",
|
||||
"node": "my-node",
|
||||
"uptime_seconds": 86400,
|
||||
"connections": 12,
|
||||
"voice_sessions": 4
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Logs
|
||||
|
||||
### View live logs
|
||||
|
||||
```bash
|
||||
# systemd
|
||||
journalctl -u vnox-gateway -f
|
||||
journalctl -u vnox-voice-node -f
|
||||
|
||||
# Docker
|
||||
docker compose logs -f gateway
|
||||
docker compose logs -f voice-node
|
||||
```
|
||||
|
||||
### Log levels
|
||||
|
||||
Set in `config.toml` under `[logging] level` or via `VNOX_LOGGING_LEVEL`:
|
||||
|
||||
```
|
||||
error — only errors
|
||||
warn — errors + warnings
|
||||
info — normal operation (default)
|
||||
debug — detailed internal events
|
||||
trace — everything including packet events (very verbose)
|
||||
```
|
||||
|
||||
For production: `info`.
|
||||
For debugging a specific issue: `debug`.
|
||||
Never run `trace` in production — it logs packet contents.
|
||||
|
||||
### Useful log patterns
|
||||
|
||||
```bash
|
||||
# Auth failures
|
||||
journalctl -u vnox-gateway | grep "AUTH_FAILED"
|
||||
|
||||
# New connections
|
||||
journalctl -u vnox-gateway | grep "SESSION"
|
||||
|
||||
# Voice node errors
|
||||
journalctl -u vnox-voice-node | grep "ERROR"
|
||||
|
||||
# Rate limit events
|
||||
journalctl -u vnox-gateway | grep "RATE_LIMITED"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Metrics (Prometheus)
|
||||
|
||||
Enable in `config.toml`:
|
||||
|
||||
```toml
|
||||
[metrics]
|
||||
enabled = true
|
||||
bind = "127.0.0.1:9090"
|
||||
path = "/metrics"
|
||||
```
|
||||
|
||||
Available metrics:
|
||||
|
||||
```
|
||||
vnox_connections_total # total connections since start
|
||||
vnox_connections_active # current active connections
|
||||
vnox_auth_success_total # successful authentications
|
||||
vnox_auth_failure_total # failed authentications
|
||||
vnox_messages_total # text messages delivered
|
||||
vnox_voice_sessions_active # current voice sessions
|
||||
vnox_voice_packets_total # voice packets relayed
|
||||
vnox_voice_packet_loss_ratio # packet loss (rolling average)
|
||||
vnox_federation_links_active # active federation connections (Phase 3)
|
||||
vnox_gateway_latency_ms # gateway processing latency histogram
|
||||
```
|
||||
|
||||
Scrape config for `prometheus.yml`:
|
||||
|
||||
```yaml
|
||||
scrape_configs:
|
||||
- job_name: vnox
|
||||
static_configs:
|
||||
- targets: ['localhost:9090']
|
||||
scrape_interval: 15s
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Backup
|
||||
|
||||
### What to back up
|
||||
|
||||
| Path | Contents | Priority |
|
||||
|------|----------|---------|
|
||||
| `/var/lib/vnox/node.key` | Node identity keypair | **Critical** |
|
||||
| `/var/lib/vnox/vnox.db` | Message history, user records | High |
|
||||
| `/etc/vnox/config.toml` | Node configuration | Medium |
|
||||
|
||||
Losing `node.key` means the node loses its federated identity.
|
||||
Other nodes that trusted this node's pubkey will not recognize the replacement.
|
||||
|
||||
### Backup node.key
|
||||
|
||||
```bash
|
||||
# Copy to secure location
|
||||
cp /var/lib/vnox/node.key /backup/vnox-node-$(date +%Y%m%d).key
|
||||
chmod 600 /backup/vnox-node-*.key
|
||||
```
|
||||
|
||||
Store this file encrypted, offline, and in multiple locations.
|
||||
|
||||
### Backup SQLite database
|
||||
|
||||
```bash
|
||||
# While gateway is running — SQLite WAL-safe copy
|
||||
sqlite3 /var/lib/vnox/vnox.db ".backup /backup/vnox-$(date +%Y%m%d).db"
|
||||
|
||||
# Or stop gateway first for a simple copy
|
||||
systemctl stop vnox-gateway
|
||||
cp /var/lib/vnox/vnox.db /backup/vnox-$(date +%Y%m%d).db
|
||||
systemctl start vnox-gateway
|
||||
```
|
||||
|
||||
### Restore
|
||||
|
||||
```bash
|
||||
systemctl stop vnox-gateway vnox-voice-node
|
||||
|
||||
cp /backup/vnox-node-20260101.key /var/lib/vnox/node.key
|
||||
cp /backup/vnox-20260101.db /var/lib/vnox/vnox.db
|
||||
|
||||
chown vnox:vnox /var/lib/vnox/node.key /var/lib/vnox/vnox.db
|
||||
chmod 600 /var/lib/vnox/node.key
|
||||
|
||||
systemctl start vnox-gateway vnox-voice-node
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Upgrades
|
||||
|
||||
### Check current version
|
||||
|
||||
```bash
|
||||
vnox-gateway --version
|
||||
# VNOX Gateway 0.2.0 (LNEx v1)
|
||||
```
|
||||
|
||||
### Docker upgrade
|
||||
|
||||
```bash
|
||||
docker compose pull
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
### systemd upgrade
|
||||
|
||||
```bash
|
||||
# Download new binary
|
||||
VERSION=0.2.0
|
||||
curl -L https://github.com/vnox/vnox/releases/download/v${VERSION}/vnox-linux-x86_64.tar.gz \
|
||||
| tar xz -C /tmp/vnox-upgrade/
|
||||
|
||||
# Validate config against new binary
|
||||
/tmp/vnox-upgrade/vnox-gateway --config /etc/vnox/config.toml --check
|
||||
|
||||
# Replace binaries
|
||||
systemctl stop vnox-gateway vnox-voice-node
|
||||
cp /tmp/vnox-upgrade/vnox-gateway /usr/local/bin/
|
||||
cp /tmp/vnox-upgrade/vnox-voice-node /usr/local/bin/
|
||||
systemctl start vnox-gateway vnox-voice-node
|
||||
```
|
||||
|
||||
### LNEx protocol upgrades
|
||||
|
||||
When a new LNEx version is released:
|
||||
|
||||
- old clients can still connect if the gateway supports multiple versions
|
||||
- the gateway advertises supported versions in `HELLO`
|
||||
- both sides negotiate the highest mutually supported version
|
||||
- a grace period is announced before old versions are dropped
|
||||
|
||||
Breaking changes in LNEx are rare and will be documented in the changelog
|
||||
with a migration guide.
|
||||
|
||||
---
|
||||
|
||||
## Security hardening checklist
|
||||
|
||||
- [ ] Gateway runs as unprivileged user (`vnox`, not root)
|
||||
- [ ] `node.key` is `chmod 600`, owned by `vnox`
|
||||
- [ ] Metrics endpoint is not exposed publicly (bound to `127.0.0.1`)
|
||||
- [ ] Firewall: only TCP 7600 and UDP 7700 are open externally
|
||||
- [ ] Log retention policy configured (rotate logs, don't fill disk)
|
||||
- [ ] `node.key` backup exists and is stored securely
|
||||
- [ ] `VNOX_LOGGING_LEVEL` is `info` or `warn` in production (not `trace`)
|
||||
- [ ] `[gateway.rate_limits]` are configured appropriately for your user base
|
||||
- [ ] TLS certificate is valid (or TOFU is acceptable for your use case)
|
||||
- [ ] Federation disabled if not needed (`[federation] enabled = false`)
|
||||
19
docs/04-clients/README.md
Normal file
19
docs/04-clients/README.md
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
# Clients
|
||||
|
||||
VNOX is a native-only platform. There is no web client and none is planned.
|
||||
|
||||
## Why no web client
|
||||
|
||||
- WebRTC adds latency overhead incompatible with VNOX's voice latency targets
|
||||
- WASM + wgpu in browser is not a viable egui deployment target today
|
||||
- Electron is explicitly rejected (it's what we're building against)
|
||||
- A browser tab is not the right environment for a persistent voice client
|
||||
|
||||
The desktop client is a real native binary. It starts fast, uses minimal memory,
|
||||
and has direct access to audio hardware without browser sandboxing.
|
||||
|
||||
## Clients
|
||||
|
||||
- [desktop.md](desktop.md) — primary client, Windows / macOS / Linux
|
||||
- [mobile.md](mobile.md) — Phase 3, stack TBD
|
||||
- [overlay.md](overlay.md) — in-game HUD, Phase 2
|
||||
400
docs/04-clients/design-system.md
Normal file
400
docs/04-clients/design-system.md
Normal file
|
|
@ -0,0 +1,400 @@
|
|||
# VNOX Client — Design System
|
||||
|
||||
> Based on Hi-Fi Minimalism v2.0
|
||||
> Adapted for: native desktop voice/chat client (Rust + egui + wgpu)
|
||||
|
||||
---
|
||||
|
||||
## Philosophy
|
||||
|
||||
VNOX UI должен ощущаться как инструмент, а не социальная сеть.
|
||||
|
||||
```
|
||||
calm · focused · fast · warm · precise
|
||||
```
|
||||
|
||||
Не:
|
||||
```
|
||||
gamer RGB · Discord clone · neon cyberpunk · glassmorphism · corporate SaaS
|
||||
```
|
||||
|
||||
> The client is infrastructure. The UI is just the control surface.
|
||||
|
||||
---
|
||||
|
||||
## Color Tokens
|
||||
|
||||
```css
|
||||
/* Backgrounds — layered, never pure black */
|
||||
--bg-base: #0d0d0d; /* root, titlebar */
|
||||
--bg-surface: #151515; /* panels, sidebars (was #111111 — lifted for panel contrast) */
|
||||
--bg-elevated: #1d1d1d; /* inputs, cards (was #141414 — lifted for depth) */
|
||||
--bg-interactive: #2a2a2a; /* hover targets, dropdowns */
|
||||
|
||||
/* Borders — lifted significantly for visual hierarchy */
|
||||
--border-subtle: #1c1c1c; /* was #171717 */
|
||||
--border-default: #262626; /* was #1e1e1e — now actually visible */
|
||||
--border-strong: #2e2e2e;
|
||||
--border-accent: rgba(255, 107, 53, 0.20);
|
||||
|
||||
/* Accent — warm orange */
|
||||
--accent: #ff6b35;
|
||||
--accent-hover: #ff844f;
|
||||
--accent-active: #e85d04;
|
||||
--accent-10: rgba(255, 107, 53, 0.08);
|
||||
--accent-20: rgba(255, 107, 53, 0.15);
|
||||
|
||||
/* Text — warm, never pure white */
|
||||
--text-primary: #f4c89a; /* main content (added) */
|
||||
--text-secondary: #c88b5a; /* names, labels */
|
||||
--text-muted: #8a6a52; /* secondary info */
|
||||
--text-dim: #6a4a2a; /* hints, timestamps */
|
||||
--text-ghost: #5a422c; /* section labels, separators (was #3d2e20 — lifted) */
|
||||
--text-invisible: #443222; /* near-invisible, decorative (was #2d1e12 — lifted) */
|
||||
|
||||
/* Semantic */
|
||||
--success: #7cb87a; /* online, connected, low latency */
|
||||
--success-muted: rgba(124, 184, 122, 0.10);
|
||||
--warning: #e6a230; /* ping indicator, unread */
|
||||
--warning-muted: rgba(230, 162, 48, 0.10);
|
||||
--error: #d9604a; /* muted mic (when needed), errors */
|
||||
--error-muted: rgba(217, 96, 74, 0.10);
|
||||
--info: #6a9ecf; /* second user accent color */
|
||||
--info-muted: rgba(106, 158, 207, 0.10);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Typography
|
||||
|
||||
Клиент использует **только monospace**. Это намеренно — усиливает ощущение инструмента.
|
||||
|
||||
```
|
||||
Primary font: IBM Plex Mono
|
||||
Fallback: Fira Code, Geist Mono, monospace
|
||||
```
|
||||
|
||||
### Scale (клиент-специфичный, компактный)
|
||||
|
||||
| Role | Size | Weight | Color |
|
||||
|------|------|--------|-------|
|
||||
| Section label | 10px | 400 | `--text-ghost` |
|
||||
| Timestamp, ID | 9px | 400 | `--text-invisible` |
|
||||
| Status, badge | 9px | 500 | semantic |
|
||||
| Channel name | 12px | 400 | `--text-ghost` → `--text-secondary` |
|
||||
| Message text | 11px | 400 | `--text-muted` |
|
||||
| Username | 11px | 600 | varies per user |
|
||||
| Node name | 13px | 600 | `--text-secondary` |
|
||||
| Settings title | 13px | 600 | `--text-secondary` |
|
||||
| Wordmark VNOX | 11px | 600 | letter-spacing: 0.2em |
|
||||
| Panel title | 11px | 400 | `--text-secondary` |
|
||||
| Dashboard heading | 14px | 600 | `--text-secondary` |
|
||||
|
||||
Letter spacing для section labels: `0.10–0.12em`, text-transform: uppercase.
|
||||
|
||||
---
|
||||
|
||||
## Layout
|
||||
|
||||
### Title bar (34px)
|
||||
|
||||
- Background: `--bg-strip` (`theme::BG_STRIP`); **без отдельной painter-linии** под всю ширину — переход «titlebar ↔ контент» только за счёт контраста `--bg-strip` vs `--bg-base`. Раньше 1px `hline` воспринимался как случайная полоска «под логотипом» из-за состыковки с левым rail.
|
||||
- Сетка из **трёх равных колонок**:
|
||||
- **левая** — **`[ prefs ]`** + **`VNOX`** (wordmark `--text-primary`, слева направо после prefs);
|
||||
- **центр** — статус ноды: **`●`** + **`NODE: …`** (`OFFLINE` / `CONNECTING` / имя ноды), **по центру средней колонки** (= визуальный центр окна);
|
||||
- **правая** — **transport‑подсказка** справа: `transport: idle` · `transport: connecting…` · `transport: quic/v1`.
|
||||
- Вход в настройки: **`[ prefs ]`** — явная консольная кнопка (без нестабильных Unicode‑иконок); при открытых настройках тот же текст, цвет **accent**. Дубль: кнопка **open prefs** в нижней левой колонке, **только пока offline**.
|
||||
- Нативный заголовок ОС (**taskbar / список окон**) — короткий **`Vnox`**, чтобы не дублировать тот же wordmark **`VNOX`** внутри клиента.
|
||||
|
||||
### Структура окна
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────┐
|
||||
│ [prefs] VNOX │ ● NODE: OFFLINE │ transport: idle │
|
||||
│ (нет линии 1px на всю ширину под панелью) │
|
||||
├──────────────────────────────────────────────────────┤
|
||||
│ rail │ channels (208px) │ main content │
|
||||
│ (52px) │ │ │
|
||||
│ SERVERS │ node / status │ connect or chat │
|
||||
│ [AB] │ channel sections │ │
|
||||
│ [+] │ ─────────────── │ │
|
||||
│ │ identity + voice UI │ │
|
||||
└─────────┴─────────────────────┴─────────────────────┘
|
||||
```
|
||||
|
||||
### Node rail (bookmark strip, 52px)
|
||||
|
||||
Узкая колонка **закладок серверов** (не «Discord‑кругляши»):
|
||||
|
||||
- Ширина **52px**, фон `--bg-strip`, как верхний titlebar для визуальной связности.
|
||||
- Заголовок колонки: **SERVERS** (8px, `--text-ghost`, центрируется по ширине rail).
|
||||
- Плитки **30×30px**, скругление 5px, **строго центрируются** по горизонтали (без случайной короткой линии‑разделителя посередине — она давала эффект «криво» в узком rail).
|
||||
- **Idle** (нет активной закладки): рамка 1px `--border-faint`, подпись `···` во внутреннем поле, tooltip объясняет «подключайся через Connect».
|
||||
- **Активная нода**: 2 символа аббревиатуры, `--accent` левый акцент 2px внутри плитки, `--accent-10` фон.
|
||||
- **«+»**: только одна вторичная плита внизу; tooltip — «bookmark позже».
|
||||
|
||||
### Node switcher (legacy note)
|
||||
|
||||
Исторически описывался как 40px rail с линией перед `+`; в текущем клиенте заменено на блок **Node rail** выше.
|
||||
|
||||
### Channel list (208px)
|
||||
|
||||
- Node info (верх): имя ноды 12px semibold + `lnex://nc.<short_id>` 9px `--text-ghost` при коннекте; офлайн — одна строка **not connected**
|
||||
- Section labels: 9px uppercase, `--text-ghost`, letter-spacing 0.12em
|
||||
- Channel item: 4px 12px padding, gap 6px (icon + name)
|
||||
- Default: `--text-ghost`
|
||||
- Hover: `--text-dim` (без background)
|
||||
- Active: `--text-secondary` + левая полоска 2px `--accent` + `--accent-10` bg
|
||||
- Voice users под каналом: indent 26px, 10px, `--success` для говорящих, `--text-ghost` для muted
|
||||
|
||||
### Bottom-left identity bar
|
||||
|
||||
```
|
||||
[ open prefs ] (только пока offline)
|
||||
|
||||
— voice ~ channel 00:42
|
||||
[x] microphone [x] hear others
|
||||
|
||||
[YU] you
|
||||
a1b2…9f0e (pubkey hex, middle-truncated)
|
||||
```
|
||||
|
||||
- **Не показывать** фиктивный RTT (например «12ms»), если нет реального измерения.
|
||||
- **Не дублировать** протокольную строку вида `LNEx v1 · udp` под ником — перегружает и налезает на аватар/текст; протокол раскрывается в Connect / docs.
|
||||
- Настройки **не** прячем в ряд мелких иконок у ника: основной вход — **`[ prefs ]`** в title bar; офлайн — компактная кнопка **open prefs**.
|
||||
- В голосе: чекбоксы **microphone** / **hear others** (без эмодзи/символов, которые на части шрифтов дают «квадратики»).
|
||||
- Avatar: 32×32px, radius 6px, disabled button (только визуал), self — `--accent-10` + border.
|
||||
|
||||
### Main area
|
||||
|
||||
- Channel header: 36px, border-bottom `--border-subtle`
|
||||
- Messages: padding 12px 14px, gap между группами 8px
|
||||
- Input: `#general ›` prefix + cursor blink + hint text
|
||||
|
||||
---
|
||||
|
||||
## Components
|
||||
|
||||
### Toggle
|
||||
|
||||
```
|
||||
Off: width 30px, height 16px, bg --bg-elevated, border --border-default
|
||||
thumb: 10px circle, bg #2d2d2d, left 2px
|
||||
|
||||
On: bg --accent-10, border --border-accent
|
||||
thumb: bg --accent, left 16px, glow rgba(255,107,53,0.4)
|
||||
|
||||
Transition: 150ms ease
|
||||
```
|
||||
|
||||
### Badge
|
||||
|
||||
```css
|
||||
/* Protocol / transport */
|
||||
.badge-lnex { color: #ff844f; border: 1px solid rgba(255,107,53,0.2); bg: rgba(255,107,53,0.08) }
|
||||
.badge-udp { color: #6a9ecf; border: 1px solid rgba(106,158,207,0.2); bg: rgba(106,158,207,0.10) }
|
||||
.badge-tcp { color: #e6a230; border: 1px solid rgba(230,162,48,0.2); bg: rgba(230,162,48,0.10) }
|
||||
.badge-enc { color: #7cb87a; border: 1px solid rgba(124,184,122,0.2); bg: rgba(124,184,122,0.10) }
|
||||
|
||||
/* Размер: 9px, padding 1px 6px, border-radius 3px */
|
||||
```
|
||||
|
||||
### Select / Input
|
||||
|
||||
```
|
||||
bg: --bg-elevated (#111)
|
||||
border: 1px solid --border-default
|
||||
border-radius: 5px
|
||||
font: 10px IBM Plex Mono
|
||||
color: --text-muted
|
||||
|
||||
focus:
|
||||
border-color: --border-accent
|
||||
```
|
||||
|
||||
### Slider
|
||||
|
||||
```
|
||||
track: 2px height, --border-default
|
||||
thumb: 10px circle, --accent, subtle glow
|
||||
|
||||
::-webkit-slider-thumb {
|
||||
background: var(--accent);
|
||||
box-shadow: 0 0 6px rgba(255, 107, 53, 0.3);
|
||||
}
|
||||
```
|
||||
|
||||
### User avatar
|
||||
|
||||
```
|
||||
Size variants:
|
||||
sm — 24×24px, border-radius 5px (identity bar)
|
||||
md — 26×26px, border-radius 6px (member list)
|
||||
lg — 36×36px, border-radius 8px (identity card in settings)
|
||||
|
||||
Default: bg --bg-elevated, border --border-default, color --text-muted
|
||||
Self: bg --accent-10, border --border-accent, color --accent
|
||||
Other: custom per-user, based on their seed color (--info, --text-secondary, etc.)
|
||||
```
|
||||
|
||||
### System message / separator
|
||||
|
||||
```
|
||||
font-size: 9–10px
|
||||
color: --text-ghost
|
||||
display: flex + ::before/::after lines in --border-subtle
|
||||
|
||||
Examples:
|
||||
● connected · nightcore.lnex · LNEx v1
|
||||
— today —
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Active / Indicator Language
|
||||
|
||||
Везде используется одна и та же визуальная метафора для "активно":
|
||||
|
||||
```
|
||||
Левая вертикальная полоска 2px --accent
|
||||
+ subtle --accent-10 background
|
||||
```
|
||||
|
||||
Это применяется к:
|
||||
- активной ноде в switcher
|
||||
- активному каналу в списке
|
||||
- активному разделу в настройках
|
||||
|
||||
Не используется background без полоски, и не используется полоска без подсветки.
|
||||
|
||||
---
|
||||
|
||||
## Voice Indicators
|
||||
|
||||
```
|
||||
Говорит: dot 5px #7cb87a, box-shadow 0 0 6px rgba(124,184,122,0.45)
|
||||
Muted: icon ti-microphone-off, color --text-ghost
|
||||
В канале: dot или icon перед именем, indent 26px от иконки канала
|
||||
```
|
||||
|
||||
В voice badge (активный call):
|
||||
```
|
||||
dot 5px --accent, box-shadow 0 0 6px rgba(255,107,53,0.5)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Settings Layout
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────┐
|
||||
│ titlebar — [prefs]* │ VNOX │ … │
|
||||
│ открыть настройки: клик [prefs]; обратный маршрут │
|
||||
│ тот же, пока приложение показывает settings screen │
|
||||
├──────────────────────────────────────────────────────┤
|
||||
│ nav (160px) │ content │
|
||||
│ │ │
|
||||
│ account │ [page title] │
|
||||
│ identity │ [subtitle] │
|
||||
│ │ │
|
||||
│ audio │ [group label] ────────────────── │
|
||||
│ voice ◄ │ row: label + control │
|
||||
│ output │ row: label + control │
|
||||
│ │ │
|
||||
│ network │ [group label] ────────────────── │
|
||||
│ network │ ... │
|
||||
│ overlay │ │
|
||||
│ │ │
|
||||
│ app │ │
|
||||
│ appearance │ │
|
||||
│ keybinds │ │
|
||||
│ plugins │ │
|
||||
│ │ │
|
||||
│ debug │ │
|
||||
│ advanced │ │
|
||||
└─────────────────┴────────────────────────────────────┘
|
||||
```
|
||||
|
||||
- Nav width: 160px, bg `--bg-base`
|
||||
- Section labels в nav: 9px uppercase, `--text-ghost`
|
||||
- Nav item: 11px, default `--text-ghost`, hover `--text-dim`, active `--text-secondary` + левая полоска
|
||||
- Content padding: 16px 20px
|
||||
- Group label: 9px uppercase, `--text-ghost`, border-bottom `--border-subtle`, margin-bottom 8px
|
||||
- Row: `display:flex; justify-content:space-between; align-items:center; padding:6px 0`
|
||||
- Row separator: `border-top: 1px solid #0f0f0f` (почти невидимый, только ритм)
|
||||
|
||||
---
|
||||
|
||||
## Motion
|
||||
|
||||
```
|
||||
duration-instant: 80ms — toggle, dot
|
||||
duration-fast: 120ms — hover color change
|
||||
duration-base: 150ms — toggle thumb, panel transitions
|
||||
duration-slow: 250ms — page switch in settings
|
||||
|
||||
ease: cubic-bezier(0.0, 0.0, 0.2, 1) — ease-out для всего
|
||||
```
|
||||
|
||||
Никаких scale transforms на hover. Только:
|
||||
- `color` transition
|
||||
- `background` transition
|
||||
- `border-color` transition
|
||||
- `box-shadow` для glow (subtle)
|
||||
- `left` для toggle thumb
|
||||
|
||||
---
|
||||
|
||||
## Anti-patterns (клиент-специфично)
|
||||
|
||||
| ❌ Не делать | ✅ Делать |
|
||||
|-------------|---------|
|
||||
| Отдельная 1px линия на всю ширину только под titlebar (стек с rail = «случайная полоска») | Отделение только контрастом `--bg-strip` / `--bg-base`; линии локально там, где группируешь контент |
|
||||
| Круглые аватары | Квадратные с border-radius 5–8px |
|
||||
| Sidebar с серверами как у Discord (72px, круглые) | Узкий rail **52px**, квадратные плитки **30×30**, аббревиатура, заголовок **SERVERS** |
|
||||
| Панель участников справа как у Discord | Нет отдельной панели, участники — в боковом списке |
|
||||
| Neon glow на элементах | Subtle glow max 0.18 opacity |
|
||||
| Pure black backgrounds | Минимум #0a0a0a |
|
||||
| Pure white text | Максимум #f4c89a |
|
||||
| RGB accent | Один тёплый accent #ff6b35 |
|
||||
| Rounded pill buttons everywhere | border-radius 4–6px, pill только для badge |
|
||||
| Жирные разделители | 1px --border-subtle, почти невидимые |
|
||||
|
||||
---
|
||||
|
||||
## Noise Texture
|
||||
|
||||
Лёгкий шум поверх всего интерфейса — добавляет аналоговое ощущение.
|
||||
|
||||
```css
|
||||
.root::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background-image: url("data:image/svg+xml,..."); /* SVG feTurbulence */
|
||||
opacity: 0.025; /* максимум 0.03, иначе грязно */
|
||||
pointer-events: none;
|
||||
z-index: 999;
|
||||
}
|
||||
```
|
||||
|
||||
В egui/wgpu: реализуется как overlay texture на финальном render pass.
|
||||
|
||||
---
|
||||
|
||||
## Seed Colors для пользователей
|
||||
|
||||
У каждого пользователя свой цвет ника, детерминированный от pubkey.
|
||||
|
||||
Палитра допустимых цветов (тёплая, совместимая с системой):
|
||||
|
||||
```
|
||||
#c88b5a — warm amber (default / self)
|
||||
#6a9ecf — steel blue
|
||||
#7cb87a — warm green
|
||||
#c49a6c — sand
|
||||
#b07cc6 — muted purple
|
||||
#d4876a — terracotta
|
||||
```
|
||||
|
||||
Не используются: яркие/neon цвета, холодные синие, чистый белый/красный.
|
||||
195
docs/04-clients/desktop.md
Normal file
195
docs/04-clients/desktop.md
Normal file
|
|
@ -0,0 +1,195 @@
|
|||
# Desktop Client
|
||||
|
||||
The primary VNOX client. Native, fast, lightweight.
|
||||
|
||||
## Stack
|
||||
|
||||
| Layer | Technology |
|
||||
|-------|-----------|
|
||||
| Language | Rust |
|
||||
| UI framework | egui |
|
||||
| Renderer | wgpu |
|
||||
| Audio capture/playback | cpal |
|
||||
| Audio processing | rodio |
|
||||
| Voice codec | opus (libopus bindings) |
|
||||
| Networking | quinn (QUIC/UDP), tokio |
|
||||
| Serialization | prost (Protobuf) |
|
||||
|
||||
### Why egui
|
||||
|
||||
- immediate mode UI: simple to reason about, no complex state trees
|
||||
- runs on wgpu: same renderer as the rest of the GPU pipeline
|
||||
- truly cross-platform: one codebase, same behavior on Windows/macOS/Linux
|
||||
- no runtime dependencies: ships as a single binary
|
||||
|
||||
### Why wgpu
|
||||
|
||||
- modern GPU API (Vulkan / Metal / DX12 / WebGPU backend)
|
||||
- future-proof for overlay rendering and spatial audio visualizations
|
||||
- native on all tier-1 platforms
|
||||
|
||||
---
|
||||
|
||||
## Platforms
|
||||
|
||||
| Platform | Status |
|
||||
|----------|--------|
|
||||
| Linux x86_64 | Phase 1 |
|
||||
| Windows x86_64 | Phase 1 |
|
||||
| macOS (Apple Silicon) | Phase 1 |
|
||||
| macOS (Intel) | Phase 1 |
|
||||
| Linux ARM64 | Phase 2 |
|
||||
|
||||
---
|
||||
|
||||
## Audio pipeline
|
||||
|
||||
```
|
||||
cpal (capture)
|
||||
│ PCM f32 48000Hz
|
||||
▼
|
||||
RNNoise (noise suppression)
|
||||
▼
|
||||
AEC (echo cancellation)
|
||||
▼
|
||||
VAD (voice activity detection)
|
||||
▼
|
||||
opus encode
|
||||
▼
|
||||
LNEx UDP packet → voice-node
|
||||
```
|
||||
|
||||
Playback:
|
||||
|
||||
```
|
||||
LNEx UDP packet ← voice-node
|
||||
▼
|
||||
jitter buffer
|
||||
▼
|
||||
opus decode
|
||||
▼
|
||||
rodio (playback)
|
||||
▼
|
||||
cpal (output device)
|
||||
```
|
||||
|
||||
Audio device selection is configurable in Settings → Voice and Settings → Audio Output.
|
||||
|
||||
---
|
||||
|
||||
## Design system
|
||||
|
||||
The client follows the VNOX Hi-Fi Minimalism design system.
|
||||
Full specification: `docs/04-clients/design-system.md`
|
||||
|
||||
Key decisions:
|
||||
- monospace font throughout (IBM Plex Mono)
|
||||
- warm dark palette (`#0d0d0d` base, `#ff6b35` accent)
|
||||
- no round server icons — square with abbreviation, 40px sidebar
|
||||
- latency indicator bottom-left, next to identity
|
||||
- no separate member list panel — voice users shown inline in channel list
|
||||
|
||||
---
|
||||
|
||||
## Layout
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────────┐
|
||||
│ titlebar: dots · VNOX wordmark · connected node │
|
||||
├────────┬──────────────────┬───────────────────────── │
|
||||
│ nodes │ channels │ main (chat / voice) │
|
||||
│ 40px │ 190px │ flex │
|
||||
│ │ │ │
|
||||
│ NC ◄ │ nightcore.lnex │ #general │
|
||||
│ DV · │ ───────────── │ ───────────────────── │
|
||||
│ GG │ # general ◄ │ messages │
|
||||
│ VD │ # dev-talk │ │
|
||||
│ + │ # plugins │ │
|
||||
│ │ │ │
|
||||
│ │ ~ lobby │ │
|
||||
│ │ · raven │ ───────────────────── │
|
||||
│ │ · 0xmist │ #general › [input] │
|
||||
│ │ 🔇 lurker_7 │ │
|
||||
│ │ ~ gaming │ │
|
||||
│ │ ───────────── │ │
|
||||
│ │ ● 12ms lnex v1 │ │
|
||||
│ │ [YU] you 🎤 🎧 ⚙ │ │
|
||||
└────────┴──────────────────┴──────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Settings
|
||||
|
||||
Settings are stored locally. No settings are synced to the server.
|
||||
|
||||
### Voice
|
||||
- input device
|
||||
- input volume
|
||||
- noise suppression (RNNoise on/off)
|
||||
- echo cancellation (on/off)
|
||||
- activation mode: push-to-talk / voice activity / always on
|
||||
- VAD threshold
|
||||
- Opus bitrate (8–128k)
|
||||
- Opus frame interval (10 / 20 / 40ms)
|
||||
|
||||
### Audio output
|
||||
- output device
|
||||
- output volume
|
||||
- jitter buffer size
|
||||
- adaptive jitter buffer (on/off)
|
||||
|
||||
### Network
|
||||
- relay address
|
||||
- auto relay selection
|
||||
- UDP port
|
||||
- force relay only (disable direct)
|
||||
|
||||
### Identity
|
||||
- view pubkey
|
||||
- export keypair
|
||||
- seed phrase backup
|
||||
- rotate keypair
|
||||
|
||||
### Appearance
|
||||
- color scheme
|
||||
- UI scale
|
||||
- font
|
||||
|
||||
### Keybinds
|
||||
- push-to-talk key
|
||||
- mute toggle
|
||||
- deafen toggle
|
||||
- overlay toggle
|
||||
|
||||
### Plugins
|
||||
- installed plugin list
|
||||
- enable / disable per plugin
|
||||
|
||||
### Advanced (debug)
|
||||
- log level
|
||||
- log voice packets
|
||||
- show packet stats
|
||||
- disable encryption (dev only)
|
||||
|
||||
---
|
||||
|
||||
## Building from source
|
||||
|
||||
```bash
|
||||
# Prerequisites: Rust stable, system audio libs
|
||||
|
||||
# Linux (Ubuntu/Debian)
|
||||
apt install libasound2-dev libopus-dev
|
||||
|
||||
# macOS
|
||||
brew install opus
|
||||
|
||||
# Build
|
||||
git clone https://github.com/vnox/vnox
|
||||
cd vnox/client
|
||||
cargo build --release
|
||||
|
||||
# Binary
|
||||
./target/release/vnox-client
|
||||
```
|
||||
71
docs/04-clients/mobile.md
Normal file
71
docs/04-clients/mobile.md
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
# Mobile Client
|
||||
|
||||
> Status: Phase 3 — not yet started.
|
||||
> This document tracks intentions and open questions.
|
||||
|
||||
---
|
||||
|
||||
## Goal
|
||||
|
||||
A native mobile client for iOS and Android.
|
||||
Not a PWA. Not a wrapper around the desktop client.
|
||||
|
||||
## Open questions
|
||||
|
||||
### UI framework
|
||||
|
||||
egui on mobile is not practical today — touch input support is limited,
|
||||
and the immediate mode model doesn't map well to mobile interaction patterns.
|
||||
|
||||
Candidates under consideration:
|
||||
|
||||
| Option | Notes |
|
||||
|--------|-------|
|
||||
| Rust + custom egui mobile backend | Most consistent with desktop codebase, significant work |
|
||||
| Rust + Makepad | Rust-native UI designed for mobile, less mature |
|
||||
| Rust core + Flutter UI | Dart for UI, Rust for audio/networking via FFI |
|
||||
| Rust core + Swift/Kotlin UI | Platform-native UI, Rust for the important parts |
|
||||
|
||||
Decision: deferred to Phase 3.
|
||||
|
||||
### Audio
|
||||
|
||||
Mobile audio APIs are significantly more constrained than desktop:
|
||||
|
||||
- iOS: AVAudioSession, strict background audio rules
|
||||
- Android: AAudio / OpenSL ES, varying latency by device
|
||||
|
||||
opus encoding is the same. cpal has partial mobile support.
|
||||
The audio pipeline will need platform-specific tuning.
|
||||
|
||||
### Background operation
|
||||
|
||||
Voice calls in the background require OS-level permission and
|
||||
platform-specific handling (CallKit on iOS, ConnectionService on Android).
|
||||
This is non-trivial and will be a significant portion of mobile dev effort.
|
||||
|
||||
---
|
||||
|
||||
## What mobile must support (MVP)
|
||||
|
||||
- connect to a VNOX node
|
||||
- join voice channels
|
||||
- push-to-talk
|
||||
- text chat
|
||||
- identity (same keypair as desktop, importable via QR or keyfile)
|
||||
|
||||
## What mobile explicitly will not do
|
||||
|
||||
- host a node (gateway or voice-node)
|
||||
- run plugins
|
||||
- game overlay
|
||||
|
||||
---
|
||||
|
||||
## Timeline
|
||||
|
||||
Mobile client is Phase 3, after:
|
||||
- Phase 1: desktop client + server MVP
|
||||
- Phase 2: overlay, permissions, friend system
|
||||
|
||||
Estimated start: after Phase 2 is stable.
|
||||
106
docs/04-clients/overlay.md
Normal file
106
docs/04-clients/overlay.md
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
# Overlay
|
||||
|
||||
> Status: Phase 2 — design draft.
|
||||
|
||||
The VNOX overlay renders a HUD on top of running games and applications,
|
||||
showing voice channel state without alt-tabbing.
|
||||
|
||||
---
|
||||
|
||||
## What it shows
|
||||
|
||||
- who is currently speaking (avatar / nickname + audio indicator)
|
||||
- your own mic state (active / muted / push-to-talk held)
|
||||
- current channel name
|
||||
- latency (RTT to node)
|
||||
- hotkey state hints
|
||||
|
||||
---
|
||||
|
||||
## Architecture
|
||||
|
||||
The overlay is a separate process that communicates with the main VNOX client
|
||||
via a local IPC socket (Unix socket / named pipe).
|
||||
|
||||
The client pushes state updates to the overlay:
|
||||
- user speaking events (`VOICE_STATE`)
|
||||
- channel changes
|
||||
- mute state changes
|
||||
|
||||
The overlay renders on top of other applications.
|
||||
|
||||
### Rendering approach
|
||||
|
||||
| Platform | Method |
|
||||
|----------|--------|
|
||||
| Windows | DirectX overlay injection or transparent top-level window |
|
||||
| Linux (X11) | Shaped transparent window, always-on-top |
|
||||
| Linux (Wayland) | Layer shell protocol (wlr-layer-shell) |
|
||||
| macOS | CGWindow overlay |
|
||||
|
||||
Implementation complexity varies significantly by platform.
|
||||
Windows DX injection is the most reliable for full-screen games.
|
||||
|
||||
---
|
||||
|
||||
## Game integrations
|
||||
|
||||
For games that support it, the overlay can receive additional data:
|
||||
|
||||
### Positional voice (Phase 4)
|
||||
|
||||
Games that expose player position data can send it to VNOX,
|
||||
enabling positional audio — players hear each other based on
|
||||
in-game distance and direction.
|
||||
|
||||
Integration methods:
|
||||
|
||||
| Game / Engine | Method |
|
||||
|---------------|--------|
|
||||
| Minecraft | Fabric/Forge mod that sends position via local socket |
|
||||
| Source Engine | Client plugin / VScript |
|
||||
| Unreal Engine | Plugin exposing position to named pipe |
|
||||
| Unity | SDK that writes position to shared memory |
|
||||
|
||||
Protocol for positional data is TBD (Phase 4 design).
|
||||
|
||||
### Speaking indicators in-game
|
||||
|
||||
Some games support custom HUD elements. Where possible, the overlay
|
||||
will render directly within the game's UI rather than as an external window.
|
||||
|
||||
---
|
||||
|
||||
## Configuration
|
||||
|
||||
```toml
|
||||
[overlay]
|
||||
enabled = true
|
||||
|
||||
# Overlay position on screen
|
||||
position = "top-right" # top-left | top-right | bottom-left | bottom-right
|
||||
|
||||
# Opacity (0.0–1.0)
|
||||
opacity = 0.85
|
||||
|
||||
# Show latency
|
||||
show_latency = false
|
||||
|
||||
# Hotkey to toggle overlay visibility
|
||||
toggle_hotkey = "ctrl+shift+o"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Hotkeys
|
||||
|
||||
All hotkeys are global (work even when VNOX window is not focused).
|
||||
|
||||
| Action | Default |
|
||||
|--------|---------|
|
||||
| Push-to-talk | `mouse4` |
|
||||
| Mute toggle | `ctrl+m` |
|
||||
| Deafen toggle | `ctrl+d` |
|
||||
| Toggle overlay | `ctrl+shift+o` |
|
||||
|
||||
Configurable in Settings → Keybinds.
|
||||
405
docs/05-features/admin-panel.md
Normal file
405
docs/05-features/admin-panel.md
Normal file
|
|
@ -0,0 +1,405 @@
|
|||
# Admin Panel — Design
|
||||
|
||||
> Target: Phase 1.1 (core) → Phase 1.2 (guilds, roles) → Phase 2 (plugins, bot permissions)
|
||||
|
||||
## Separation of concerns
|
||||
|
||||
| Layer | Config file (TOML) | Admin panel (GUI) |
|
||||
|-------|-------------------|-------------------|
|
||||
| **Infrastructure** | `bind`, `ports`, `data_dir`, TLS certs | — |
|
||||
| **Node identity** | `name`, `address` | Display only |
|
||||
| **Storage** | `backend`, `sqlite_path`, `retention` | — |
|
||||
| **Security** | federation gate, rate limits, session timeout | Ban list, mute, kick |
|
||||
| **Runtime state** | — | Channels, roles, members, invites, audit log |
|
||||
| **Plugins** | Plugin enable/disable | Permission keys, commands |
|
||||
|
||||
**Rule of thumb:** If it needs a server restart → TOML. If it can apply live → GUI.
|
||||
|
||||
---
|
||||
|
||||
## Permission System
|
||||
|
||||
Designed for both built-in actions and plugin/bot commands (inspired by LuckPerms).
|
||||
|
||||
### Built-in permission keys
|
||||
|
||||
```
|
||||
vnox.admin.manage_channels — create, edit, delete channels
|
||||
vnox.admin.manage_roles — create, edit, delete roles
|
||||
vnox.admin.manage_members — kick, ban, mute, timeout
|
||||
vnox.admin.manage_invites — create, delete invites
|
||||
vnox.admin.manage_guild — edit guild name, icon, settings
|
||||
vnox.admin.view_audit_log — read audit log
|
||||
|
||||
vnox.channel.read — view channel
|
||||
vnox.channel.send — send messages
|
||||
vnox.channel.voice_connect — join voice channel
|
||||
vnox.channel.voice_speak — speak in voice (push-to-talk)
|
||||
vnox.channel.voice_mute_members — server-mute others
|
||||
|
||||
vnox.dm.send — send direct messages
|
||||
vnox.friend.request — send friend requests
|
||||
vnox.presence.view — see online status
|
||||
```
|
||||
|
||||
### Custom permission keys (plugin/bot commands)
|
||||
|
||||
Plugins register their permissions at load time via the RPC API:
|
||||
|
||||
```
|
||||
mybot.command.greet — /greet command
|
||||
mybot.command.play — /play music
|
||||
mybot.command.ban_override — override sub-command
|
||||
```
|
||||
|
||||
**Wildcard support:**
|
||||
```
|
||||
vnox.admin.* — all admin permissions
|
||||
vnox.channel.* — all channel permissions
|
||||
mybot.* — all commands from mybot
|
||||
* — everything (owner only)
|
||||
```
|
||||
|
||||
### How it works
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────┐
|
||||
│ Role Editor — "Moderator" │
|
||||
├──────────────────────────────────────────────────┤
|
||||
│ Permission keys: │
|
||||
│ │
|
||||
│ ┌─ Built-in ──────────────────────────────┐ │
|
||||
│ │ ☑ vnox.admin.manage_channels │ │
|
||||
│ │ ☑ vnox.admin.manage_members │ │
|
||||
│ │ ☐ vnox.admin.manage_roles │ │
|
||||
│ │ ☑ vnox.admin.view_audit_log │ │
|
||||
│ │ ☑ vnox.channel.* │ │
|
||||
│ └──────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ ┌─ Plugin permissions ────────────────────┐ │
|
||||
│ │ ☑ mybot.command.greet │ │
|
||||
│ │ ☐ mybot.command.play │ │
|
||||
│ │ ☑ mybot.command.* │ │
|
||||
│ └──────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ ┌─ Custom key ────────────────────────────┐ │
|
||||
│ │ [ Type a permission key... ] [ +Add ] │ │
|
||||
│ └──────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ [Cancel] [Save] │
|
||||
└──────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**Resolution order (highest priority wins):**
|
||||
1. Owner — overrides everything
|
||||
2. Channel override (user-specific) — allow/deny
|
||||
3. Channel override (role-specific) — allow/deny
|
||||
4. Role hierarchy (top role has higher priority)
|
||||
5. @everyone default
|
||||
6. Server-wide default
|
||||
|
||||
Each override is `(allow_mask, deny_mask)` — deny always beats allow at the same level.
|
||||
|
||||
### Database
|
||||
|
||||
Already specified in `docs/10-database.md`:
|
||||
|
||||
```
|
||||
roles(id, guild_id, name, color, permissions BITMASK, position)
|
||||
channel_overrides(id, channel_id, target_id, target_type, allow_mask, deny_mask)
|
||||
member_roles(member_guild_id, member_user_id, role_id)
|
||||
```
|
||||
|
||||
**Custom permission keys** are stored separately:
|
||||
|
||||
```sql
|
||||
CREATE TABLE permission_keys (
|
||||
id TEXT PRIMARY KEY,
|
||||
guild_id TEXT NOT NULL,
|
||||
name TEXT NOT NULL, -- e.g. "mybot.command.greet"
|
||||
description TEXT, -- e.g. "Allow using /greet command"
|
||||
plugin_id TEXT, -- which plugin registered it, null = built-in
|
||||
created_at TIMESTAMP NOT NULL
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX idx_perm_keys_guild_name ON permission_keys(guild_id, name);
|
||||
```
|
||||
|
||||
Role grants use the same `roles.permissions` bitmask for built-in keys (first 128 bits).
|
||||
For custom/plugin keys beyond 128, they're stored in a separate table:
|
||||
|
||||
```sql
|
||||
CREATE TABLE role_perm_grants (
|
||||
role_id TEXT NOT NULL,
|
||||
permission_key TEXT NOT NULL, -- "mybot.command.greet"
|
||||
granted INTEGER NOT NULL, -- 1 = allow, 0 = deny
|
||||
PRIMARY KEY (role_id, permission_key),
|
||||
FOREIGN KEY(role_id) REFERENCES roles(id)
|
||||
);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Channel Management
|
||||
|
||||
### Create Channel
|
||||
|
||||
```
|
||||
Right-click channel list → "Create Channel"
|
||||
or
|
||||
Server Settings → Channels → [+ Add]
|
||||
```
|
||||
|
||||
**Modal:**
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────┐
|
||||
│ Create Channel │
|
||||
├──────────────────────────────────────┤
|
||||
│ │
|
||||
│ Name [_________________] │
|
||||
│ │
|
||||
│ Type ○ Text ● Voice │
|
||||
│ │
|
||||
│ Category [General ▼] [None] │
|
||||
│ │
|
||||
│ Topic [_________________] │
|
||||
│ │
|
||||
│ ┌─ Permissions ▾ ─────────────┐ │
|
||||
│ │ @everyone │ │
|
||||
│ │ ☑ View Channel │ │
|
||||
│ │ ☑ Send Messages │ │
|
||||
│ │ ☐ Voice Connect │ │
|
||||
│ │ │ │
|
||||
│ │ @admin │ │
|
||||
│ │ ☑ Everything │ │
|
||||
│ └──────────────────────────────┘ │
|
||||
│ │
|
||||
│ [Cancel] [Create] │
|
||||
└──────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Edit Channel
|
||||
|
||||
Double-click channel or right-click → "Edit Channel".
|
||||
|
||||
Same modal pre-filled, plus:
|
||||
- **Name change** — updates slug
|
||||
- **Category change** — moves channel
|
||||
- **Drag position** — reorder inside category
|
||||
- **Delete** — with confirmation (soft delete in DB)
|
||||
|
||||
### Drag & Drop
|
||||
|
||||
```
|
||||
Channels Server: My Server
|
||||
─────────────────────────────────────────────
|
||||
▶ TEXT CHANNELS [+]
|
||||
○ general
|
||||
○ announcements
|
||||
▸ VOICE CHANNELS [+]
|
||||
○ lobby ← drag handles ≡
|
||||
○ afk ← can drag to reorder inside category
|
||||
← can drag to another category
|
||||
```
|
||||
|
||||
**Behavior:**
|
||||
- Drag handle `≡` on hover
|
||||
- Drop zone highlight between channels and around categories
|
||||
- Hold `Ctrl` to copy permission overrides when moving to another category
|
||||
- Position stored in `channels.position` column (integer, step by 10 for gaps)
|
||||
- Changes broadcast via `CHANNEL_UPDATED` packet to all guild members in real-time
|
||||
|
||||
---
|
||||
|
||||
## Server Settings UI Layout
|
||||
|
||||
```
|
||||
Server Settings: "My Server"
|
||||
─────────────────────────────────────────────
|
||||
│ Navigation │ Content Panel │
|
||||
│ │ │
|
||||
│ Overview │ ┌──────────────┐ │
|
||||
│ Channels │ │ │ │
|
||||
│ Roles │ │ (dynamic) │ │
|
||||
│ Members │ │ │ │
|
||||
│ Invites │ └──────────────┘ │
|
||||
│ Bans │ │
|
||||
│ Audit Log │ │
|
||||
│ Plugins │ │
|
||||
│ ─────────────────── │ │
|
||||
│ Danger Zone │ │
|
||||
│ Delete Server │ │
|
||||
│ Transfer Owner │ │
|
||||
│ │ │
|
||||
└───────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Overview tab
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────┐
|
||||
│ Server Overview │
|
||||
│ │
|
||||
│ Name [My Server________________] │
|
||||
│ Description [_______________________] │
|
||||
│ │
|
||||
│ Icon [🔵 Upload image] drag & drop │
|
||||
│ │
|
||||
│ Owner: loki5512344 │
|
||||
│ Region: Warsaw (default) │
|
||||
│ Created: 2026-06-06 │
|
||||
│ │
|
||||
│ ████████████████████░░ Manage Channels │
|
||||
│ ████████████░░░░░░░░░░ Manage Roles │
|
||||
│ │
|
||||
│ [Save Changes] │
|
||||
└──────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Plugins tab
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────┐
|
||||
│ Plugins │
|
||||
│ │
|
||||
│ ┌─ Installed ─────────────────────┐ │
|
||||
│ │ 🎵 MusicBot v1.2 [Config] │ │
|
||||
│ │ [Disable] [Permissions ▸] │ │
|
||||
│ │ │ │
|
||||
│ │ 🤖 ModBot v0.8 [Config] │ │
|
||||
│ │ [Disable] [Permissions ▸] │ │
|
||||
│ └──────────────────────────────────┘ │
|
||||
│ │
|
||||
│ [Install from URL...] │
|
||||
│ │
|
||||
│ ┌─ Permission Keys ──────────────────┐ │
|
||||
│ │ Search: [___________________] 🔍 │ │
|
||||
│ │ │ │
|
||||
│ │ Key │ Granted │ │
|
||||
│ │ ──────────────────────────────── │ │
|
||||
│ │ musicbot.command.play │ admin │ │
|
||||
│ │ musicbot.command.skip │ @everyone│ │
|
||||
│ │ modbot.warn │ mod │ │
|
||||
│ │ modbot.ban │ admin │ │
|
||||
│ └──────────────────────────────────────┘
|
||||
└──────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## LNEx Protocol Packets
|
||||
|
||||
New packets for admin operations:
|
||||
|
||||
```
|
||||
PID 0x0070 ADMIN_LIST_CHANNELS client → gateway
|
||||
PID 0x0071 ADMIN_CREATE_CHANNEL client → gateway
|
||||
PID 0x0072 ADMIN_UPDATE_CHANNEL client → gateway
|
||||
PID 0x0073 ADMIN_DELETE_CHANNEL client → gateway
|
||||
PID 0x0074 CHANNEL_CREATED gateway → broadcast
|
||||
PID 0x0075 CHANNEL_UPDATED gateway → broadcast
|
||||
PID 0x0076 CHANNEL_DELETED gateway → broadcast
|
||||
|
||||
PID 0x0080 ADMIN_LIST_ROLES client → gateway
|
||||
PID 0x0081 ADMIN_CREATE_ROLE client → gateway
|
||||
PID 0x0082 ADMIN_UPDATE_ROLE client → gateway
|
||||
PID 0x0083 ADMIN_DELETE_ROLE client → gateway
|
||||
PID 0x0084 ADMIN_ADD_ROLE_MEMBER client → gateway
|
||||
PID 0x0085 ADMIN_REMOVE_ROLE_MEMBER client → gateway
|
||||
|
||||
PID 0x0090 ADMIN_KICK_MEMBER client → gateway
|
||||
PID 0x0091 ADMIN_BAN_MEMBER client → gateway
|
||||
PID 0x0092 ADMIN_UNBAN_MEMBER client → gateway
|
||||
PID 0x0093 ADMIN_MUTE_MEMBER client → gateway
|
||||
|
||||
PID 0x00A0 GUILD_UPDATE client → gateway
|
||||
PID 0x00A1 GUILD_DELETE client → gateway (owner only)
|
||||
PID 0x00A2 GUILD_TRANSFER client → gateway (owner only)
|
||||
```
|
||||
|
||||
**Examples:**
|
||||
|
||||
**ADMIN_CREATE_CHANNEL (0x0071):**
|
||||
```json
|
||||
{
|
||||
"guild_id": "uuid",
|
||||
"category_id": null,
|
||||
"type": "voice",
|
||||
"name": "lobby",
|
||||
"topic": "General voice"
|
||||
}
|
||||
```
|
||||
|
||||
**CHANNEL_CREATED broadcast (0x0074):**
|
||||
```json
|
||||
{
|
||||
"channel": {
|
||||
"id": "uuid",
|
||||
"guild_id": "uuid",
|
||||
"category_id": null,
|
||||
"type": "voice",
|
||||
"name": "lobby",
|
||||
"topic": "General voice",
|
||||
"position": 3
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**ADMIN_UPDATE_ROLE (0x0082):**
|
||||
```json
|
||||
{
|
||||
"role_id": "uuid",
|
||||
"name": "Moderator",
|
||||
"color": 16744960,
|
||||
"permissions": {
|
||||
"vnox.admin.manage_channels": true,
|
||||
"vnox.admin.manage_members": true,
|
||||
"mybot.command.greet": true,
|
||||
"mybot.command.warn": true
|
||||
},
|
||||
"hoist": true,
|
||||
"mentionable": true
|
||||
}
|
||||
```
|
||||
|
||||
**ADMIN_UPDATE_CHANNEL (0x0072) — includes position changes via drag & drop:**
|
||||
```json
|
||||
{
|
||||
"channel_id": "uuid",
|
||||
"name": "general",
|
||||
"category_id": "uuid",
|
||||
"position": 5,
|
||||
"topic": "General discussion"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Permission checks flow
|
||||
|
||||
```
|
||||
Client click "Create Channel"
|
||||
│
|
||||
▼
|
||||
Client sends ADMIN_CREATE_CHANNEL → Gateway
|
||||
│
|
||||
▼
|
||||
Gateway checks:
|
||||
1. Is user authenticated? → 403 if no
|
||||
2. Is user in guild? → 403 if no
|
||||
3. Does user have vnox.admin.manage_channels?
|
||||
- Resolve role hierarchy
|
||||
- Check channel_overrides (none — new channel)
|
||||
- Check wildcards (vnox.admin.*)
|
||||
- Owner override → 403 if no
|
||||
│
|
||||
▼
|
||||
Gateway: INSERT into channels table
|
||||
Gateway: INSERT into channel_overrides if specified
|
||||
Gateway: Broadcast CHANNEL_CREATED to all guild members
|
||||
Gateway: Log to audit_logs
|
||||
│
|
||||
▼
|
||||
All clients in guild see new channel in channel list
|
||||
```
|
||||
65
docs/05-features/central-hub.md
Normal file
65
docs/05-features/central-hub.md
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
# Central Hub — опциональный координационный сервер
|
||||
|
||||
## Мотивация
|
||||
|
||||
VNOX спроектирован как self-hosted платформа: каждый сам поднимает свой нод.
|
||||
Но для adoption нужна точка входа для тех, у кого нет своего сервера, и механизм
|
||||
обнаружения публичных нодов.
|
||||
|
||||
Central Hub — опциональный сервер, который **не заменяет** собственный нод
|
||||
и **не хостит** голос/чат. Он только координирует.
|
||||
|
||||
## Что делает Central Hub
|
||||
|
||||
### 1. Discovery / Registry — реестр публичных нодов
|
||||
|
||||
Владельцы нодов могут опубликовать свой сервер в реестре:
|
||||
- адрес, название, описание, регион
|
||||
- теги (`gaming`, `dev`, `ru`, `cs2`, ...)
|
||||
- мета: онлайне, кол-во пользователей
|
||||
|
||||
Клиент может найти сервер по тегам/названию и подключиться.
|
||||
Без Central Hub — только прямой ввод адреса или закладки.
|
||||
|
||||
### 2. Global identity bridge — аккаунт без своего нода
|
||||
|
||||
Пользователь регистрирует ключ на Central Hub:
|
||||
- `user@hub` — глобальный идентификатор
|
||||
- никнейм, аватар (опционально)
|
||||
- привязка к одному или нескольким "гостевым" нодам
|
||||
|
||||
Позволяет зайти в VNOX без поднятия собственной инфраструктуры.
|
||||
При этом Central Hub не хранит сообщения, не релеит голос, не управляет каналами.
|
||||
|
||||
### 3. Push notification relay — поддержка мобильных
|
||||
|
||||
Мобильные клиенты не могут держать постоянное TCP/UDP соединение.
|
||||
Central Hub принимает пуш-уведомления от нодов:
|
||||
- входящее DM
|
||||
- упоминание в чате
|
||||
- звонок в войс
|
||||
|
||||
Без этого мобильный клиент будет получать сообщения только с задержками.
|
||||
|
||||
### 4. User directory / friend graph — глобальная социальная сеть
|
||||
|
||||
Список друзей и поиск пользователей не привязан к одному ноду:
|
||||
- `@user` ищется по Central Hub
|
||||
- запрос в друзья через хаб, а не через конкретный сервер
|
||||
- cross-node DM: хаб знает, на каком ноде сейчас пользователь
|
||||
|
||||
## Границы (чего Central Hub НЕ делает)
|
||||
|
||||
- ❌ Не хостит голосовые каналы
|
||||
- ❌ Не хостит текстовые каналы
|
||||
- ❌ Не хранит историю сообщений
|
||||
- ❌ Не управляет сессиями — только направляет
|
||||
- ❌ Не заменяет self-hosted нод — только дополняет
|
||||
|
||||
Hub — это телефонная книга и коммутатор, а не сам телефон.
|
||||
|
||||
## Деплой
|
||||
|
||||
Central Hub — отдельный бинарник (например, `vnox-hub`).
|
||||
Работает на HTTP(S) / WebSocket, минимальные требования.
|
||||
Может быть поднят кем угодно, не обязательно авторами VNOX.
|
||||
443
docs/05-features/custom-emoji-stickers.md
Normal file
443
docs/05-features/custom-emoji-stickers.md
Normal file
|
|
@ -0,0 +1,443 @@
|
|||
# Custom Emoji, Stickers & GIF
|
||||
|
||||
> Target phase: 1.3 (emoji picker) / 2 (custom emoji, stickers, GIF)
|
||||
> Depends on: Phase 1.2 (guild system, permissions)
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
Three related but distinct features:
|
||||
|
||||
| Feature | Size | Format | Scope | Storage |
|
||||
|---------|------|--------|-------|---------|
|
||||
| **Custom Emoji** | 128×128 max | PNG, GIF, WebP | Per-guild | Server filesystem |
|
||||
| **Stickers** | 512×512 max | PNG, GIF, WebP, Lottie† | Per-guild + per-user | Server filesystem |
|
||||
| **GIF Picker** | External | GIF (Tenor/Giphy API) | Per-server config | Proxy via server |
|
||||
|
||||
† Lottie — future, not Phase 2.
|
||||
|
||||
### Difference from unicode reactions
|
||||
|
||||
Unicode emoji reactions (`message_reactions` table, Phase 1.3) use single-codepoint emoji (👍, 🔥, 😂).
|
||||
Custom emoji extend this with server-hosted images referenced as `:emoji_name:` in messages.
|
||||
|
||||
---
|
||||
|
||||
## Storage & Schema
|
||||
|
||||
### Filesystem layout
|
||||
|
||||
```
|
||||
data/
|
||||
├── guilds/
|
||||
│ └── {guild_id}/
|
||||
│ ├── emojis/
|
||||
│ │ ├── kappa.png
|
||||
│ │ ├── pogchamp.gif
|
||||
│ │ └── ...
|
||||
│ └── stickers/
|
||||
│ ├── wave.png
|
||||
│ └── ...
|
||||
├── users/
|
||||
│ └── {user_id}/
|
||||
│ └── stickers/ # personal stickers
|
||||
│ └── ...
|
||||
└── emoji_cache/ # client-side cache (per client, not server)
|
||||
```
|
||||
|
||||
### Database tables
|
||||
|
||||
#### guild_emojis
|
||||
|
||||
```sql
|
||||
CREATE TABLE guild_emojis (
|
||||
id TEXT PRIMARY KEY, -- UUID
|
||||
guild_id TEXT NOT NULL, -- FK guilds
|
||||
name TEXT NOT NULL, -- :name: reference (lowercase, alphanumeric + underscore)
|
||||
filename TEXT NOT NULL, -- on-disk filename (id.png)
|
||||
content_type TEXT NOT NULL, -- image/png, image/gif, image/webp
|
||||
file_size INTEGER NOT NULL, -- bytes
|
||||
width INTEGER NOT NULL, -- px
|
||||
height INTEGER NOT NULL, -- px
|
||||
is_animated BOOLEAN NOT NULL, -- true for GIF
|
||||
uploaded_by TEXT NOT NULL, -- FK users
|
||||
created_at TIMESTAMP NOT NULL,
|
||||
UNIQUE(guild_id, name)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_guild_emojis_guild ON guild_emojis(guild_id);
|
||||
```
|
||||
|
||||
#### guild_stickers
|
||||
|
||||
```sql
|
||||
CREATE TABLE guild_stickers (
|
||||
id TEXT PRIMARY KEY,
|
||||
guild_id TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
description TEXT,
|
||||
filename TEXT NOT NULL,
|
||||
content_type TEXT NOT NULL,
|
||||
file_size INTEGER NOT NULL,
|
||||
width INTEGER NOT NULL,
|
||||
height INTEGER NOT NULL,
|
||||
uploaded_by TEXT NOT NULL,
|
||||
created_at TIMESTAMP NOT NULL,
|
||||
UNIQUE(guild_id, name)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_guild_stickers_guild ON guild_stickers(guild_id);
|
||||
```
|
||||
|
||||
#### user_stickers
|
||||
|
||||
```sql
|
||||
CREATE TABLE user_stickers (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
filename TEXT NOT NULL,
|
||||
content_type TEXT NOT NULL,
|
||||
file_size INTEGER NOT NULL,
|
||||
width INTEGER NOT NULL,
|
||||
height INTEGER NOT NULL,
|
||||
created_at TIMESTAMP NOT NULL,
|
||||
UNIQUE(user_id, name)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_user_stickers_user ON user_stickers(user_id);
|
||||
```
|
||||
|
||||
### Limits (configurable in server config)
|
||||
|
||||
```toml
|
||||
[limits.emojis]
|
||||
max_per_guild = 150 # default: 150 static + animated combined
|
||||
max_static_per_guild = 100
|
||||
max_animated_per_guild = 50
|
||||
max_file_size_kb = 256 # per emoji
|
||||
max_dimension = 128 # px, both width and height
|
||||
|
||||
[limits.stickers]
|
||||
max_per_guild = 50
|
||||
max_per_user = 25
|
||||
max_file_size_kb = 512 # per sticker
|
||||
max_dimension = 512
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Protocol
|
||||
|
||||
### New packet types
|
||||
|
||||
```
|
||||
0x0070 EMOJI_SYNC server → client, full emoji list for guild
|
||||
0x0071 EMOJI_ADD client → server, admin upload
|
||||
0x0072 EMOJI_DELETE client → server, admin delete
|
||||
0x0073 EMOJI_UPDATE server → client, delta update (single emoji added/removed)
|
||||
|
||||
0x0074 STICKER_SYNC server → client, full sticker list
|
||||
0x0075 STICKER_SEND client → server, send sticker in channel
|
||||
0x0076 STICKER_UPLOAD client → server, upload sticker
|
||||
0x0077 STICKER_DELETE client → server, delete sticker
|
||||
|
||||
0x0078 EMOJI_DATA client → server, request emoji image bytes
|
||||
0x0079 EMOJI_DATA_RESP server → client, image bytes (lazy-load, not in sync)
|
||||
|
||||
0x0080 GIF_SEARCH client → server, proxy search to Tenor/Giphy
|
||||
0x0081 GIF_TRENDING client → server, trending GIFs
|
||||
```
|
||||
|
||||
### EMOJI_SYNC (0x0070)
|
||||
|
||||
Server sends on guild join. Client caches locally.
|
||||
|
||||
```json
|
||||
{
|
||||
"guild_id": "uuid",
|
||||
"emojis": [
|
||||
{
|
||||
"id": "uuid",
|
||||
"name": "kappa",
|
||||
"content_type": "image/png",
|
||||
"is_animated": false,
|
||||
"width": 128,
|
||||
"height": 128,
|
||||
"file_size": 4096
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Image bytes are NOT included — client lazy-loads via `EMOJI_DATA` + `EMOJI_DATA_RESP`.
|
||||
|
||||
### EMOJI_ADD (0x0071)
|
||||
|
||||
Admin uploads new emoji. Requires `MANAGE_EMOJIS` permission.
|
||||
|
||||
```json
|
||||
{
|
||||
"guild_id": "uuid",
|
||||
"name": "pogchamp",
|
||||
"data": "<base64>"
|
||||
}
|
||||
```
|
||||
|
||||
Server validates:
|
||||
- Name: lowercase alphanumeric + underscore, 2-32 chars
|
||||
- Image: PNG/GIF/WebP, max 256KB, max 128×128
|
||||
- Count: under guild limit
|
||||
|
||||
Response: new emoji object (same shape as in EMOJI_SYNC), server broadcasts `EMOJI_UPDATE` to guild.
|
||||
|
||||
### EMOJI_DELETE (0x0072)
|
||||
|
||||
```json
|
||||
{
|
||||
"guild_id": "uuid",
|
||||
"emoji_id": "uuid"
|
||||
}
|
||||
```
|
||||
|
||||
Server removes file + DB row, broadcasts `EMOJI_UPDATE` with `deleted: true`.
|
||||
|
||||
### EMOJI_DATA / EMOJI_DATA_RESP (0x0078/0x0079)
|
||||
|
||||
Lazy-load pattern — client requests image bytes when first rendering an emoji.
|
||||
|
||||
Request:
|
||||
```json
|
||||
{
|
||||
"emoji_id": "uuid"
|
||||
}
|
||||
```
|
||||
|
||||
Response:
|
||||
```json
|
||||
{
|
||||
"emoji_id": "uuid",
|
||||
"content_type": "image/png",
|
||||
"data": "<base64>"
|
||||
}
|
||||
```
|
||||
|
||||
Client caches image bytes on disk in `emoji_cache/{emoji_id}.{ext}`.
|
||||
|
||||
### STICKER_SEND (0x0075)
|
||||
|
||||
Stickers are sent as a separate message type (not inline). The sticker reference is embedded in a chat message.
|
||||
|
||||
```json
|
||||
{
|
||||
"channel_id": "uuid",
|
||||
"sticker_id": "uuid",
|
||||
"sticker_type": "guild" // "guild" | "user"
|
||||
}
|
||||
```
|
||||
|
||||
Gateway creates a message with `content_type: "sticker"` and `content: {sticker_id, sticker_type}`.
|
||||
|
||||
### GIF_SEARCH (0x0080)
|
||||
|
||||
Client requests GIF search via server (server proxies to Tenor/Giphy so API key stays server-side).
|
||||
|
||||
```json
|
||||
{
|
||||
"query": "cat dancing",
|
||||
"limit": 20,
|
||||
"offset": 0
|
||||
}
|
||||
```
|
||||
|
||||
Response:
|
||||
```json
|
||||
{
|
||||
"results": [
|
||||
{
|
||||
"id": "tenor_12345",
|
||||
"url": "https://media.tenor.com/...",
|
||||
"preview_url": "https://media.tenor.com/.../tiny.gif",
|
||||
"width": 498,
|
||||
"height": 280,
|
||||
"title": "Cat Dancing"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
GIF config in server `config.toml`:
|
||||
|
||||
```toml
|
||||
[gif]
|
||||
enabled = true
|
||||
provider = "tenor" # "tenor" | "giphy"
|
||||
api_key = "TENOR_API_KEY"
|
||||
max_results = 50
|
||||
```
|
||||
|
||||
When `enabled = false` the GIF picker is hidden in all clients.
|
||||
|
||||
---
|
||||
|
||||
## Client Integration
|
||||
|
||||
### Message format with custom emoji
|
||||
|
||||
Messages reference custom emoji as `<:emoji_name:emoji_id>` inline in text content:
|
||||
|
||||
```
|
||||
User: check out this <:kappa:abc123> and <:pog:def456>
|
||||
```
|
||||
|
||||
Client renders by looking up emoji_id in local cache, falling back to lazy-load via `EMOJI_DATA_RESP`.
|
||||
|
||||
Unicode emoji (`👍`) render as-is via system font.
|
||||
|
||||
### Emoji picker
|
||||
|
||||
New widget: `client/src/ui/chat/emoji_picker.rs`
|
||||
|
||||
```
|
||||
┌─────────────────────────────┐
|
||||
│ [Search emoji... 🔍] │
|
||||
│─────────────────────────────│
|
||||
│ Favorites │ Custom │ GIF │ ← tabs
|
||||
│────────────┴─────────┴──────│
|
||||
│ 👍 😂 🔥 💯 🙏 😎 │
|
||||
│ 🎉 ✨ 😭 💀 👀 🫡 │
|
||||
│ 😈 🐱 💪 🚀 🫶 🔒 │
|
||||
│─────────────────────────────│
|
||||
│ ┌───┐ ┌───┐ ┌───┐ ┌───┐ │
|
||||
│ │Kappa│ │Pog │ │Hmm │ │Clap│ │ ← custom (rendered as images)
|
||||
│ └───┘ └───┘ └───┘ └───┘ │
|
||||
└─────────────────────────────┘
|
||||
```
|
||||
|
||||
### Sticker panel
|
||||
|
||||
New widget: `client/src/ui/chat/sticker_panel.rs`
|
||||
|
||||
- Grid of sticker thumbnails
|
||||
- Tabs: Guild stickers / My stickers
|
||||
- Click to send as sticker message (not inline)
|
||||
- Send button shows sticker preview before sending
|
||||
|
||||
### GIF picker
|
||||
|
||||
New widget: `client/src/ui/chat/gif_picker.rs`
|
||||
|
||||
- Search bar at top
|
||||
- Trending grid when empty query
|
||||
- Results grid with animated previews
|
||||
- Click to send GIF as embedded image in chat message
|
||||
|
||||
### Local cache
|
||||
|
||||
```
|
||||
%APPDATA%/vnox/cache/
|
||||
├── emojis/{emoji_id}.{ext} # custom emoji images
|
||||
├── stickers/{sticker_id}.{ext} # sticker images
|
||||
└── gifs/{gif_id}.gif # recently used GIFs (LRU, max 50)
|
||||
```
|
||||
|
||||
Cache invalidation: client re-requests on version mismatch (server increments `emoji_version` per guild).
|
||||
|
||||
---
|
||||
|
||||
## Admin UX
|
||||
|
||||
### Permissions
|
||||
|
||||
| Permission | Bit | Description |
|
||||
| ----------------- | --- | --------------------------- |
|
||||
| MANAGE_EMOJIS | 12 | Upload/delete guild emojis |
|
||||
| MANAGE_STICKERS | 13 | Upload/delete guild stickers|
|
||||
|
||||
These fit into the existing u128 permission bitmask (bits 12-13 are available).
|
||||
|
||||
### Emoji management panel
|
||||
|
||||
Accessible from guild settings (admin only):
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────┐
|
||||
│ Guild Emojis [Upload] │
|
||||
│──────────────────────────────────────────────────│
|
||||
│ ┌──────┐ ┌──────┐ ┌──────┐ ┌──────┐ ┌────────┐│
|
||||
│ │Kappa │ │ Pog │ │ Hmm │ │ Clap │ │ (free) ││
|
||||
│ │ 4KB │ │ 12KB │ │ 8KB │ │ 16KB │ │ ││
|
||||
│ │ [✕] │ │ [✕] │ │ [✕] │ │ [✕] │ │ ││
|
||||
│ └──────┘ └──────┘ └──────┘ └──────┘ └────────┘│
|
||||
│──────────────────────────────────────────────────│
|
||||
│ 4/150 emojis used [Save order]│
|
||||
└──────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
Upload flow:
|
||||
1. Admin clicks Upload → file dialog (PNG/GIF/WebP)
|
||||
2. Client validates: ≤256KB, ≤128×128, valid format
|
||||
3. Client sends `EMOJI_ADD` with base64 data
|
||||
4. Server validates + stores + broadcasts `EMOJI_UPDATE`
|
||||
|
||||
### Sticker management
|
||||
|
||||
Same pattern as emoji management, separate tab in guild settings.
|
||||
|
||||
Personal stickers managed in user settings (no admin required).
|
||||
|
||||
---
|
||||
|
||||
## Federation Considerations (Phase 3)
|
||||
|
||||
### Emoji visibility across nodes
|
||||
|
||||
When two nodes federate:
|
||||
1. Each node maintains its own emoji set
|
||||
2. Guild emoji metadata synced via federation protocol
|
||||
3. Image bytes fetched on-demand (lazy, same as client)
|
||||
4. Reference format includes origin node: `<:emoji_name:emoji_id@node>`
|
||||
|
||||
### Cross-node sticker send
|
||||
|
||||
1. Sticker reference sent as `{sticker_id, origin_node}`
|
||||
2. Receiving node fetches sticker metadata + image from origin node
|
||||
3. Caches locally for subsequent renders
|
||||
|
||||
### GIF proxy
|
||||
|
||||
GIF search always goes through the user's home node.
|
||||
The server proxies to Tenor/Giphy — no federation needed for GIF results (they're just URLs).
|
||||
|
||||
---
|
||||
|
||||
## Migration Path
|
||||
|
||||
### Phase 1.3 — Unicode reactions only
|
||||
- `message_reactions` table (already designed)
|
||||
- Unicode emoji picker in client
|
||||
- No custom emoji, no stickers, no GIF
|
||||
|
||||
### Phase 2 — Custom emoji
|
||||
- Add `guild_emojis` table
|
||||
- Add packet types 0x0070–0x0073, 0x0078–0x0079
|
||||
- Client emoji picker with custom tab
|
||||
- Admin upload/delete UI
|
||||
- No federation support yet
|
||||
|
||||
### Phase 2 — Stickers
|
||||
- Add `guild_stickers` + `user_stickers` tables
|
||||
- Add packet types 0x0074–0x0077
|
||||
- Sticker panel in client
|
||||
- New message content_type: "sticker"
|
||||
|
||||
### Phase 2 — GIF picker
|
||||
- Add Tenor/Giphy config to gateway
|
||||
- Add packet types 0x0080–0x0081
|
||||
- GIF picker widget in client
|
||||
- Server-side proxy (API key protection)
|
||||
|
||||
### Phase 3 — Federation
|
||||
- Cross-node emoji/sticker sync
|
||||
- `@node` suffix in references
|
||||
131
docs/05-features/direct-messages.md
Normal file
131
docs/05-features/direct-messages.md
Normal file
|
|
@ -0,0 +1,131 @@
|
|||
# Direct Messages — Phase 1.1
|
||||
|
||||
## Goal
|
||||
|
||||
Allow users to send private messages to each other outside of channels.
|
||||
DM conversations appear in a dedicated section of the sidebar (like Discord).
|
||||
|
||||
## Wire protocol
|
||||
|
||||
New packet type in LNEx:
|
||||
|
||||
```
|
||||
DM_START → client→gateway: "open DM with user X"
|
||||
DM_MESSAGE → client→gateway: "send message to DM"
|
||||
gateway→client: "new message in DM"
|
||||
DM_HISTORY → client→gateway: "get DM history"
|
||||
gateway→client: DM message list
|
||||
```
|
||||
|
||||
### DM_START payload
|
||||
|
||||
```json
|
||||
{
|
||||
"packet_id": "DM_START",
|
||||
"target_user_id": "<pubkey of recipient>"
|
||||
}
|
||||
```
|
||||
|
||||
Gateway response:
|
||||
|
||||
```json
|
||||
{
|
||||
"packet_id": "DM_START",
|
||||
"dm_id": "dm_<uid1>_<uid2>",
|
||||
"other_user": {
|
||||
"user_id": "<pubkey>",
|
||||
"nickname": "alice"
|
||||
},
|
||||
"messages": []
|
||||
}
|
||||
```
|
||||
|
||||
### DM_MESSAGE payload
|
||||
|
||||
```json
|
||||
{
|
||||
"packet_id": "DM_MESSAGE",
|
||||
"dm_id": "dm_<uid1>_<uid2>",
|
||||
"sender_id": "<pubkey>",
|
||||
"content": "hello!",
|
||||
"timestamp": 1715000000
|
||||
}
|
||||
```
|
||||
|
||||
## DM ID format
|
||||
|
||||
`dm_<user1_hex>_<user2_hex>` where user1 < user2 lexicographically.
|
||||
This ensures the same DM has the same ID on both sides.
|
||||
|
||||
## Database schema
|
||||
|
||||
```sql
|
||||
CREATE TABLE direct_messages (
|
||||
id TEXT PRIMARY KEY, -- "dm_<uid1>_<uid2>"
|
||||
user1_id TEXT NOT NULL, -- lexicographically smaller
|
||||
user2_id TEXT NOT NULL,
|
||||
created_at INTEGER NOT NULL,
|
||||
last_message_at INTEGER,
|
||||
UNIQUE(user1_id, user2_id)
|
||||
);
|
||||
|
||||
CREATE TABLE dm_messages (
|
||||
id TEXT PRIMARY KEY,
|
||||
dm_id TEXT NOT NULL REFERENCES direct_messages(id),
|
||||
sender_id TEXT NOT NULL,
|
||||
body TEXT NOT NULL,
|
||||
created_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX idx_dm_messages_dm_id ON dm_messages(dm_id, created_at);
|
||||
CREATE INDEX idx_dm_participant ON direct_messages(user1_id, user2_id);
|
||||
```
|
||||
|
||||
## Gateway handler
|
||||
|
||||
`gateway/src/handler/direct_message.rs` (new file):
|
||||
|
||||
1. `handle_dm_start(session, target_user)`:
|
||||
- Find or create DM record (lexicographic user ID ordering)
|
||||
- Return DM history
|
||||
|
||||
2. `handle_dm_send(session, dm_id, body)`:
|
||||
- Validate session is participant of this DM
|
||||
- Save to `dm_messages` table
|
||||
- If recipient is connected → forward `DM_MESSAGE` to their session
|
||||
- If recipient is offline → stored for delivery on reconnect
|
||||
|
||||
3. `handle_dm_history(session, dm_id)`:
|
||||
- Return last N messages (N = 50 default)
|
||||
|
||||
## Client UI
|
||||
|
||||
- New section in sidebar: "Direct Messages" with user list
|
||||
- Click a user → opens DM chat panel (same layout as channel chat)
|
||||
- Button on user context menu: "Message"
|
||||
- Unread count badge on DM entries
|
||||
- New messages trigger notification dot
|
||||
|
||||
## Client state
|
||||
|
||||
Add to `client/src/ui/state/types.rs`:
|
||||
|
||||
```rust
|
||||
pub struct DmConversation {
|
||||
pub dm_id: String,
|
||||
pub other_user_id: String,
|
||||
pub other_nickname: String,
|
||||
pub messages: Vec<ChatMessage>,
|
||||
pub unread_count: u32,
|
||||
}
|
||||
|
||||
// Add to UiState:
|
||||
pub dms: Vec<DmConversation>,
|
||||
pub active_dm: Option<String>,
|
||||
```
|
||||
|
||||
## Open questions
|
||||
|
||||
- Should DMs support group conversations (3+ users)? → Phase 2
|
||||
- Should DMs be encrypted (E2EE)? → Phase 2
|
||||
- Should users be able to block DMs from specific users? → Phase 2
|
||||
78
docs/05-features/encryption.md
Normal file
78
docs/05-features/encryption.md
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
# Encryption — Phase 1.1
|
||||
|
||||
> **Phase 1.1 status:** Fully implemented. ChaCha20-Poly1305 AEAD with X25519 ECDH + HKDF-SHA256 key exchange. Both TCP (control) and UDP (voice) paths are now encrypted. Forward secrecy via ephemeral session keys.
|
||||
|
||||
## Goal
|
||||
|
||||
Replace all plaintext traffic (TCP + UDP) with ChaCha20-Poly1305 AEAD encryption.
|
||||
X25519 ECDH ephemeral key exchange during handshake for forward secrecy.
|
||||
|
||||
✅ **COMPLETED in Phase 1.1**
|
||||
|
||||
## Key exchange (during HELLO handshake)
|
||||
|
||||
1. Server generates ephemeral X25519 keypair per session
|
||||
2. Server sends public key in `HELLO` packet
|
||||
3. Client generates own ephemeral X25519 keypair
|
||||
4. Both compute shared secret via X25519 ECDH
|
||||
5. Shared secret → HKDF-SHA256 → two keys:
|
||||
- `client→server` encryption key
|
||||
- `server→client` encryption key
|
||||
6. Ephemeral keys discarded after session (forward secrecy)
|
||||
|
||||
## Packet encryption
|
||||
|
||||
- Algorithm: **ChaCha20-Poly1305** (AEAD)
|
||||
- 96-bit nonce: `session_id || packet_sequence`
|
||||
- 16-byte Poly1305 authentication tag per packet
|
||||
- Applied to ALL packets on both TCP and UDP
|
||||
|
||||
## Why ChaCha20-Poly1305 over AES-GCM
|
||||
|
||||
- Constant-time on all platforms (no hardware AES requirement)
|
||||
- Faster in software on ARM (common for mobile — Phase 3)
|
||||
- Simpler nonce management (no IV collision risk)
|
||||
|
||||
## Implementation plan
|
||||
|
||||
### ✅ Client (DONE)
|
||||
- `client/src/net/crypto.rs` — SessionCrypto with ChaCha20-Poly1305
|
||||
- `client/src/net/voice.rs` — `build_packet()` encrypts voice payloads with c2s_key
|
||||
- `client/src/net/voice.rs` — `spawn_recv()` decrypts incoming voice with s2c_key
|
||||
- `client/src/net/session/session_loop.rs` — voice_seq counter for encryption nonces
|
||||
|
||||
### ✅ Gateway (DONE)
|
||||
- `gateway/src/proto/crypto.rs` — X25519 ECDH, HKDF-SHA256, ChaCha20-Poly1305
|
||||
- `gateway/src/net/handshake.rs` — ephemeral key exchange in HELLO/AUTH
|
||||
- `gateway/src/net/io.rs` — TCP framing with encryption
|
||||
|
||||
### ✅ Voice
|
||||
- `client/src/net/voice.rs` — encrypted voice packets on UDP (plaintext at rest in voice-node, encrypted on wire)
|
||||
- Voice-node treats packets as opaque bytes (transparent relay)
|
||||
- End-to-end encryption: client A → encrypted → voice-node → encrypted → client B
|
||||
|
||||
## Nonce management
|
||||
|
||||
Each session has a monotonic sequence counter:
|
||||
- Start at 0 on session establishment
|
||||
- Increment per packet (both directions independently)
|
||||
- Nonce = `session_id (8 bytes) || sequence (4 bytes)`
|
||||
- 12-byte nonce fits ChaCha20-Poly1305 standard
|
||||
|
||||
## Key dependencies
|
||||
|
||||
Already in `Cargo.toml` (workspace):
|
||||
- `chacha20poly1305 = "0.10"`
|
||||
- `x25519-dalek = { version = "2", features = ["static_secrets"] }`
|
||||
|
||||
Need to add:
|
||||
- `hkdf = "0.12"` for key derivation
|
||||
- `sha2 = "0.10"` (HKDF dependency, likely already transitive)
|
||||
|
||||
## Testing
|
||||
|
||||
- ✅ Unit test: encrypt → decrypt round-trip with known keys (`client/src/net/crypto.rs`)
|
||||
- ✅ Unit test: tampered ciphertext fails authentication
|
||||
- ✅ Unit test: build_packet creates valid encrypted packets
|
||||
- Manual test: Run client + gateway + voice-node, join voice channel, verify packets encrypted
|
||||
- Wireshark: Capture UDP traffic, confirm it's not readable plaintext
|
||||
84
docs/05-features/private-mode.md
Normal file
84
docs/05-features/private-mode.md
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
# Private Mode — Phase 1.1
|
||||
|
||||
## Goal
|
||||
|
||||
Allow running a VNOX server in fully isolated mode.
|
||||
When private, the server does not discover, connect to, or federate with any other node.
|
||||
|
||||
This makes VNOX safe for personal/family use (OwnCord-style),
|
||||
while keeping the option to go federated later.
|
||||
|
||||
## Config
|
||||
|
||||
In `dev/config.toml`:
|
||||
|
||||
```toml
|
||||
[federation]
|
||||
enabled = false # ← PRIVATE: server is isolated
|
||||
# enabled = true # ← FEDERATED: can peer with other nodes
|
||||
```
|
||||
|
||||
Or with explicit mode:
|
||||
|
||||
```toml
|
||||
[server]
|
||||
mode = "private" # isolated, no federation
|
||||
# mode = "federated" # can discover and peer
|
||||
```
|
||||
|
||||
## Implementation
|
||||
|
||||
### Gateway config
|
||||
|
||||
```rust
|
||||
// gateway/src/domain/config.rs
|
||||
pub struct Config {
|
||||
pub server: ServerConfig,
|
||||
pub federation: FederationConfig,
|
||||
}
|
||||
|
||||
pub struct ServerConfig {
|
||||
pub mode: ServerMode, // Private | Federated
|
||||
}
|
||||
|
||||
pub enum ServerMode {
|
||||
Private,
|
||||
Federated,
|
||||
}
|
||||
|
||||
pub struct FederationConfig {
|
||||
pub enabled: bool,
|
||||
pub known_nodes: Vec<String>,
|
||||
}
|
||||
```
|
||||
|
||||
### Behavior when private
|
||||
|
||||
- Gateway does not send any federation packets
|
||||
- Gateway does not accept inbound federation connections
|
||||
- No node discovery (DNS SRV queries skipped)
|
||||
- No federation port needs to be open
|
||||
- All features (channels, voice, DMs) work normally, just local-only
|
||||
|
||||
### Gate check
|
||||
|
||||
```rust
|
||||
// gateway/src/handler/federation.rs
|
||||
pub async fn should_sync_to_remotes(config: &Config) -> bool {
|
||||
config.federation.enabled
|
||||
}
|
||||
|
||||
// All federation entry points start with:
|
||||
if !config.federation.enabled {
|
||||
return Ok(()); // silently skip
|
||||
}
|
||||
```
|
||||
|
||||
## Result
|
||||
|
||||
| Mode | Behaviour |
|
||||
|------|-----------|
|
||||
| `private` | Fully isolated. Like OwnCord. Safe for personal LAN/ home server. |
|
||||
| `federated` | Can discover, peer, and sync with other VNOX nodes. Like Matrix. |
|
||||
|
||||
Default is `private` — opt-in to federation.
|
||||
108
docs/05-features/slint-migration.md
Normal file
108
docs/05-features/slint-migration.md
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
# Slint UI Migration — Phase 1.1
|
||||
|
||||
## Current state
|
||||
|
||||
Desktop client uses **egui** (immediate-mode GUI). Functional but:
|
||||
- Grey, utilitarian aesthetic
|
||||
- Limited design system
|
||||
- Text-heavy, no modern chat app feel
|
||||
- Difficult to style consistently
|
||||
|
||||
## Why Slint
|
||||
|
||||
| Criteria | egui | Slint | Iced |
|
||||
|----------|------|-------|------|
|
||||
| UI language | Rust code | Declarative DSL (`.slint`) | Elm-style |
|
||||
| Design quality | Utilitarian | Design-oriented | Good |
|
||||
| Performance | Fast | Fast | Moderate |
|
||||
| Chat app fit | ❌ feel | ✅ modern | ✅ modern |
|
||||
| Learning curve | Low | Medium | Medium |
|
||||
|
||||
**Recommendation:** Slint — declarative DSL makes UI easier to iterate,
|
||||
built-in design system produces professional look out of the box.
|
||||
|
||||
## Migration strategy
|
||||
|
||||
### Stage 1 — Side-by-side (week 1)
|
||||
- Embed Slint canvas alongside existing egui panels
|
||||
- Rewrite sidebar (server list + channel list) in Slint first
|
||||
- Keep chat area in egui
|
||||
- Verify data flow works between both frameworks
|
||||
|
||||
### Stage 2 — Chat panel (week 2)
|
||||
- Rewrite channel chat (text messages + input bar) in Slint
|
||||
- Rewrite voice panel in Slint
|
||||
- Remove egui chat dependency
|
||||
|
||||
### Stage 3 — Settings & polish (week 3)
|
||||
- Rewrite settings window in Slint
|
||||
- Add dark/light theme toggle
|
||||
- Add accent color picker
|
||||
- Apply consistent spacing/typography
|
||||
|
||||
## Example Slint structure
|
||||
|
||||
```slint
|
||||
// client/src/ui/main.slint
|
||||
export component MainWindow inherits Window {
|
||||
in-out property <[Channel]> channels;
|
||||
in-out property <[Message]> messages;
|
||||
in-out property <string> active-channel;
|
||||
|
||||
HorizontalLayout {
|
||||
ServerSidebar {}
|
||||
ChatPanel {}
|
||||
VoicePanel {}
|
||||
}
|
||||
}
|
||||
|
||||
component ServerSidebar {
|
||||
VerticalLayout {
|
||||
Text { text: "VNOX"; }
|
||||
ListView {
|
||||
for ch in channels: ChannelRow {
|
||||
text: ch.name;
|
||||
icon: ch.kind == "voice" ? "~" : "#";
|
||||
}
|
||||
}
|
||||
UserBar {}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Data binding
|
||||
|
||||
Use Slint's `in-out property` bindings to sync with Rust state:
|
||||
|
||||
```rust
|
||||
// client/src/ui/app.rs
|
||||
use slint::ComponentHandle;
|
||||
|
||||
let ui = MainWindow::new()?;
|
||||
ui.set_channels(slint::ModelRc::from(models));
|
||||
ui.on_send_message(|content| {
|
||||
// forward to net layer
|
||||
});
|
||||
ui.run()?;
|
||||
```
|
||||
|
||||
## File structure after migration
|
||||
|
||||
```
|
||||
client/src/ui/
|
||||
├── app.rs # entry, wiring
|
||||
├── main.slint # main layout
|
||||
├── sidebar.slint # server sidebar + channel list
|
||||
├── chat.slint # message list + input bar
|
||||
├── voice.slint # voice panel
|
||||
├── settings.slint # settings window
|
||||
├── theme.slint # color/font definitions
|
||||
└── state/ # UiState (stays in Rust)
|
||||
```
|
||||
|
||||
## Open questions
|
||||
|
||||
- Slint's rendering backend (gl/winit/software) — test on all target platforms
|
||||
- Slint's text input / rich text support for chat messages (emoji, markdown?)
|
||||
- Whether to remove egui entirely or keep for specific panels (e.g. overlay)
|
||||
- Licensing: Slint is LGPL (compatible with GPL-3.0 for now, but check for future)
|
||||
453
docs/05-features/video-screen-audit.md
Normal file
453
docs/05-features/video-screen-audit.md
Normal file
|
|
@ -0,0 +1,453 @@
|
|||
# Video, Camera & Screen Share — Design Audit
|
||||
|
||||
> Audits `docs/05-features/video.md` against the current codebase and roadmap.
|
||||
> Each gap has: problem, recommendation, priority, target file.
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
`video.md` provides a solid high-level sketch (video-node, H.264 UDP relay, nokhwa capture, grid UI).
|
||||
But critical details are missing for actual implementation. This audit fills those gaps.
|
||||
|
||||
---
|
||||
|
||||
## 1. Video-node — Integration with existing architecture
|
||||
|
||||
### Gap 1.1 — Video-node lifecycle
|
||||
|
||||
**Problem:** `video.md` says "separate binary or embedded in voice-node" but doesn't decide. Voice-node has just been refactored with jitter buffer — video needs the same relay pattern.
|
||||
|
||||
**Recommendation:** Separate binary (`vnox-video-node`). Video frames are fundamentally different from voice packets (keyframes, fragmentation, higher bandwidth). Keeping them separate avoids coupling and allows independent scaling.
|
||||
|
||||
**Priority:** P0 — must decide before writing a line of code.
|
||||
|
||||
**Target:** `video.md` — add "Deployment" section.
|
||||
|
||||
### Gap 1.2 — Channel membership signalling
|
||||
|
||||
**Problem:** Video-node needs to know which users are in which channel. Currently this is managed by gateway. How does video-node learn membership?
|
||||
|
||||
**Recommendation:** Gateway sends `VIDEO_SESSION_JOIN` / `VIDEO_SESSION_LEAVE` events to video-node over TCP (internal control channel), same pattern as planned for gateway→voice-node membership signalling (Phase 2 in roadmap).
|
||||
|
||||
```json
|
||||
// Gateway → Video-node (internal TCP)
|
||||
{
|
||||
"event": "VIDEO_SESSION_JOIN",
|
||||
"channel_id": 12345,
|
||||
"user_id": "<pubkey>",
|
||||
"socket_addr": "192.168.1.5:7701"
|
||||
}
|
||||
```
|
||||
|
||||
**Priority:** P0 — video relay can't work without knowing recipients.
|
||||
|
||||
**Target:** `video.md` — add "Gateway integration" section.
|
||||
|
||||
### Gap 1.3 — No encryption spec
|
||||
|
||||
**Problem:** Voice packets are encrypted with ChaCha20-Poly1305. Video packets are not addressed — are they encrypted? With what keys?
|
||||
|
||||
**Recommendation:** Same ChaCha20-Poly1305 AEAD as voice. Each video session gets ephemeral keys derived via the same X25519 ECDH + HKDF-SHA256 path used for voice. Video-node does NOT decrypt (transparent relay, same as voice-node).
|
||||
|
||||
The session key exchange happens during `HELLO` handshake — add a `video_key` field alongside the existing `voice_key`.
|
||||
|
||||
**Priority:** P0 — security regression if video is plaintext.
|
||||
|
||||
**Target:** `video.md` — add "Encryption" section; `docs/02-protocol/security.md` — add video encryption note.
|
||||
|
||||
---
|
||||
|
||||
## 2. Camera Capture — Missing details
|
||||
|
||||
### Gap 2.1 — nokhwa API surface and fallback
|
||||
|
||||
**Problem:** `video.md` mentions `nokhwa` but not the concrete API or what happens on platforms where it doesn't work (Linux v4l2 permissions, Wayland).
|
||||
|
||||
**Recommendation:** Abstract behind a `VideoSource` trait:
|
||||
|
||||
```rust
|
||||
// client/src/video/capture.rs
|
||||
pub trait VideoSource {
|
||||
fn devices() -> Vec<DeviceInfo>;
|
||||
fn start(config: StreamConfig) -> Result<FrameStream>;
|
||||
}
|
||||
|
||||
struct NokhwaSource { ... }
|
||||
struct StubSource { ... } // fallback
|
||||
|
||||
pub struct StreamConfig {
|
||||
pub device_id: String,
|
||||
pub resolution: Resolution, // 640x480, 1280x720, 1920x1080
|
||||
pub fps: u32, // 15, 30
|
||||
pub format: PixelFormat, // NV12, I420, BGRA
|
||||
}
|
||||
```
|
||||
|
||||
Make `nokhwa` a feature gate (same pattern as `rnnoise` for voice).
|
||||
|
||||
**Priority:** P0 — blocks camera implementation.
|
||||
|
||||
**Target:** New file: `client/src/video/capture.rs` (spec in `video.md`).
|
||||
|
||||
### Gap 2.2 — Device hotplug / switch
|
||||
|
||||
**Problem:** No mention of what happens when camera is unplugged/replugged or user switches camera mid-call.
|
||||
|
||||
**Recommendation:** `VideoSource` emits events:
|
||||
```rust
|
||||
enum CaptureEvent {
|
||||
Frame(VideoFrame),
|
||||
DeviceLost(String),
|
||||
DeviceReconnected(String),
|
||||
Error(String),
|
||||
}
|
||||
```
|
||||
|
||||
Client handles `DeviceLost` by showing placeholder tile. User can select new device in settings without leaving call.
|
||||
|
||||
**Priority:** P1 — quality of life, not MVP blocker.
|
||||
|
||||
**Target:** `video.md` — add "Device lifecycle" to capture section.
|
||||
|
||||
### Gap 2.3 — Resolution / FPS negotiation
|
||||
|
||||
**Problem:** `video.md` says "720p default, 30 fps" but doesn't say how clients agree on parameters. Different clients have different cameras.
|
||||
|
||||
**Recommendation:** Server advertises max resolution per channel. Client sends its capability in `VIDEO_SESSION_JOIN`. Server picks min(max_server, max_client) per sender.
|
||||
|
||||
```toml
|
||||
# server config
|
||||
[video.channel_defaults]
|
||||
max_resolution = "1280x720"
|
||||
max_fps = 30
|
||||
max_bitrate_kbps = 2500
|
||||
```
|
||||
|
||||
**Priority:** P1 — needed before multi-user video testing.
|
||||
|
||||
**Target:** `video.md` — add "Capability negotiation" section.
|
||||
|
||||
---
|
||||
|
||||
## 3. Screen Share — Almost entirely missing
|
||||
|
||||
### Gap 3.1 — Screen capture crate
|
||||
|
||||
**Problem:** `video.md` mentions "Screen sharing (desktop capture)" as one bullet in Phase 2, no detail.
|
||||
|
||||
**Recommendation:**
|
||||
|
||||
| Platform | Crate | Notes |
|
||||
|----------|-------|-------|
|
||||
| Windows | `windows-capture` or DXGI via `winapi` | DXGI Desktop Duplication API — best perf |
|
||||
| Linux X11 | `x11cap` or raw XSHM | X11 only, no Wayland |
|
||||
| Linux Wayland | `pipewire` via `pw-video` or xdg-desktop-portal | Portal is the standard path |
|
||||
| macOS | `screencapturekit` via objc bindings or `CGDisplay` | ScreenCaptureKit requires macOS 13+ |
|
||||
|
||||
Abstract behind same `VideoSource` trait as camera. Two implementations: `CameraSource`, `ScreenSource`.
|
||||
|
||||
**Priority:** P1 — needed for Phase 2 screen share.
|
||||
|
||||
**Target:** New doc: `docs/05-features/screen-share.md` or extend `video.md` Phase 2.
|
||||
|
||||
### Gap 3.2 — Window/display selection UI
|
||||
|
||||
**Problem:** User needs to pick which screen or window to share. No UI spec.
|
||||
|
||||
**Recommendation:** Modal dialog when user clicks "Screen Share":
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────┐
|
||||
│ Share Your Screen │
|
||||
│─────────────────────────────────────────│
|
||||
│ [Screens] [Windows] │ ← tabs
|
||||
│─────────────────────────────────────────│
|
||||
│ ┌──────────┐ ┌──────────┐ │
|
||||
│ │ Display 1│ │ Display 2│ │
|
||||
│ │1920×1080 │ │2560×1440 │ │
|
||||
│ │ [✓] │ │ │ │
|
||||
│ └──────────┘ └──────────┘ │
|
||||
│─────────────────────────────────────────│
|
||||
│ ☐ Share system audio │
|
||||
│ ☐ Optimize for video (60fps) │
|
||||
│─────────────────────────────────────────│
|
||||
│ [Cancel] [Share] │
|
||||
└─────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**Priority:** P1 — UX blocker for screen share.
|
||||
|
||||
**Target:** `video.md` — add "Screen share UI" section.
|
||||
|
||||
### Gap 3.3 — System audio capture with screen
|
||||
|
||||
**Problem:** "Share system audio" checkbox in the mockup — how? No mention in video.md.
|
||||
|
||||
**Recommendation:**
|
||||
- Windows: WASAPI loopback (`cpal` supports it with `loopback` config)
|
||||
- Linux: PulseAudio monitor or PipeWire
|
||||
- macOS: BlackHole or ScreenCaptureKit (built-in on 13+)
|
||||
|
||||
Feature-gate it: `--features screen-audio`. Not all platforms support it cleanly.
|
||||
|
||||
**Priority:** P2 — nice to have, not MVP.
|
||||
|
||||
**Target:** `video.md` Phase 2 — add "System audio" bullet.
|
||||
|
||||
### Gap 3.4 — Screen share FPS strategy
|
||||
|
||||
**Problem:** Screen share has different FPS needs than camera. Coding: 15fps is fine. Gaming: 60fps needed.
|
||||
|
||||
**Recommendation:** Client detects content type (static → low FPS, motion → high FPS) and adapts. User can override with "Optimize for video" checkbox.
|
||||
|
||||
Default: 15fps for screen share, 30fps for camera.
|
||||
|
||||
**Priority:** P2 — optimization, not MVP.
|
||||
|
||||
**Target:** `video.md` — add to Phase 2.
|
||||
|
||||
---
|
||||
|
||||
## 4. Interaction Model — Video + Voice
|
||||
|
||||
### Gap 4.1 — Video tied to voice channel
|
||||
|
||||
**Problem:** `video.md` implies video is in the same channel as voice. What if users want video-only (no voice) or voice-only (no video)?
|
||||
|
||||
**Recommendation:** Video is a capability toggle within a voice channel, not a separate channel type:
|
||||
|
||||
1. User joins voice channel (as today)
|
||||
2. User clicks "Enable Camera" → client starts sending video frames to video-node
|
||||
3. User clicks "Disable Camera" → stops sending, voice continues
|
||||
4. Same for "Share Screen"
|
||||
|
||||
Channel membership = voice channel membership. Video is additive.
|
||||
|
||||
New voice state: `VOICE_STATE` gets a `video: bool` field.
|
||||
|
||||
**Priority:** P0 — architectural decision, affects protocol design.
|
||||
|
||||
**Target:** `video.md` — add "Interaction model" section; `docs/02-protocol/packets.md` — extend VOICE_STATE.
|
||||
|
||||
### Gap 4.2 — Video without voice
|
||||
|
||||
**Problem:** Some users may want to watch a stream without transmitting audio. Current model requires voice channel join.
|
||||
|
||||
**Recommendation:** Phase 1: allow joining voice channel muted+deafened to watch video. Phase 3: separate "watch-only" mode (VIEW permission).
|
||||
|
||||
**Priority:** P2 — Phase 1 workaround is acceptable.
|
||||
|
||||
**Target:** `video.md` — add "Watch-only mode" to Phase 3.
|
||||
|
||||
---
|
||||
|
||||
## 5. Performance & Quality
|
||||
|
||||
### Gap 5.1 — Bitrate adaptation
|
||||
|
||||
**Problem:** No mechanism to adjust video bitrate based on network conditions. Voice has jitter buffer adaptation, video has nothing.
|
||||
|
||||
**Recommendation:** Client-side adaptive bitrate (ABR):
|
||||
- Measure packet loss and RTT from video-node ACKs
|
||||
- Adjust encoder bitrate up/down based on available bandwidth
|
||||
- 3 tiers: low (500kbps), medium (1500kbps), high (4000kbps)
|
||||
|
||||
Video-node sends periodic `VIDEO_STATS` with per-receiver loss rates:
|
||||
|
||||
```json
|
||||
{
|
||||
"channel_id": 12345,
|
||||
"receivers": {
|
||||
"user_a": { "loss_pct": 0.5, "rtt_ms": 12 },
|
||||
"user_b": { "loss_pct": 8.0, "rtt_ms": 150 }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Priority:** P1 — needed for real-world use.
|
||||
|
||||
**Target:** `video.md` — add "Adaptive bitrate" section.
|
||||
|
||||
### Gap 5.2 — Simulcast design
|
||||
|
||||
**Problem:** "Simulcast" is listed as Phase 3 with no design. Different receivers have different bandwidth.
|
||||
|
||||
**Recommendation:** Sender encodes 2-3 quality layers. Video-node forwards appropriate layer to each receiver based on their reported bandwidth.
|
||||
|
||||
```
|
||||
Sender → encode: 1080p (4Mbps) + 720p (1.5Mbps) + 360p (500kbps)
|
||||
│
|
||||
Video-node
|
||||
╱ │ ╲
|
||||
1080p→A 720p→B 360p→C (based on per-receiver stats)
|
||||
```
|
||||
|
||||
Requires VP9 SVC or H.264 simulcast. Complex — Phase 3 is correct.
|
||||
|
||||
**Priority:** P2 — Phase 3, but design it now to avoid architecture lock-in.
|
||||
|
||||
**Target:** `video.md` Phase 3 — expand "Simulcast" bullet.
|
||||
|
||||
### Gap 5.3 — Keyframe interval
|
||||
|
||||
**Problem:** New viewers joining mid-stream need a keyframe to start decoding. Video.md doesn't mention keyframe strategy.
|
||||
|
||||
**Recommendation:**
|
||||
- Sender inserts keyframe every 2 seconds (configurable)
|
||||
- Video-node caches last keyframe per sender
|
||||
- On new viewer join: video-node sends cached keyframe immediately, then regular frames
|
||||
- `VIDEO_SESSION_JOIN` triggers keyframe push
|
||||
|
||||
Add `REQUEST_KEYFRAME` packet for explicit PLI (Picture Loss Indication).
|
||||
|
||||
**Priority:** P1 — new viewers can't see video without keyframe.
|
||||
|
||||
**Target:** `video.md` — add "Keyframe handling" section.
|
||||
|
||||
---
|
||||
|
||||
## 6. Missing Protocol Details
|
||||
|
||||
### Gap 6.1 — No packet fragmentation spec
|
||||
|
||||
**Problem:** Video frames can be 50-100KB. UDP MTU is ~1400 bytes. Video.md says "Packetize into MTU-friendly chunks" but doesn't define the fragmentation protocol.
|
||||
|
||||
**Recommendation:** Reuse the LNEx fragmentation flags from the base packet header (bits 2-3):
|
||||
|
||||
```
|
||||
FRAGMENTED (bit 2) + LAST_FRAG (bit 3)
|
||||
```
|
||||
|
||||
Each video frame:
|
||||
1. Split into 1400-byte chunks
|
||||
2. Each chunk gets same `frame_seq`, incrementing `fragment_seq`
|
||||
3. Last chunk sets `LAST_FRAG` flag
|
||||
4. Receiver reassembles before decode
|
||||
|
||||
New packet type: `VIDEO_FRAME_FRAGMENT (0x0012)` — separate from unfragmented `VIDEO_FRAME (0x0011)`.
|
||||
|
||||
Wait — `VIDEO_FRAME` ID isn't assigned in the packet registry yet. Register:
|
||||
|
||||
```
|
||||
0x0011 VIDEO_FRAME single (small) video frame, no fragmentation
|
||||
0x0012 VIDEO_FRAME_FRAG fragment of a larger video frame
|
||||
```
|
||||
|
||||
**Priority:** P0 — video can't work over UDP without fragmentation.
|
||||
|
||||
**Target:** `video.md` — replace packet format section; `docs/02-protocol/packets.md` — add 0x0011/0x0012 to registry.
|
||||
|
||||
### Gap 6.2 — No RTCP-like receiver reports
|
||||
|
||||
**Problem:** Sender has no feedback about what receivers are experiencing. Voice has implicit feedback (jitter buffer stats), video needs explicit.
|
||||
|
||||
**Recommendation:** Minimal receiver report packet:
|
||||
|
||||
```json
|
||||
// 0x0013 VIDEO_RECEIVER_REPORT — client → video-node → sender
|
||||
{
|
||||
"frame_seq": 1042,
|
||||
"loss_cumulative": 15,
|
||||
"loss_fraction": 2, // percent of last N packets
|
||||
"jitter_ms": 8,
|
||||
"rtt_ms": 35
|
||||
}
|
||||
```
|
||||
|
||||
Client sends every 1 second. Video-node aggregates and forwards to sender.
|
||||
|
||||
**Priority:** P1 — needed for ABR and quality adaptation.
|
||||
|
||||
**Target:** `video.md` — add "Receiver reports" section.
|
||||
|
||||
---
|
||||
|
||||
## 7. Guild Integration (Phase 1.2)
|
||||
|
||||
### Gap 7.1 — Video permissions already defined
|
||||
|
||||
**Status:** ✅ `STREAM` (bit 18) permission is already in the community model (`docs/07-community-model.md`). Covers both camera and screen share.
|
||||
|
||||
No gap here — just implement the check in gateway when processing `VIDEO_SESSION_JOIN`.
|
||||
|
||||
### Gap 7.2 — Channel-level video settings
|
||||
|
||||
**Problem:** Guild admins may want to disable video in certain channels (text-only channels, AFK channel).
|
||||
|
||||
**Recommendation:** Add `video_allowed: bool` to channel config. Default: `true` for voice channels, `false` for text channels.
|
||||
|
||||
```sql
|
||||
ALTER TABLE channels ADD COLUMN video_allowed BOOLEAN NOT NULL DEFAULT 1;
|
||||
```
|
||||
|
||||
Gateway rejects `VIDEO_SESSION_JOIN` if `video_allowed = false`.
|
||||
|
||||
**Priority:** P1 — admin control expected by guild owners.
|
||||
|
||||
**Target:** `docs/10-database.md` — add column to channels table; `docs/07-community-model.md` — add `video_allowed` to channel settings.
|
||||
|
||||
---
|
||||
|
||||
## 8. Mobile Considerations (Phase 3)
|
||||
|
||||
### Gap 8.1 — Front/back camera switch
|
||||
|
||||
**Problem:** Mobile video.md doesn't mention camera switching at all.
|
||||
|
||||
**Recommendation:** Mobile client sends `CAMERA_SWITCH` event to video-node — no protocol change needed, just local capture change. New frame stream with `camera: front|back` metadata.
|
||||
|
||||
**Priority:** P2 — Phase 3.
|
||||
|
||||
**Target:** `docs/04-clients/mobile.md` (doesn't exist yet — create in Phase 3).
|
||||
|
||||
### Gap 8.2 — Self preview
|
||||
|
||||
**Problem:** Desktop video grid has "Self-view (small, picture-in-picture corner)". On mobile the self-view is more important (front camera framing).
|
||||
|
||||
**Recommendation:** Mobile layout: self-view full-width at top, other participants in scrollable grid below. Toggle button to swap.
|
||||
|
||||
**Priority:** P2 — Phase 3.
|
||||
|
||||
**Target:** `docs/04-clients/mobile.md`.
|
||||
|
||||
---
|
||||
|
||||
## Gap Summary — What to Add Before Implementation
|
||||
|
||||
| # | Gap | Priority | Target file |
|
||||
|---|-----|----------|-------------|
|
||||
| 1 | Video-node binary decision + deployment | P0 | `video.md` |
|
||||
| 2 | Gateway membership signalling to video-node | P0 | `video.md` |
|
||||
| 3 | Video encryption (ChaCha20-Poly1305) | P0 | `video.md`, `security.md` |
|
||||
| 4 | Video frame fragmentation protocol | P0 | `video.md`, `packets.md` |
|
||||
| 5 | Video ↔ voice interaction model | P0 | `video.md`, `packets.md` |
|
||||
| 6 | VideoSource trait + feature-gated nokhwa | P0 | `video.md` |
|
||||
| 7 | Keyframe caching + PLI request | P1 | `video.md` |
|
||||
| 8 | Receiver reports (RTCP-like) | P1 | `video.md` |
|
||||
| 9 | Adaptive bitrate design | P1 | `video.md` |
|
||||
| 10 | Screen share crate selection + ScreenSource | P1 | `video.md` |
|
||||
| 11 | Window/display selection UI | P1 | `video.md` |
|
||||
| 12 | Resolution/FPS negotiation | P1 | `video.md` |
|
||||
| 13 | Channel-level video_allowed setting | P1 | `database.md`, `community-model.md` |
|
||||
| 14 | Camera hotplug/switch handling | P1 | `video.md` |
|
||||
| 15 | Video-only (watch) mode | P2 | `video.md` |
|
||||
| 16 | System audio with screen share | P2 | `video.md` |
|
||||
| 17 | Screen share FPS strategy | P2 | `video.md` |
|
||||
| 18 | Simulcast design | P2 | `video.md` |
|
||||
| 19 | Mobile front/back camera + self preview | P2 | `mobile.md` |
|
||||
|
||||
---
|
||||
|
||||
## Recommended Implementation Order
|
||||
|
||||
1. **Update `video.md`** — incorporate P0 and P1 gaps from this audit into the design doc
|
||||
2. **Register packet types** — 0x0011–0x0013 in `packets.md`
|
||||
3. **Video-node prototype** — separate binary, UDP relay, no encoding/decoding (transparent relay like voice)
|
||||
4. **Gateway ↔ video-node signalling** — internal TCP control channel for membership
|
||||
5. **Client camera capture** — `VideoSource` trait + `NokhwaSource` behind feature gate
|
||||
6. **Fragmentation** — implement FRAGMENTED/LAST_FRAG for video frames
|
||||
7. **Grid UI** — basic 2×2 grid in client
|
||||
8. **Encryption** — ChaCha20-Poly1305 for video frames
|
||||
9. **ABR + receiver reports** — iterate toward production quality
|
||||
10. **Screen share** — `ScreenSource` implementation + picker UI
|
||||
99
docs/05-features/video.md
Normal file
99
docs/05-features/video.md
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
# Video Chat — Architecture
|
||||
|
||||
## Goal
|
||||
|
||||
Add real-time video to voice channels.
|
||||
Users can share their webcam feed alongside voice.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
Current (voice only):
|
||||
├─ Gateway: TCP (signaling, chat)
|
||||
├─ Voice-node: UDP (Opus voice relay)
|
||||
└─ Client: mic → Opus → UDP → voice-node → UDP → client → playback
|
||||
|
||||
With video:
|
||||
├─ Gateway: TCP (signaling) — UNCHANGED
|
||||
├─ Voice-node: UDP (Opus voice) — UNCHANGED
|
||||
├─ Video-node: UDP (H.264/VP9 relay) — NEW
|
||||
│ └─ Receives encoded frames, relays to channel members
|
||||
└─ Client: mic → Opus → UDP → voice-node
|
||||
webcam → H.264 → UDP → video-node ← UDP → decode → display grid
|
||||
```
|
||||
|
||||
## Video-node
|
||||
|
||||
New optional component, separate binary or embedded in voice-node.
|
||||
|
||||
### Responsibilities
|
||||
- Receive encoded video frames over UDP
|
||||
- Relay to all other clients in the same voice channel
|
||||
- No transcoding (relay only — CPU efficient)
|
||||
- Max resolution / bitrate per channel configurable
|
||||
|
||||
### Packet format
|
||||
|
||||
```json
|
||||
{
|
||||
"packet_id": "VIDEO_FRAME",
|
||||
"channel_id": 12345,
|
||||
"sender_id": "<pubkey>",
|
||||
"frame_seq": 42,
|
||||
"codec": "h264", // or "vp9"
|
||||
"keyframe": false,
|
||||
"data": "<base64 encoded frame>"
|
||||
}
|
||||
```
|
||||
|
||||
Initially JSON (matching Phase 1 convention), binary framing in Phase 2.
|
||||
|
||||
## Client capture pipeline
|
||||
|
||||
New file: `client/src/video/capture.rs`
|
||||
|
||||
1. Enumerate webcam devices via `nokhwa` or `video4linux`
|
||||
2. Capture frames at configurable resolution (720p default)
|
||||
3. Encode to H.264 via `ffmpeg-next` or hardware encoder
|
||||
4. Packetize into MTU-friendly chunks
|
||||
5. Send over UDP to video-node
|
||||
|
||||
### Dependencies
|
||||
|
||||
- `nokhwa` — cross-platform camera capture (Rust)
|
||||
- `ffmpeg-next` or `rav1e` — H.264/VP9 encoding
|
||||
|
||||
## Client UI — Video grid
|
||||
|
||||
New component: `client/src/ui/video.rs`
|
||||
|
||||
- Grid layout (max 4×4 = 16 participants visible)
|
||||
- Active speaker highlight (green border)
|
||||
- Self-view (small, picture-in-picture corner)
|
||||
- Mute video button per participant
|
||||
- Resolution/quality indicator per stream
|
||||
|
||||
Layout modes:
|
||||
- 1 participant → full width
|
||||
- 2-4 → 2×2 grid
|
||||
- 5-9 → 3×3 grid
|
||||
- 10-16 → 4×4 grid with scrolling
|
||||
|
||||
## Implementation phases
|
||||
|
||||
### Phase 1 (MVP)
|
||||
- H.264 encoding with `nokhwa` + `ffmpeg-next`
|
||||
- Single video-node binary
|
||||
- 2×2 grid in UI
|
||||
- 720p max resolution, 30 fps
|
||||
|
||||
### Phase 2
|
||||
- VP9 support (better quality/bitrate)
|
||||
- Adaptive resolution (auto downscale on packet loss)
|
||||
- Picture-in-picture self-view
|
||||
- Screen sharing (desktop capture)
|
||||
|
||||
### Phase 3
|
||||
- Hardware encoding (NVENC/VAAPI)
|
||||
- Simulcast (different resolution per stream)
|
||||
- Recording support
|
||||
214
docs/06-roadmap.md
Normal file
214
docs/06-roadmap.md
Normal file
|
|
@ -0,0 +1,214 @@
|
|||
# Roadmap
|
||||
|
||||
---
|
||||
|
||||
## Phase 1 — MVP
|
||||
|
||||
Goal: a working voice + chat system you can actually use.
|
||||
|
||||
### Server
|
||||
- [x] LNEx v1 protocol spec
|
||||
- [x] Gateway: auth, sessions, channels, text chat
|
||||
- [x] Voice node: UDP relay (jitter buffer code exists, not wired into relay yet)
|
||||
- [x] SQLite storage: message history, user records
|
||||
- [x] Docker + systemd deployment ([Dockerfile](../Dockerfile), [docker-compose.yml](../docker-compose.yml))
|
||||
- [x] config.toml reference ([configuration.md](../03-server/configuration.md), [dev/README.md](../../dev/README.md))
|
||||
|
||||
### Client
|
||||
- [x] Identity: keypair generation, Ed25519 auth
|
||||
- [x] Node switcher — bookmarks with save/remove
|
||||
- [x] Channel list: text + voice
|
||||
- [x] Text chat
|
||||
- [x] Voice chat: push-to-talk, VAD, always-on modes
|
||||
- [x] Noise suppression (RNNoise, feature-gated)
|
||||
- [x] Settings: voice, audio, network, identity, keybinds
|
||||
- [x] Latency indicator
|
||||
|
||||
### Protocol
|
||||
- [x] Packet format finalized (JSON framing in Phase 1)
|
||||
- [x] Error codes documented
|
||||
|
||||
---
|
||||
|
||||
## Phase 1.1 — Hardening & Core Features
|
||||
|
||||
Goal: production-safe for small private communities.
|
||||
|
||||
### Encryption (Priority 1) ✅ DONE
|
||||
- [x] Replace plaintext with ChaCha20-Poly1305 on all packets
|
||||
- [x] X25519 ECDH key exchange during HELLO handshake
|
||||
- [x] HKDF-SHA256 key derivation (client→server / server→client keys)
|
||||
- [x] Encrypted TCP (control) + UDP (voice) paths
|
||||
- [x] Forward secrecy via ephemeral session keys
|
||||
- [x] Session nonce management for replay protection
|
||||
|
||||
All traffic is now encrypted. See [features/encryption.md](05-features/encryption.md).
|
||||
|
||||
### Direct Messages ✅ DONE
|
||||
- [x] New packet type: `DM_MESSAGE` (separate from `CHAT_MESSAGE`)
|
||||
- [x] Gateway routing: create private DM channel on first message
|
||||
- [x] DB schema: `direct_messages` + `dm_messages` tables
|
||||
- [x] UI: DM list in sidebar (like Discord)
|
||||
- [x] Unread count tracking
|
||||
- [x] Message sync to both participants
|
||||
- [x] DM history search bar
|
||||
|
||||
See [features/direct-messages.md](05-features/direct-messages.md). **Backend and UI complete.**
|
||||
|
||||
### Private Mode ✅ DONE
|
||||
- [x] Config flag: `[federation] enabled = false` / `[server] mode = "private"`
|
||||
- [x] Server cannot see or reach other servers when private
|
||||
- [x] No federation packets sent
|
||||
- [x] Makes VNOX safe for single-server deployment (OwnCord-style)
|
||||
|
||||
### UI Refresh
|
||||
- [x] Evaluate Slint vs improving egui design system — **staying on egui**
|
||||
- [x] Custom dark theme with accent color support (#E67E22 orange)
|
||||
- [x] Redesigned channel list and user bar
|
||||
- [x] Consistency pass across all settings panels
|
||||
- [ ] Slint migration plan exists at `docs/superpowers/plans/2026-05-31-slint-migration.md` (deferred to Phase 2+)
|
||||
|
||||
### Client Polish
|
||||
- [x] Audio device labels in settings (live cpal enumeration)
|
||||
- [x] Opus bitrate slider (8–128 kbps)
|
||||
- [x] Jitter buffer size and adaptive mode
|
||||
- [x] Per-user volume control (client-side mix)
|
||||
- [x] Keybind recording UI (capture keystroke, not type string)
|
||||
|
||||
---
|
||||
|
||||
## Phase 1.2 — Community Foundation
|
||||
|
||||
Goal: guilds, roles, permissions, presence layer.
|
||||
|
||||
### Community Model (Priority 1) ✅ DONE
|
||||
- [x] Guild system: create, list, delete, settings
|
||||
- [x] Role system with permission bits (u64)
|
||||
- [x] Permission resolution (owner → admin → roles → channel overrides)
|
||||
- [x] Invite system: permanent and temporary
|
||||
- [x] Guild member management: join, leave, kick
|
||||
- [x] Audit log for admin actions
|
||||
- [x] Invite accept popup dialog
|
||||
- [x] Guild header with member count and Leave button
|
||||
|
||||
### Direct Messages UI ✅ DONE
|
||||
- [x] DM list in sidebar (per-user, not per-guild)
|
||||
- [x] DM conversation panel (similar to channel chat)
|
||||
- [x] Unread badges and notification dots
|
||||
- [x] Search DM history
|
||||
- [x] Per-conversation volume controls
|
||||
|
||||
### Presence System ✅ DONE
|
||||
- [x] Status types: ONLINE, IDLE, DO_NOT_DISTURB, OFFLINE, INVISIBLE
|
||||
- [ ] Activity status: playing, listening, watching, streaming — **deferred**
|
||||
- [ ] Custom status text — **deferred**
|
||||
- [x] Broadcast on login/logout
|
||||
- [x] Clickable status cycler in user bar
|
||||
|
||||
### Friends System ✅ DONE
|
||||
- [x] Friend requests
|
||||
- [x] Friends list with Online / All / Pending / Blocked tabs
|
||||
- [x] Pending count badge on Friends tab
|
||||
- [x] Block list (placeholder UI)
|
||||
- [x] Friend notifications (incoming request badge)
|
||||
- [x] Add Friend popup with user-id input
|
||||
- [x] Per-friend DM shortcut and remove button
|
||||
|
||||
### UI Refresh ✅ DONE
|
||||
- [x] Sidebar restructure for guilds + DMs
|
||||
- [x] Guild switcher (vertical icons on left)
|
||||
- [x] Role color display in user mentions
|
||||
- [x] Category collapsing
|
||||
- [x] Right-hand member list panel (online/offline per channel)
|
||||
|
||||
---
|
||||
|
||||
## Phase 1.3 — Advanced Features
|
||||
|
||||
Goal: typing indicators, read receipts, typing states.
|
||||
|
||||
### Typing & Read Status ✅ DONE
|
||||
- [x] Typing indicators in text channels
|
||||
- [x] Read receipts per message
|
||||
- [x] Last read pointer storage
|
||||
- [x] Multi-user typing indicator ("Alice and Bob are typing...")
|
||||
|
||||
### Voice Improvements ✅ DONE
|
||||
- [x] Speaking indicators with volume levels
|
||||
- [x] Voice activity detection (local visualization — green dot/border on speaking)
|
||||
- [x] Adaptive bitrate negotiation
|
||||
- [x] Voice activity banner ("you are talking" / "someone is talking")
|
||||
|
||||
### Client Polish ✅ DONE
|
||||
- [x] Message reactions (emoji)
|
||||
- [x] Message editing (with "(edited)" marker)
|
||||
- [x] Message deletion with confirmation
|
||||
- [x] Per-user volume control (client-side mix)
|
||||
|
||||
---
|
||||
|
||||
## Phase 2
|
||||
|
||||
Goal: scalability, federation preparation, advanced security.
|
||||
|
||||
### Server
|
||||
- [ ] TLS 1.3 on TCP control plane
|
||||
- [ ] Permission system enforcement in gateway
|
||||
- [ ] Rate limiting refinement
|
||||
- [ ] PostgreSQL backend (sqlx support ready, config not exposed yet)
|
||||
- [ ] Prometheus metrics on gateway and voice-node
|
||||
- [ ] Gateway admin API (HTTP endpoint)
|
||||
- [ ] Identity keypair encryption at rest (Argon2id passphrase)
|
||||
|
||||
### Protocol
|
||||
- [ ] Protobuf schemas (replacing JSON)
|
||||
- [ ] E2EE for direct messages (optional)
|
||||
- [ ] E2EE for private channels (opt-in)
|
||||
- [ ] Voice regions: Warsaw, Frankfurt, Amsterdam, London, New York, Singapore
|
||||
|
||||
### Client
|
||||
- [ ] Seed phrase backup UI
|
||||
- [ ] Keyfile export / import
|
||||
- [ ] Per-user volume control (client-side mix)
|
||||
- [ ] Overlay: Windows, Linux X11, speaking indicators, mute/deafen state
|
||||
|
||||
### Federation Foundation
|
||||
- [ ] LNEx federation protocol spec
|
||||
- [ ] Node-to-node handshake and mutual auth
|
||||
- [ ] Federation discovery (DNS SRV)
|
||||
|
||||
### Plugins Foundation
|
||||
- [ ] Plugin runtime (Deno or QuickJS — decision required)
|
||||
- [ ] WebSocket RPC API v1
|
||||
- [ ] Plugin manifest and permissions
|
||||
- [ ] Example plugins: moderation bot, music bot
|
||||
|
||||
---
|
||||
|
||||
## Phase 3
|
||||
|
||||
Goal: federation ecosystem and mobile clients.
|
||||
|
||||
### Federation
|
||||
- [ ] Text chat bridging between nodes
|
||||
- [ ] Voice relay across nodes
|
||||
- [ ] Channel bridging
|
||||
- [ ] Federation admin controls (trust levels, denylist)
|
||||
- [ ] Shared user directory
|
||||
|
||||
### Plugins Ecosystem
|
||||
- [ ] Plugin installer in client
|
||||
- [ ] Community plugin registry (basic)
|
||||
- [ ] Advanced plugins: relay-switcher, analytics, moderation suite
|
||||
|
||||
### Mobile
|
||||
- [ ] Stack decision (Flutter / React Native / Native)
|
||||
- [ ] MVP: connect, text, voice, push-to-talk
|
||||
- [ ] Identity import from desktop (QR / keyfile)
|
||||
|
||||
---
|
||||
|
||||
## Future ideas (unscheduled)
|
||||
|
||||
Spatial audio for games, adaptive bitrate relay, distributed mesh,
|
||||
VNOX SDK (Rust → TypeScript → Python), offline message queue.
|
||||
450
docs/07-community-model.md
Normal file
450
docs/07-community-model.md
Normal file
|
|
@ -0,0 +1,450 @@
|
|||
# Community Model
|
||||
|
||||
Version: 1.0
|
||||
Status: Specification for Phase 1.2
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
The Community Model defines the core entities of the Vnox social platform. Communities (called "Guilds" in the spec, analogous to Discord servers or Slack workspaces) are the primary organizational unit where users can communicate through channels, voice calls, and direct messages.
|
||||
|
||||
### Entity Hierarchy
|
||||
|
||||
```
|
||||
Guild
|
||||
├── Categories
|
||||
│ └── Channels (TEXT, VOICE, ANNOUNCEMENT, STAGE)
|
||||
├── Members
|
||||
├── Roles
|
||||
├── Invites
|
||||
└── Settings
|
||||
```
|
||||
|
||||
Each entity has:
|
||||
- Unique identifier (UUID)
|
||||
- Lifecycle events (CREATE, UPDATE, DELETE)
|
||||
- Associated Gateway events
|
||||
- Audit trail in moderator actions
|
||||
|
||||
---
|
||||
|
||||
## Guild
|
||||
|
||||
A Guild represents an independent community of users.
|
||||
|
||||
### Analogues
|
||||
- Discord Server
|
||||
- Slack Workspace
|
||||
- Matrix Space
|
||||
- TeamSpeak Server
|
||||
|
||||
### Responsibilities
|
||||
- Store and manage members
|
||||
- Store and manage roles
|
||||
- Store and manage channels and categories
|
||||
- Manage invitations
|
||||
- Enforce community settings
|
||||
|
||||
### Fields
|
||||
|
||||
| Field | Type | Description |
|
||||
| ----------- | --------- | ------------------------ |
|
||||
| id | UUID | Unique identifier |
|
||||
| owner_id | UUID | Guild owner (immutable) |
|
||||
| name | String | Display name |
|
||||
| description | String | Guild description |
|
||||
| icon | AssetId | Guild icon (optional) |
|
||||
| banner | AssetId | Guild banner (optional) |
|
||||
| created_at | Timestamp | Creation time |
|
||||
| updated_at | Timestamp | Last modification time |
|
||||
|
||||
### Limits
|
||||
|
||||
Recommended thresholds:
|
||||
- 500 Categories per guild
|
||||
- 5,000 Channels per guild
|
||||
- 250 Roles per guild
|
||||
- 100,000+ Members per guild
|
||||
|
||||
---
|
||||
|
||||
## Category
|
||||
|
||||
Categories group channels for organization and permission inheritance.
|
||||
|
||||
### Responsibilities
|
||||
- Organize channels visually
|
||||
- Inherit and override permissions
|
||||
- Provide structural organization for the guild
|
||||
|
||||
### Fields
|
||||
|
||||
| Field | Type | Description |
|
||||
| ---------- | --------- | ------------------------ |
|
||||
| id | UUID | Unique identifier |
|
||||
| guild_id | UUID | Parent guild |
|
||||
| name | String | Display name |
|
||||
| position | Integer | Sort order (ascending) |
|
||||
| created_at | Timestamp | Creation time |
|
||||
|
||||
### Example Structure
|
||||
|
||||
```
|
||||
Development
|
||||
├── backend
|
||||
├── frontend
|
||||
└── infrastructure
|
||||
|
||||
Community
|
||||
├── general
|
||||
├── memes
|
||||
└── voice
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Channel
|
||||
|
||||
Channels are the primary communication unit within a guild.
|
||||
|
||||
### Channel Types
|
||||
|
||||
| Type | Purpose |
|
||||
| ------------ | ------------------------------------------------ |
|
||||
| TEXT | Standard text messages with history |
|
||||
| VOICE | Voice communication with connected members |
|
||||
| ANNOUNCEMENT | Read-only broadcasts from moderators |
|
||||
| STAGE | One-way speaker podiums with audience |
|
||||
| DM | Private 1:1 conversation (no guild) |
|
||||
| GROUP_DM | Private group conversation (no guild) |
|
||||
|
||||
### Fields
|
||||
|
||||
| Field | Type | Description |
|
||||
| ----------- | --------- | ------------------------ |
|
||||
| id | UUID | Unique identifier |
|
||||
| guild_id | UUID | Parent guild |
|
||||
| category_id | UUID | Parent category (nullable) |
|
||||
| type | Enum | Channel type |
|
||||
| name | String | Display name |
|
||||
| topic | String | Channel description |
|
||||
| position | Integer | Sort order (ascending) |
|
||||
| created_at | Timestamp | Creation time |
|
||||
|
||||
### Permissions
|
||||
|
||||
Each channel can define permission overrides for roles and users.
|
||||
|
||||
Example:
|
||||
|
||||
```
|
||||
Role: Member
|
||||
|
||||
VIEW_CHANNEL: ALLOW
|
||||
SEND_MESSAGES: DENY
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Role
|
||||
|
||||
Roles represent groups of permissions that can be assigned to members.
|
||||
|
||||
### Characteristics
|
||||
- Members can have multiple roles
|
||||
- Roles inherit permissions additively
|
||||
- Roles have a display color
|
||||
- Roles have a hierarchical position
|
||||
|
||||
### Fields
|
||||
|
||||
| Field | Type | Description |
|
||||
| ----------- | ------- | ------------------------ |
|
||||
| id | UUID | Unique identifier |
|
||||
| guild_id | UUID | Parent guild |
|
||||
| name | String | Display name |
|
||||
| color | Integer | RGB color (0x000000-0xFFFFFF) |
|
||||
| permissions | u128 | Permission bitmask |
|
||||
| position | Integer | Role hierarchy (higher = more power) |
|
||||
| hoist | Boolean | Display separately in member list |
|
||||
| mentionable | Boolean | Can be mentioned with @role |
|
||||
|
||||
### Default Roles
|
||||
|
||||
Every guild starts with these built-in roles:
|
||||
|
||||
| Role | Permissions |
|
||||
| ------------- | -------------------- |
|
||||
| Owner | All permissions |
|
||||
| Administrator | All except ownership transfer |
|
||||
| Moderator | Message management, member moderation |
|
||||
| Member | Standard member permissions |
|
||||
| Guest | Limited permissions |
|
||||
| Bot | API permissions for bots |
|
||||
|
||||
---
|
||||
|
||||
## Permission System
|
||||
|
||||
Permissions control which actions a user can perform. They are stored as a 128-bit bitmask.
|
||||
|
||||
### Guild Permissions
|
||||
|
||||
| Permission | Bit | Description |
|
||||
| -------------------- | --- | ------------------------------ |
|
||||
| VIEW_CHANNEL | 0 | See channels and categories |
|
||||
| SEND_MESSAGES | 1 | Send messages in text channels |
|
||||
| EMBED_LINKS | 2 | Send URL embeds |
|
||||
| ATTACH_FILES | 3 | Upload files |
|
||||
| MENTION_EVERYONE | 4 | Use @everyone and @here |
|
||||
| MANAGE_MESSAGES | 5 | Delete/edit others' messages |
|
||||
| MANAGE_CHANNELS | 6 | Create/delete channels |
|
||||
| MANAGE_ROLES | 7 | Manage roles |
|
||||
| MANAGE_GUILD | 8 | Edit guild settings |
|
||||
| CREATE_INVITE | 9 | Create invitations |
|
||||
| VIEW_AUDIT_LOG | 10 | View audit log |
|
||||
|
||||
### Voice Permissions
|
||||
|
||||
| Permission | Bit | Description |
|
||||
| -------------------- | --- | ------------------------------ |
|
||||
| CONNECT | 16 | Join voice channels |
|
||||
| SPEAK | 17 | Transmit audio |
|
||||
| STREAM | 18 | Share screen/camera |
|
||||
| PRIORITY_SPEAKER | 19 | Auto unmute when speaking |
|
||||
| MUTE_MEMBERS | 20 | Mute other users |
|
||||
| DEAFEN_MEMBERS | 21 | Deafen other users |
|
||||
| MOVE_MEMBERS | 22 | Move users between channels |
|
||||
|
||||
### Administrative Permissions
|
||||
|
||||
| Permission | Bit | Description |
|
||||
| -------------------- | --- | ------------------------------ |
|
||||
| ADMINISTRATOR | 30 | All permissions |
|
||||
| OWNER | 31 | Guild ownership (unique) |
|
||||
| BYPASS_CHECKS | 32 | Bypass permission checks |
|
||||
|
||||
### Permission Resolution Order
|
||||
|
||||
When determining if a user can perform an action:
|
||||
|
||||
1. **Guild Owner** → Always allowed
|
||||
2. **ADMINISTRATOR flag** → All permissions granted
|
||||
3. **Role Permissions** → Sum all roles' permissions
|
||||
4. **Channel Overrides** → Role-specific channel overrides
|
||||
5. **User Overrides** → User-specific channel overrides (highest priority)
|
||||
|
||||
Last rule wins (most specific override takes precedence).
|
||||
|
||||
---
|
||||
|
||||
## Invite
|
||||
|
||||
Invites allow users to join guilds.
|
||||
|
||||
### Types
|
||||
|
||||
| Type | Description |
|
||||
| ----------- | ---------------------------------- |
|
||||
| Permanent | Never expires, unlimited uses |
|
||||
| Temporary | Expires after time or usage limit |
|
||||
|
||||
### Fields
|
||||
|
||||
| Field | Type | Description |
|
||||
| ---------- | --------- | ------------------------ |
|
||||
| id | UUID | Unique identifier |
|
||||
| guild_id | UUID | Target guild |
|
||||
| creator_id | UUID | User who created invite |
|
||||
| code | String | Invite code (slug) |
|
||||
| expires_at | Timestamp | Expiration (nullable) |
|
||||
| max_uses | Integer | Use limit (nullable) |
|
||||
| uses | Integer | Current use count |
|
||||
|
||||
### Example Codes
|
||||
|
||||
```
|
||||
vnox.gg/dev
|
||||
vnox.gg/community
|
||||
vnox.gg/events-2026
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Member
|
||||
|
||||
A Member represents a user within a specific guild. Important: User and Member are separate entities.
|
||||
|
||||
- **User**: Global identity across all nodes
|
||||
- **Member**: User's role and status within a specific guild
|
||||
|
||||
### Fields
|
||||
|
||||
| Field | Type | Description |
|
||||
| ------------- | ------------- | ------------------------ |
|
||||
| guild_id | UUID | Parent guild |
|
||||
| user_id | UUID | User identity |
|
||||
| nickname | String | Guild-specific nickname |
|
||||
| joined_at | Timestamp | Join time |
|
||||
| roles | Array<UUID> | Assigned role IDs |
|
||||
| timeout_until | Timestamp | Moderation timeout (nullable) |
|
||||
|
||||
### Responsibilities
|
||||
|
||||
- Track guild membership
|
||||
- Store assigned roles
|
||||
- Maintain guild-specific nickname
|
||||
- Enforce moderation state (timeouts)
|
||||
|
||||
---
|
||||
|
||||
## Presence
|
||||
|
||||
Presence describes a user's current online status and activity. Presence is **global** (not per-guild).
|
||||
|
||||
### Status Types
|
||||
|
||||
| Status | Description |
|
||||
| ------------------ | ------------------------------------ |
|
||||
| ONLINE | Active and available |
|
||||
| IDLE | Away but still connected |
|
||||
| DO_NOT_DISTURB | Online but do not send notifications |
|
||||
| OFFLINE | Not connected |
|
||||
| INVISIBLE | Appears offline to others |
|
||||
|
||||
### Activity Types
|
||||
|
||||
| Activity | Example |
|
||||
| -------- | ------------------------ |
|
||||
| PLAYING | "Rust" or "Minecraft" |
|
||||
| LISTENING | "Spotify" or "Podcast" |
|
||||
| WATCHING | "Live Stream" or "Movie" |
|
||||
| STREAMING | "Twitch" or "YouTube" |
|
||||
| CUSTOM | User-defined text |
|
||||
|
||||
### Fields
|
||||
|
||||
| Field | Type | Description |
|
||||
| ------------- | --------- | ------------------------ |
|
||||
| user_id | UUID | User identity |
|
||||
| status | Enum | Online status |
|
||||
| activity_type | Enum | Current activity type |
|
||||
| activity_text | String | Activity description |
|
||||
| last_seen | Timestamp | Last activity time |
|
||||
|
||||
### Examples
|
||||
|
||||
```
|
||||
Status: ONLINE
|
||||
Activity: Playing Rust
|
||||
|
||||
Status: IDLE
|
||||
Activity: Listening to Spotify
|
||||
|
||||
Status: DO_NOT_DISTURB
|
||||
Custom: Building Vnox
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Entity Relationships
|
||||
|
||||
```
|
||||
Guild (1) ──── (N) Category
|
||||
Guild (1) ──── (N) Channel
|
||||
Guild (1) ──── (N) Role
|
||||
Guild (1) ──── (N) Invite
|
||||
Guild (1) ──── (N) Member
|
||||
|
||||
User (1) ──── (N) Presence
|
||||
User (1) ──── (N) Member
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Gateway Events
|
||||
|
||||
The Gateway broadcasts events when community entities change.
|
||||
|
||||
### Guild Events
|
||||
|
||||
| Event | Trigger |
|
||||
| ------------- | ------------------------ |
|
||||
| GUILD_CREATE | Guild created |
|
||||
| GUILD_UPDATE | Guild settings changed |
|
||||
| GUILD_DELETE | Guild deleted |
|
||||
|
||||
### Category Events
|
||||
|
||||
| Event | Trigger |
|
||||
| ----------------- | ------------------------ |
|
||||
| CATEGORY_CREATE | Category created |
|
||||
| CATEGORY_UPDATE | Category settings changed |
|
||||
| CATEGORY_DELETE | Category deleted |
|
||||
|
||||
### Channel Events
|
||||
|
||||
| Event | Trigger |
|
||||
| --------------- | ------------------------ |
|
||||
| CHANNEL_CREATE | Channel created |
|
||||
| CHANNEL_UPDATE | Channel settings changed |
|
||||
| CHANNEL_DELETE | Channel deleted |
|
||||
|
||||
### Role Events
|
||||
|
||||
| Event | Trigger |
|
||||
| ------------- | ------------------------ |
|
||||
| ROLE_CREATE | Role created |
|
||||
| ROLE_UPDATE | Role modified |
|
||||
| ROLE_DELETE | Role deleted |
|
||||
|
||||
### Invite Events
|
||||
|
||||
| Event | Trigger |
|
||||
| -------------- | ------------------------ |
|
||||
| INVITE_CREATE | Invite generated |
|
||||
| INVITE_DELETE | Invite revoked |
|
||||
|
||||
### Member Events
|
||||
|
||||
| Event | Trigger |
|
||||
| -------------- | ------------------------ |
|
||||
| MEMBER_JOIN | User joined guild |
|
||||
| MEMBER_UPDATE | Member data changed |
|
||||
| MEMBER_LEAVE | User left guild |
|
||||
|
||||
### Presence Events
|
||||
|
||||
| Event | Trigger |
|
||||
| ----------------- | ------------------------ |
|
||||
| PRESENCE_UPDATE | Status or activity changed |
|
||||
|
||||
---
|
||||
|
||||
## Design Goals
|
||||
|
||||
✓ Discord-like usability and familiarity
|
||||
✓ Scalable architecture (thousands of members)
|
||||
✓ Federation compatibility (Phase 3+)
|
||||
✓ Plugin compatibility (Phase 2+)
|
||||
✓ Self-hosting support
|
||||
✓ Efficient permission resolution (cached, bitwise)
|
||||
✓ Low memory overhead
|
||||
✓ Gateway-first synchronization (eventual consistency)
|
||||
|
||||
---
|
||||
|
||||
## Future Extensions
|
||||
|
||||
Planned for later phases:
|
||||
|
||||
- Forum Channels
|
||||
- Threads (message branching)
|
||||
- Scheduled Events
|
||||
- Voice Regions (automatic routing)
|
||||
- Guild Templates (one-click setup)
|
||||
- Role Icons
|
||||
- Verification Levels
|
||||
- Community Discovery
|
||||
- Guild Analytics
|
||||
745
docs/08-gateway-events.md
Normal file
745
docs/08-gateway-events.md
Normal file
|
|
@ -0,0 +1,745 @@
|
|||
# Gateway Events System
|
||||
|
||||
Version: 1.0
|
||||
Status: Specification for Phase 1.2+
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
The Gateway Events system is the real-time synchronization layer for Vnox. All state changes (messages, user status, channel creation, etc.) are broadcast as events to interested clients.
|
||||
|
||||
Events follow a standard structure and allow clients to:
|
||||
- Stay synchronized without polling
|
||||
- React to user actions in real-time
|
||||
- Build responsive UIs
|
||||
- Implement typing indicators and read receipts
|
||||
|
||||
---
|
||||
|
||||
## Event Structure
|
||||
|
||||
### Standard Packet Format
|
||||
|
||||
```json
|
||||
{
|
||||
"op": 0,
|
||||
"event": "MESSAGE_CREATE",
|
||||
"data": {
|
||||
"message_id": "...",
|
||||
"channel_id": "...",
|
||||
"sender_id": "...",
|
||||
"content": "...",
|
||||
"timestamp": "..."
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Fields
|
||||
|
||||
| Field | Type | Description |
|
||||
| ----- | ------ | ------------------------------------ |
|
||||
| op | u32 | Opcode (0=event, 1=command response) |
|
||||
| event | String | Event type name |
|
||||
| data | Object | Event-specific payload |
|
||||
|
||||
---
|
||||
|
||||
## Message Events
|
||||
|
||||
Events related to text messages in channels.
|
||||
|
||||
### MESSAGE_CREATE
|
||||
|
||||
Sent when a message is posted.
|
||||
|
||||
**Payload:**
|
||||
```json
|
||||
{
|
||||
"message_id": "uuid",
|
||||
"channel_id": "uuid",
|
||||
"sender_id": "uuid",
|
||||
"sender_name": "string",
|
||||
"content": "string",
|
||||
"timestamp": "2026-06-05T12:34:56Z"
|
||||
}
|
||||
```
|
||||
|
||||
**Broadcast:** Everyone with `VIEW_CHANNEL` permission
|
||||
|
||||
---
|
||||
|
||||
### MESSAGE_UPDATE
|
||||
|
||||
Sent when a message is edited.
|
||||
|
||||
**Payload:**
|
||||
```json
|
||||
{
|
||||
"message_id": "uuid",
|
||||
"channel_id": "uuid",
|
||||
"content": "string (new content)",
|
||||
"edited_at": "2026-06-05T12:35:00Z"
|
||||
}
|
||||
```
|
||||
|
||||
**Broadcast:** Everyone with `VIEW_CHANNEL` permission
|
||||
|
||||
---
|
||||
|
||||
### MESSAGE_DELETE
|
||||
|
||||
Sent when a message is deleted.
|
||||
|
||||
**Payload:**
|
||||
```json
|
||||
{
|
||||
"message_id": "uuid",
|
||||
"channel_id": "uuid",
|
||||
"deleted_at": "2026-06-05T12:36:00Z"
|
||||
}
|
||||
```
|
||||
|
||||
**Broadcast:** Everyone with `VIEW_CHANNEL` permission
|
||||
|
||||
---
|
||||
|
||||
### MESSAGE_REACTION_ADD
|
||||
|
||||
Sent when a user adds an emoji reaction.
|
||||
|
||||
**Payload:**
|
||||
```json
|
||||
{
|
||||
"message_id": "uuid",
|
||||
"channel_id": "uuid",
|
||||
"reactor_id": "uuid",
|
||||
"emoji": "👍"
|
||||
}
|
||||
```
|
||||
|
||||
**Broadcast:** Everyone with `VIEW_CHANNEL` permission
|
||||
|
||||
---
|
||||
|
||||
### MESSAGE_REACTION_REMOVE
|
||||
|
||||
Sent when a user removes an emoji reaction.
|
||||
|
||||
**Payload:**
|
||||
```json
|
||||
{
|
||||
"message_id": "uuid",
|
||||
"channel_id": "uuid",
|
||||
"reactor_id": "uuid",
|
||||
"emoji": "👍"
|
||||
}
|
||||
```
|
||||
|
||||
**Broadcast:** Everyone with `VIEW_CHANNEL` permission
|
||||
|
||||
---
|
||||
|
||||
## Typing Events
|
||||
|
||||
### TYPING_START
|
||||
|
||||
Sent when a user begins typing.
|
||||
|
||||
**Payload:**
|
||||
```json
|
||||
{
|
||||
"channel_id": "uuid",
|
||||
"user_id": "uuid",
|
||||
"user_name": "string",
|
||||
"timestamp": "2026-06-05T12:34:56Z"
|
||||
}
|
||||
```
|
||||
|
||||
**Broadcast:** Everyone with `VIEW_CHANNEL` permission
|
||||
|
||||
**Note:** Sender must stop sending after 5-10 seconds of inactivity.
|
||||
|
||||
---
|
||||
|
||||
### TYPING_STOP
|
||||
|
||||
Sent when a user stops typing.
|
||||
|
||||
**Payload:**
|
||||
```json
|
||||
{
|
||||
"channel_id": "uuid",
|
||||
"user_id": "uuid"
|
||||
}
|
||||
```
|
||||
|
||||
**Broadcast:** Everyone with `VIEW_CHANNEL` permission
|
||||
|
||||
---
|
||||
|
||||
## Read Receipt Events
|
||||
|
||||
### MESSAGE_ACK
|
||||
|
||||
Sent when a user reads up to a certain message.
|
||||
|
||||
**Payload:**
|
||||
```json
|
||||
{
|
||||
"channel_id": "uuid",
|
||||
"user_id": "uuid",
|
||||
"message_id": "uuid (last read)",
|
||||
"timestamp": "2026-06-05T12:34:56Z"
|
||||
}
|
||||
```
|
||||
|
||||
**Broadcast:** Sender and channel members
|
||||
|
||||
---
|
||||
|
||||
## Guild Events
|
||||
|
||||
### GUILD_CREATE
|
||||
|
||||
Sent when a new guild is created.
|
||||
|
||||
**Payload:**
|
||||
```json
|
||||
{
|
||||
"guild_id": "uuid",
|
||||
"owner_id": "uuid",
|
||||
"name": "string",
|
||||
"description": "string",
|
||||
"icon": "asset_id (nullable)",
|
||||
"created_at": "2026-06-05T12:34:56Z"
|
||||
}
|
||||
```
|
||||
|
||||
**Broadcast:** All guild members on login
|
||||
|
||||
---
|
||||
|
||||
### GUILD_UPDATE
|
||||
|
||||
Sent when guild settings change.
|
||||
|
||||
**Payload:**
|
||||
```json
|
||||
{
|
||||
"guild_id": "uuid",
|
||||
"changes": {
|
||||
"name": "new name",
|
||||
"description": "new description",
|
||||
"icon": "asset_id (nullable)"
|
||||
},
|
||||
"updated_at": "2026-06-05T12:34:56Z"
|
||||
}
|
||||
```
|
||||
|
||||
**Broadcast:** All guild members
|
||||
|
||||
---
|
||||
|
||||
### GUILD_DELETE
|
||||
|
||||
Sent when a guild is deleted.
|
||||
|
||||
**Payload:**
|
||||
```json
|
||||
{
|
||||
"guild_id": "uuid",
|
||||
"deleted_at": "2026-06-05T12:34:56Z"
|
||||
}
|
||||
```
|
||||
|
||||
**Broadcast:** All former members
|
||||
|
||||
---
|
||||
|
||||
## Category Events
|
||||
|
||||
### CATEGORY_CREATE
|
||||
|
||||
Sent when a category is created.
|
||||
|
||||
**Payload:**
|
||||
```json
|
||||
{
|
||||
"category_id": "uuid",
|
||||
"guild_id": "uuid",
|
||||
"name": "string",
|
||||
"position": 0,
|
||||
"created_at": "2026-06-05T12:34:56Z"
|
||||
}
|
||||
```
|
||||
|
||||
**Broadcast:** All members with `VIEW_CHANNEL` permission
|
||||
|
||||
---
|
||||
|
||||
### CATEGORY_UPDATE
|
||||
|
||||
Sent when category properties change.
|
||||
|
||||
**Payload:**
|
||||
```json
|
||||
{
|
||||
"category_id": "uuid",
|
||||
"guild_id": "uuid",
|
||||
"changes": {
|
||||
"name": "new name",
|
||||
"position": 1
|
||||
},
|
||||
"updated_at": "2026-06-05T12:34:56Z"
|
||||
}
|
||||
```
|
||||
|
||||
**Broadcast:** All members with `VIEW_CHANNEL` permission
|
||||
|
||||
---
|
||||
|
||||
### CATEGORY_DELETE
|
||||
|
||||
Sent when a category is deleted.
|
||||
|
||||
**Payload:**
|
||||
```json
|
||||
{
|
||||
"category_id": "uuid",
|
||||
"guild_id": "uuid",
|
||||
"deleted_at": "2026-06-05T12:34:56Z"
|
||||
}
|
||||
```
|
||||
|
||||
**Broadcast:** All members with `VIEW_CHANNEL` permission
|
||||
|
||||
---
|
||||
|
||||
## Channel Events
|
||||
|
||||
### CHANNEL_CREATE
|
||||
|
||||
Sent when a channel is created.
|
||||
|
||||
**Payload:**
|
||||
```json
|
||||
{
|
||||
"channel_id": "uuid",
|
||||
"guild_id": "uuid",
|
||||
"category_id": "uuid (nullable)",
|
||||
"type": "TEXT | VOICE | ANNOUNCEMENT | STAGE",
|
||||
"name": "string",
|
||||
"topic": "string (nullable)",
|
||||
"position": 0,
|
||||
"created_at": "2026-06-05T12:34:56Z"
|
||||
}
|
||||
```
|
||||
|
||||
**Broadcast:** All members with `VIEW_CHANNEL` permission
|
||||
|
||||
---
|
||||
|
||||
### CHANNEL_UPDATE
|
||||
|
||||
Sent when channel properties change.
|
||||
|
||||
**Payload:**
|
||||
```json
|
||||
{
|
||||
"channel_id": "uuid",
|
||||
"guild_id": "uuid",
|
||||
"changes": {
|
||||
"name": "new name",
|
||||
"topic": "new topic",
|
||||
"position": 2
|
||||
},
|
||||
"updated_at": "2026-06-05T12:34:56Z"
|
||||
}
|
||||
```
|
||||
|
||||
**Broadcast:** All members with `VIEW_CHANNEL` permission
|
||||
|
||||
---
|
||||
|
||||
### CHANNEL_DELETE
|
||||
|
||||
Sent when a channel is deleted.
|
||||
|
||||
**Payload:**
|
||||
```json
|
||||
{
|
||||
"channel_id": "uuid",
|
||||
"guild_id": "uuid",
|
||||
"deleted_at": "2026-06-05T12:34:56Z"
|
||||
}
|
||||
```
|
||||
|
||||
**Broadcast:** All members with `VIEW_CHANNEL` permission
|
||||
|
||||
---
|
||||
|
||||
## Role Events
|
||||
|
||||
### ROLE_CREATE
|
||||
|
||||
Sent when a new role is created.
|
||||
|
||||
**Payload:**
|
||||
```json
|
||||
{
|
||||
"role_id": "uuid",
|
||||
"guild_id": "uuid",
|
||||
"name": "string",
|
||||
"color": 16711680,
|
||||
"permissions": 0,
|
||||
"position": 5,
|
||||
"hoist": false,
|
||||
"mentionable": true,
|
||||
"created_at": "2026-06-05T12:34:56Z"
|
||||
}
|
||||
```
|
||||
|
||||
**Broadcast:** All members with `MANAGE_ROLES` permission
|
||||
|
||||
---
|
||||
|
||||
### ROLE_UPDATE
|
||||
|
||||
Sent when role settings change.
|
||||
|
||||
**Payload:**
|
||||
```json
|
||||
{
|
||||
"role_id": "uuid",
|
||||
"guild_id": "uuid",
|
||||
"changes": {
|
||||
"name": "new name",
|
||||
"color": 16711680,
|
||||
"permissions": 1024,
|
||||
"position": 6
|
||||
},
|
||||
"updated_at": "2026-06-05T12:34:56Z"
|
||||
}
|
||||
```
|
||||
|
||||
**Broadcast:** All members with `MANAGE_ROLES` permission
|
||||
|
||||
---
|
||||
|
||||
### ROLE_DELETE
|
||||
|
||||
Sent when a role is deleted.
|
||||
|
||||
**Payload:**
|
||||
```json
|
||||
{
|
||||
"role_id": "uuid",
|
||||
"guild_id": "uuid",
|
||||
"deleted_at": "2026-06-05T12:34:56Z"
|
||||
}
|
||||
```
|
||||
|
||||
**Broadcast:** All members with `MANAGE_ROLES` permission
|
||||
|
||||
---
|
||||
|
||||
## Invite Events
|
||||
|
||||
### INVITE_CREATE
|
||||
|
||||
Sent when a new invite is generated.
|
||||
|
||||
**Payload:**
|
||||
```json
|
||||
{
|
||||
"invite_id": "uuid",
|
||||
"guild_id": "uuid",
|
||||
"code": "string",
|
||||
"creator_id": "uuid",
|
||||
"expires_at": "2026-06-10T12:34:56Z (nullable)",
|
||||
"max_uses": 10,
|
||||
"uses": 0,
|
||||
"created_at": "2026-06-05T12:34:56Z"
|
||||
}
|
||||
```
|
||||
|
||||
**Broadcast:** All members with `MANAGE_INVITES` permission
|
||||
|
||||
---
|
||||
|
||||
### INVITE_DELETE
|
||||
|
||||
Sent when an invite is revoked.
|
||||
|
||||
**Payload:**
|
||||
```json
|
||||
{
|
||||
"invite_id": "uuid",
|
||||
"guild_id": "uuid",
|
||||
"code": "string",
|
||||
"deleted_at": "2026-06-05T12:34:56Z"
|
||||
}
|
||||
```
|
||||
|
||||
**Broadcast:** All members with `MANAGE_INVITES` permission
|
||||
|
||||
---
|
||||
|
||||
## Member Events
|
||||
|
||||
### MEMBER_JOIN
|
||||
|
||||
Sent when a user joins a guild.
|
||||
|
||||
**Payload:**
|
||||
```json
|
||||
{
|
||||
"guild_id": "uuid",
|
||||
"user_id": "uuid",
|
||||
"user_name": "string",
|
||||
"nickname": "string (nullable)",
|
||||
"joined_at": "2026-06-05T12:34:56Z"
|
||||
}
|
||||
```
|
||||
|
||||
**Broadcast:** All guild members
|
||||
|
||||
---
|
||||
|
||||
### MEMBER_UPDATE
|
||||
|
||||
Sent when member data changes (roles, nickname, timeout).
|
||||
|
||||
**Payload:**
|
||||
```json
|
||||
{
|
||||
"guild_id": "uuid",
|
||||
"user_id": "uuid",
|
||||
"changes": {
|
||||
"nickname": "new nickname",
|
||||
"roles": ["role_id_1", "role_id_2"],
|
||||
"timeout_until": "2026-06-05T13:34:56Z (nullable)"
|
||||
},
|
||||
"updated_at": "2026-06-05T12:34:56Z"
|
||||
}
|
||||
```
|
||||
|
||||
**Broadcast:** All guild members with `MANAGE_MEMBERS` permission
|
||||
|
||||
---
|
||||
|
||||
### MEMBER_LEAVE
|
||||
|
||||
Sent when a user leaves a guild.
|
||||
|
||||
**Payload:**
|
||||
```json
|
||||
{
|
||||
"guild_id": "uuid",
|
||||
"user_id": "uuid",
|
||||
"left_at": "2026-06-05T12:34:56Z"
|
||||
}
|
||||
```
|
||||
|
||||
**Broadcast:** All remaining guild members
|
||||
|
||||
---
|
||||
|
||||
## Voice Events
|
||||
|
||||
### VOICE_STATE_UPDATE
|
||||
|
||||
Sent when a user's voice state changes (connect, disconnect, mute, deafen).
|
||||
|
||||
**Payload:**
|
||||
```json
|
||||
{
|
||||
"user_id": "uuid",
|
||||
"user_name": "string",
|
||||
"guild_id": "uuid",
|
||||
"channel_id": "uuid (nullable, null = disconnect)",
|
||||
"muted": false,
|
||||
"deafened": false,
|
||||
"self_muted": false,
|
||||
"self_deafened": false,
|
||||
"speaking": false,
|
||||
"volume_level": 0.8,
|
||||
"timestamp": "2026-06-05T12:34:56Z"
|
||||
}
|
||||
```
|
||||
|
||||
**Broadcast:** All members of the voice channel and moderators
|
||||
|
||||
---
|
||||
|
||||
## Presence Events
|
||||
|
||||
### PRESENCE_UPDATE
|
||||
|
||||
Sent when a user's presence (online status, activity) changes.
|
||||
|
||||
**Payload:**
|
||||
```json
|
||||
{
|
||||
"user_id": "uuid",
|
||||
"status": "ONLINE | IDLE | DO_NOT_DISTURB | OFFLINE | INVISIBLE",
|
||||
"activity_type": "PLAYING | LISTENING | WATCHING | STREAMING | CUSTOM (nullable)",
|
||||
"activity_text": "string (nullable)",
|
||||
"last_seen": "2026-06-05T12:34:56Z"
|
||||
}
|
||||
```
|
||||
|
||||
**Broadcast:** All connected clients of mutual friends/guild members
|
||||
|
||||
---
|
||||
|
||||
## Direct Message Events
|
||||
|
||||
### DM_CREATE
|
||||
|
||||
Sent when a new DM channel is opened.
|
||||
|
||||
**Payload:**
|
||||
```json
|
||||
{
|
||||
"dm_id": "uuid",
|
||||
"user_id": "uuid",
|
||||
"recipient_id": "uuid",
|
||||
"recipient_name": "string",
|
||||
"created_at": "2026-06-05T12:34:56Z"
|
||||
}
|
||||
```
|
||||
|
||||
**Broadcast:** Both participants
|
||||
|
||||
---
|
||||
|
||||
### DM_MESSAGE
|
||||
|
||||
Sent when a direct message is received.
|
||||
|
||||
**Payload:**
|
||||
```json
|
||||
{
|
||||
"dm_id": "uuid",
|
||||
"message_id": "uuid",
|
||||
"sender_id": "uuid",
|
||||
"content": "string",
|
||||
"timestamp": "2026-06-05T12:34:56Z"
|
||||
}
|
||||
```
|
||||
|
||||
**Broadcast:** Both participants
|
||||
|
||||
---
|
||||
|
||||
### DM_TYPING
|
||||
|
||||
Sent when a user types in a DM.
|
||||
|
||||
**Payload:**
|
||||
```json
|
||||
{
|
||||
"dm_id": "uuid",
|
||||
"user_id": "uuid",
|
||||
"timestamp": "2026-06-05T12:34:56Z"
|
||||
}
|
||||
```
|
||||
|
||||
**Broadcast:** Recipient only
|
||||
|
||||
---
|
||||
|
||||
## Friend Events
|
||||
|
||||
### FRIEND_REQUEST
|
||||
|
||||
Sent when a friend request is received.
|
||||
|
||||
**Payload:**
|
||||
```json
|
||||
{
|
||||
"requester_id": "uuid",
|
||||
"requester_name": "string",
|
||||
"timestamp": "2026-06-05T12:34:56Z"
|
||||
}
|
||||
```
|
||||
|
||||
**Broadcast:** Recipient only
|
||||
|
||||
---
|
||||
|
||||
### FRIEND_ACCEPT
|
||||
|
||||
Sent when a friend request is accepted.
|
||||
|
||||
**Payload:**
|
||||
```json
|
||||
{
|
||||
"friend_id": "uuid",
|
||||
"friend_name": "string",
|
||||
"timestamp": "2026-06-05T12:34:56Z"
|
||||
}
|
||||
```
|
||||
|
||||
**Broadcast:** Both parties
|
||||
|
||||
---
|
||||
|
||||
### FRIEND_REMOVE
|
||||
|
||||
Sent when a friendship is ended.
|
||||
|
||||
**Payload:**
|
||||
```json
|
||||
{
|
||||
"friend_id": "uuid",
|
||||
"timestamp": "2026-06-05T12:34:56Z"
|
||||
}
|
||||
```
|
||||
|
||||
**Broadcast:** Both parties
|
||||
|
||||
---
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
### Permission Checks
|
||||
|
||||
The gateway should verify permissions before broadcasting:
|
||||
|
||||
```
|
||||
if event_requires_permission:
|
||||
for client in subscribers:
|
||||
if client.has_permission(event.required_permission):
|
||||
send(client, event)
|
||||
```
|
||||
|
||||
### Ordering Guarantees
|
||||
|
||||
Events within a single session are in causal order:
|
||||
- Messages from the same user appear in send order
|
||||
- Role changes before member updates using that role
|
||||
|
||||
### Event Deduplication
|
||||
|
||||
Clients should handle duplicate events gracefully. Use `message_id` or unique event identifiers to deduplicate.
|
||||
|
||||
### Backpressure
|
||||
|
||||
If a client falls behind, the gateway should:
|
||||
1. Queue up to N events in memory
|
||||
2. If queue exceeds N, force reconnect (replay full state)
|
||||
3. Implement exponential backoff for slow clients
|
||||
|
||||
---
|
||||
|
||||
## Design Goals
|
||||
|
||||
✓ Real-time synchronization without polling
|
||||
✓ Bandwidth efficient (only changed state)
|
||||
✓ Permissible (respect channel and role visibility)
|
||||
✓ Ordered causally (maintain consistency)
|
||||
✓ Extensible (easy to add new event types)
|
||||
✓ Compatible with federation (events can be bridged)
|
||||
669
docs/10-database.md
Normal file
669
docs/10-database.md
Normal file
|
|
@ -0,0 +1,669 @@
|
|||
# Database Schema
|
||||
|
||||
Version: 1.0
|
||||
Status: Specification and implementation guide
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
Vnox uses SQLite for local deployment and supports PostgreSQL for larger deployments.
|
||||
|
||||
Database schema defines storage for:
|
||||
- Users and identity
|
||||
- Guilds, channels, categories
|
||||
- Roles and permissions
|
||||
- Messages and history
|
||||
- Direct messages
|
||||
- Presence and online status
|
||||
- Audit logs
|
||||
|
||||
All tables use UUID for primary keys (no auto-increment) for federation compatibility.
|
||||
|
||||
---
|
||||
|
||||
## Core Tables
|
||||
|
||||
### users
|
||||
|
||||
Global user identity across all Vnox instances.
|
||||
|
||||
```sql
|
||||
CREATE TABLE users (
|
||||
id TEXT PRIMARY KEY,
|
||||
username TEXT NOT NULL,
|
||||
identity_key TEXT NOT NULL,
|
||||
created_at TIMESTAMP NOT NULL,
|
||||
updated_at TIMESTAMP NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX idx_users_username ON users(username);
|
||||
```
|
||||
|
||||
| Column | Type | Constraints | Description |
|
||||
| ------------ | --------- | -------------- | -------------------- |
|
||||
| id | TEXT | PRIMARY KEY | UUID |
|
||||
| username | TEXT | NOT NULL | Display name |
|
||||
| identity_key | TEXT | NOT NULL | Ed25519 public key |
|
||||
| created_at | TIMESTAMP | NOT NULL | Creation time |
|
||||
| updated_at | TIMESTAMP | NOT NULL | Last modification |
|
||||
|
||||
---
|
||||
|
||||
### sessions
|
||||
|
||||
Active user sessions on a gateway instance.
|
||||
|
||||
```sql
|
||||
CREATE TABLE sessions (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL,
|
||||
session_token TEXT NOT NULL UNIQUE,
|
||||
expires_at TIMESTAMP NOT NULL,
|
||||
created_at TIMESTAMP NOT NULL,
|
||||
FOREIGN KEY(user_id) REFERENCES users(id)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_sessions_user ON sessions(user_id);
|
||||
CREATE INDEX idx_sessions_expires ON sessions(expires_at);
|
||||
```
|
||||
|
||||
| Column | Type | Constraints | Description |
|
||||
| ------------- | --------- | -------------- | -------------------- |
|
||||
| id | TEXT | PRIMARY KEY | Session UUID |
|
||||
| user_id | TEXT | FK users | User owning session |
|
||||
| session_token | TEXT | UNIQUE | Auth token |
|
||||
| expires_at | TIMESTAMP | NOT NULL | Expiration time |
|
||||
| created_at | TIMESTAMP | NOT NULL | Creation time |
|
||||
|
||||
---
|
||||
|
||||
## Guild Tables
|
||||
|
||||
### guilds
|
||||
|
||||
Guild entities (communities/servers).
|
||||
|
||||
```sql
|
||||
CREATE TABLE guilds (
|
||||
id TEXT PRIMARY KEY,
|
||||
owner_id TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
description TEXT,
|
||||
icon TEXT,
|
||||
banner TEXT,
|
||||
created_at TIMESTAMP NOT NULL,
|
||||
updated_at TIMESTAMP NOT NULL,
|
||||
FOREIGN KEY(owner_id) REFERENCES users(id)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_guilds_owner ON guilds(owner_id);
|
||||
```
|
||||
|
||||
| Column | Type | Constraints | Description |
|
||||
| ----------- | --------- | -------------- | -------------------- |
|
||||
| id | TEXT | PRIMARY KEY | Guild UUID |
|
||||
| owner_id | TEXT | FK users | Guild owner |
|
||||
| name | TEXT | NOT NULL | Guild name |
|
||||
| description | TEXT | | Guild description |
|
||||
| icon | TEXT | | Asset ID |
|
||||
| banner | TEXT | | Asset ID |
|
||||
| created_at | TIMESTAMP | NOT NULL | Creation time |
|
||||
| updated_at | TIMESTAMP | NOT NULL | Last modification |
|
||||
|
||||
---
|
||||
|
||||
### guild_members
|
||||
|
||||
Guild membership tracking.
|
||||
|
||||
```sql
|
||||
CREATE TABLE guild_members (
|
||||
guild_id TEXT NOT NULL,
|
||||
user_id TEXT NOT NULL,
|
||||
nickname TEXT,
|
||||
joined_at TIMESTAMP NOT NULL,
|
||||
timeout_until TIMESTAMP,
|
||||
PRIMARY KEY (guild_id, user_id),
|
||||
FOREIGN KEY(guild_id) REFERENCES guilds(id),
|
||||
FOREIGN KEY(user_id) REFERENCES users(id)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_guild_members_user ON guild_members(user_id);
|
||||
CREATE INDEX idx_guild_members_joined ON guild_members(joined_at);
|
||||
```
|
||||
|
||||
| Column | Type | Constraints | Description |
|
||||
| ------------- | --------- | -------------- | -------------------- |
|
||||
| guild_id | TEXT | PRIMARY KEY | Guild UUID |
|
||||
| user_id | TEXT | PRIMARY KEY | User UUID |
|
||||
| nickname | TEXT | | Guild-specific nick |
|
||||
| joined_at | TIMESTAMP | NOT NULL | Join time |
|
||||
| timeout_until | TIMESTAMP | | Moderation timeout |
|
||||
|
||||
---
|
||||
|
||||
### categories
|
||||
|
||||
Channel categories for organization.
|
||||
|
||||
```sql
|
||||
CREATE TABLE categories (
|
||||
id TEXT PRIMARY KEY,
|
||||
guild_id TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
position INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMP NOT NULL,
|
||||
updated_at TIMESTAMP NOT NULL,
|
||||
FOREIGN KEY(guild_id) REFERENCES guilds(id)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_categories_guild ON categories(guild_id);
|
||||
CREATE INDEX idx_categories_position ON categories(guild_id, position);
|
||||
```
|
||||
|
||||
| Column | Type | Constraints | Description |
|
||||
| ---------- | --------- | -------------- | -------------------- |
|
||||
| id | TEXT | PRIMARY KEY | Category UUID |
|
||||
| guild_id | TEXT | FK guilds | Parent guild |
|
||||
| name | TEXT | NOT NULL | Display name |
|
||||
| position | INTEGER | NOT NULL | Sort order |
|
||||
| created_at | TIMESTAMP | NOT NULL | Creation time |
|
||||
| updated_at | TIMESTAMP | NOT NULL | Last modification |
|
||||
|
||||
---
|
||||
|
||||
### channels
|
||||
|
||||
Communication channels (text, voice, announcements).
|
||||
|
||||
```sql
|
||||
CREATE TABLE channels (
|
||||
id TEXT PRIMARY KEY,
|
||||
guild_id TEXT NOT NULL,
|
||||
category_id TEXT,
|
||||
type TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
topic TEXT,
|
||||
position INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMP NOT NULL,
|
||||
updated_at TIMESTAMP NOT NULL,
|
||||
FOREIGN KEY(guild_id) REFERENCES guilds(id),
|
||||
FOREIGN KEY(category_id) REFERENCES categories(id)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_channels_guild ON channels(guild_id);
|
||||
CREATE INDEX idx_channels_category ON channels(category_id);
|
||||
CREATE INDEX idx_channels_position ON channels(guild_id, position);
|
||||
```
|
||||
|
||||
| Column | Type | Constraints | Description |
|
||||
| ----------- | --------- | -------------- | -------------------- |
|
||||
| id | TEXT | PRIMARY KEY | Channel UUID |
|
||||
| guild_id | TEXT | FK guilds | Parent guild |
|
||||
| category_id | TEXT | FK categories | Parent category |
|
||||
| type | TEXT | NOT NULL | TEXT/VOICE/etc |
|
||||
| name | TEXT | NOT NULL | Display name |
|
||||
| topic | TEXT | | Channel description |
|
||||
| position | INTEGER | NOT NULL | Sort order |
|
||||
| created_at | TIMESTAMP | NOT NULL | Creation time |
|
||||
| updated_at | TIMESTAMP | NOT NULL | Last modification |
|
||||
|
||||
---
|
||||
|
||||
## Role & Permission Tables
|
||||
|
||||
### roles
|
||||
|
||||
Guild roles with permission bitmasks.
|
||||
|
||||
```sql
|
||||
CREATE TABLE roles (
|
||||
id TEXT PRIMARY KEY,
|
||||
guild_id TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
color INTEGER NOT NULL DEFAULT 0,
|
||||
permissions INTEGER NOT NULL DEFAULT 0,
|
||||
position INTEGER NOT NULL DEFAULT 0,
|
||||
hoist INTEGER NOT NULL DEFAULT 0,
|
||||
mentionable INTEGER NOT NULL DEFAULT 1,
|
||||
created_at TIMESTAMP NOT NULL,
|
||||
updated_at TIMESTAMP NOT NULL,
|
||||
FOREIGN KEY(guild_id) REFERENCES guilds(id)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_roles_guild ON roles(guild_id);
|
||||
CREATE INDEX idx_roles_position ON roles(guild_id, position);
|
||||
```
|
||||
|
||||
| Column | Type | Constraints | Description |
|
||||
| ------------ | --------- | -------------- | -------------------- |
|
||||
| id | TEXT | PRIMARY KEY | Role UUID |
|
||||
| guild_id | TEXT | FK guilds | Parent guild |
|
||||
| name | TEXT | NOT NULL | Display name |
|
||||
| color | INTEGER | NOT NULL | RGB color value |
|
||||
| permissions | INTEGER | NOT NULL | Bitmask (u128) |
|
||||
| position | INTEGER | NOT NULL | Hierarchy position |
|
||||
| hoist | INTEGER | NOT NULL | Display separately |
|
||||
| mentionable | INTEGER | NOT NULL | Can be @mentioned |
|
||||
| created_at | TIMESTAMP | NOT NULL | Creation time |
|
||||
| updated_at | TIMESTAMP | NOT NULL | Last modification |
|
||||
|
||||
---
|
||||
|
||||
### member_roles
|
||||
|
||||
Many-to-many mapping of members to roles.
|
||||
|
||||
```sql
|
||||
CREATE TABLE member_roles (
|
||||
member_guild_id TEXT NOT NULL,
|
||||
member_user_id TEXT NOT NULL,
|
||||
role_id TEXT NOT NULL,
|
||||
PRIMARY KEY (member_guild_id, member_user_id, role_id),
|
||||
FOREIGN KEY(member_guild_id, member_user_id)
|
||||
REFERENCES guild_members(guild_id, user_id),
|
||||
FOREIGN KEY(role_id) REFERENCES roles(id)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_member_roles_role ON member_roles(role_id);
|
||||
```
|
||||
|
||||
| Column | Type | Constraints | Description |
|
||||
| ---------------- | ---- | -------------- | -------------------- |
|
||||
| member_guild_id | TEXT | PRIMARY KEY | Guild UUID |
|
||||
| member_user_id | TEXT | PRIMARY KEY | User UUID |
|
||||
| role_id | TEXT | PRIMARY KEY | Role UUID |
|
||||
|
||||
---
|
||||
|
||||
### channel_overrides
|
||||
|
||||
Role and user permission overrides per channel.
|
||||
|
||||
```sql
|
||||
CREATE TABLE channel_overrides (
|
||||
id TEXT PRIMARY KEY,
|
||||
channel_id TEXT NOT NULL,
|
||||
target_id TEXT NOT NULL,
|
||||
target_type TEXT NOT NULL,
|
||||
allow_mask INTEGER NOT NULL DEFAULT 0,
|
||||
deny_mask INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMP NOT NULL,
|
||||
updated_at TIMESTAMP NOT NULL,
|
||||
FOREIGN KEY(channel_id) REFERENCES channels(id)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_channel_overrides_channel
|
||||
ON channel_overrides(channel_id);
|
||||
CREATE INDEX idx_channel_overrides_target
|
||||
ON channel_overrides(target_id, target_type);
|
||||
```
|
||||
|
||||
| Column | Type | Constraints | Description |
|
||||
| ----------- | --------- | -------------- | ------------------------ |
|
||||
| id | TEXT | PRIMARY KEY | Override UUID |
|
||||
| channel_id | TEXT | FK channels | Channel |
|
||||
| target_id | TEXT | NOT NULL | Role or User UUID |
|
||||
| target_type | TEXT | NOT NULL | "ROLE" or "USER" |
|
||||
| allow_mask | INTEGER | NOT NULL | Allowed permissions |
|
||||
| deny_mask | INTEGER | NOT NULL | Denied permissions |
|
||||
| created_at | TIMESTAMP | NOT NULL | Creation time |
|
||||
| updated_at | TIMESTAMP | NOT NULL | Last modification |
|
||||
|
||||
---
|
||||
|
||||
## Message Tables
|
||||
|
||||
### messages
|
||||
|
||||
Text messages in channels.
|
||||
|
||||
```sql
|
||||
CREATE TABLE messages (
|
||||
id TEXT PRIMARY KEY,
|
||||
channel_id TEXT NOT NULL,
|
||||
sender_id TEXT NOT NULL,
|
||||
content TEXT NOT NULL,
|
||||
created_at TIMESTAMP NOT NULL,
|
||||
updated_at TIMESTAMP,
|
||||
deleted_at TIMESTAMP,
|
||||
FOREIGN KEY(channel_id) REFERENCES channels(id),
|
||||
FOREIGN KEY(sender_id) REFERENCES users(id)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_messages_channel ON messages(channel_id, created_at DESC);
|
||||
CREATE INDEX idx_messages_sender ON messages(sender_id);
|
||||
```
|
||||
|
||||
| Column | Type | Constraints | Description |
|
||||
| ---------- | --------- | -------------- | -------------------- |
|
||||
| id | TEXT | PRIMARY KEY | Message UUID |
|
||||
| channel_id | TEXT | FK channels | Parent channel |
|
||||
| sender_id | TEXT | FK users | Message author |
|
||||
| content | TEXT | NOT NULL | Encrypted content |
|
||||
| created_at | TIMESTAMP | NOT NULL | Creation time |
|
||||
| updated_at | TIMESTAMP | | Edit time |
|
||||
| deleted_at | TIMESTAMP | | Deletion time (soft) |
|
||||
|
||||
---
|
||||
|
||||
### message_reactions
|
||||
|
||||
Emoji reactions on messages.
|
||||
|
||||
```sql
|
||||
CREATE TABLE message_reactions (
|
||||
message_id TEXT NOT NULL,
|
||||
user_id TEXT NOT NULL,
|
||||
emoji TEXT NOT NULL,
|
||||
created_at TIMESTAMP NOT NULL,
|
||||
PRIMARY KEY (message_id, user_id, emoji),
|
||||
FOREIGN KEY(message_id) REFERENCES messages(id),
|
||||
FOREIGN KEY(user_id) REFERENCES users(id)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_reactions_message ON message_reactions(message_id);
|
||||
```
|
||||
|
||||
| Column | Type | Constraints | Description |
|
||||
| ---------- | --------- | -------------- | -------------------- |
|
||||
| message_id | TEXT | PRIMARY KEY | Message UUID |
|
||||
| user_id | TEXT | PRIMARY KEY | User UUID |
|
||||
| emoji | TEXT | PRIMARY KEY | Emoji character |
|
||||
| created_at | TIMESTAMP | NOT NULL | Reaction time |
|
||||
|
||||
---
|
||||
|
||||
## Direct Message Tables
|
||||
|
||||
### direct_messages
|
||||
|
||||
DM channel tracking (not per-guild).
|
||||
|
||||
```sql
|
||||
CREATE TABLE direct_messages (
|
||||
dm_id TEXT PRIMARY KEY,
|
||||
user_id_1 TEXT NOT NULL,
|
||||
user_id_2 TEXT NOT NULL,
|
||||
created_at TIMESTAMP NOT NULL,
|
||||
updated_at TIMESTAMP NOT NULL,
|
||||
FOREIGN KEY(user_id_1) REFERENCES users(id),
|
||||
FOREIGN KEY(user_id_2) REFERENCES users(id)
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX idx_dm_users
|
||||
ON direct_messages(
|
||||
MIN(user_id_1, user_id_2),
|
||||
MAX(user_id_1, user_id_2)
|
||||
);
|
||||
```
|
||||
|
||||
| Column | Type | Constraints | Description |
|
||||
| ---------- | --------- | -------------- | -------------------- |
|
||||
| dm_id | TEXT | PRIMARY KEY | DM UUID |
|
||||
| user_id_1 | TEXT | FK users | First user (lexicog) |
|
||||
| user_id_2 | TEXT | FK users | Second user |
|
||||
| created_at | TIMESTAMP | NOT NULL | Creation time |
|
||||
| updated_at | TIMESTAMP | NOT NULL | Last message time |
|
||||
|
||||
---
|
||||
|
||||
### dm_messages
|
||||
|
||||
Direct messages (archived).
|
||||
|
||||
```sql
|
||||
CREATE TABLE dm_messages (
|
||||
id TEXT PRIMARY KEY,
|
||||
dm_id TEXT NOT NULL,
|
||||
sender_id TEXT NOT NULL,
|
||||
content TEXT NOT NULL,
|
||||
created_at TIMESTAMP NOT NULL,
|
||||
FOREIGN KEY(dm_id) REFERENCES direct_messages(dm_id),
|
||||
FOREIGN KEY(sender_id) REFERENCES users(id)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_dm_messages_dm ON dm_messages(dm_id, created_at DESC);
|
||||
CREATE INDEX idx_dm_messages_sender ON dm_messages(sender_id);
|
||||
```
|
||||
|
||||
| Column | Type | Constraints | Description |
|
||||
| ---------- | --------- | -------------- | -------------------- |
|
||||
| id | TEXT | PRIMARY KEY | Message UUID |
|
||||
| dm_id | TEXT | FK dm tables | Parent DM |
|
||||
| sender_id | TEXT | FK users | Message author |
|
||||
| content | TEXT | NOT NULL | Encrypted content |
|
||||
| created_at | TIMESTAMP | NOT NULL | Creation time |
|
||||
|
||||
---
|
||||
|
||||
## Invitation Tables
|
||||
|
||||
### invites
|
||||
|
||||
Guild invitations.
|
||||
|
||||
```sql
|
||||
CREATE TABLE invites (
|
||||
id TEXT PRIMARY KEY,
|
||||
guild_id TEXT NOT NULL,
|
||||
creator_id TEXT NOT NULL,
|
||||
code TEXT NOT NULL UNIQUE,
|
||||
expires_at TIMESTAMP,
|
||||
max_uses INTEGER,
|
||||
uses INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMP NOT NULL,
|
||||
FOREIGN KEY(guild_id) REFERENCES guilds(id),
|
||||
FOREIGN KEY(creator_id) REFERENCES users(id)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_invites_guild ON invites(guild_id);
|
||||
CREATE INDEX idx_invites_code ON invites(code);
|
||||
CREATE INDEX idx_invites_expires ON invites(expires_at);
|
||||
```
|
||||
|
||||
| Column | Type | Constraints | Description |
|
||||
| ---------- | --------- | -------------- | -------------------- |
|
||||
| id | TEXT | PRIMARY KEY | Invite UUID |
|
||||
| guild_id | TEXT | FK guilds | Target guild |
|
||||
| creator_id | TEXT | FK users | Creator |
|
||||
| code | TEXT | UNIQUE | Invite slug |
|
||||
| expires_at | TIMESTAMP | | Expiration time |
|
||||
| max_uses | INTEGER | | Use limit |
|
||||
| uses | INTEGER | NOT NULL | Current uses |
|
||||
| created_at | TIMESTAMP | NOT NULL | Creation time |
|
||||
|
||||
---
|
||||
|
||||
## Presence Tables
|
||||
|
||||
### presence
|
||||
|
||||
User online status and activity.
|
||||
|
||||
```sql
|
||||
CREATE TABLE presence (
|
||||
user_id TEXT PRIMARY KEY,
|
||||
status TEXT NOT NULL,
|
||||
activity_type TEXT,
|
||||
activity_text TEXT,
|
||||
last_seen TIMESTAMP NOT NULL,
|
||||
FOREIGN KEY(user_id) REFERENCES users(id)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_presence_status ON presence(status);
|
||||
CREATE INDEX idx_presence_last_seen ON presence(last_seen);
|
||||
```
|
||||
|
||||
| Column | Type | Constraints | Description |
|
||||
| ------------- | --------- | -------------- | -------------------- |
|
||||
| user_id | TEXT | PRIMARY KEY | User UUID |
|
||||
| status | TEXT | NOT NULL | ONLINE/IDLE/etc |
|
||||
| activity_type | TEXT | | PLAYING/LISTENING |
|
||||
| activity_text | TEXT | | Activity description |
|
||||
| last_seen | TIMESTAMP | NOT NULL | Last activity time |
|
||||
|
||||
---
|
||||
|
||||
## Friendship Tables
|
||||
|
||||
### friends
|
||||
|
||||
User friendship relationships.
|
||||
|
||||
```sql
|
||||
CREATE TABLE friends (
|
||||
requester_id TEXT NOT NULL,
|
||||
target_id TEXT NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
created_at TIMESTAMP NOT NULL,
|
||||
PRIMARY KEY (requester_id, target_id),
|
||||
FOREIGN KEY(requester_id) REFERENCES users(id),
|
||||
FOREIGN KEY(target_id) REFERENCES users(id)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_friends_target ON friends(target_id, status);
|
||||
```
|
||||
|
||||
| Column | Type | Constraints | Description |
|
||||
| ------------ | --------- | -------------- | ------------------------ |
|
||||
| requester_id | TEXT | PRIMARY KEY | Request originator |
|
||||
| target_id | TEXT | PRIMARY KEY | Request recipient |
|
||||
| status | TEXT | NOT NULL | pending/accepted/blocked |
|
||||
| created_at | TIMESTAMP | NOT NULL | Request time |
|
||||
|
||||
---
|
||||
|
||||
## Audit Log Table
|
||||
|
||||
### audit_logs
|
||||
|
||||
Moderation and administrative actions.
|
||||
|
||||
```sql
|
||||
CREATE TABLE audit_logs (
|
||||
id TEXT PRIMARY KEY,
|
||||
guild_id TEXT NOT NULL,
|
||||
actor_id TEXT NOT NULL,
|
||||
action TEXT NOT NULL,
|
||||
target_id TEXT,
|
||||
target_type TEXT,
|
||||
reason TEXT,
|
||||
changes TEXT,
|
||||
created_at TIMESTAMP NOT NULL,
|
||||
FOREIGN KEY(guild_id) REFERENCES guilds(id),
|
||||
FOREIGN KEY(actor_id) REFERENCES users(id)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_audit_logs_guild ON audit_logs(guild_id, created_at DESC);
|
||||
CREATE INDEX idx_audit_logs_actor ON audit_logs(actor_id);
|
||||
CREATE INDEX idx_audit_logs_target
|
||||
ON audit_logs(target_id, target_type);
|
||||
```
|
||||
|
||||
| Column | Type | Constraints | Description |
|
||||
| ----------- | --------- | -------------- | -------------------- |
|
||||
| id | TEXT | PRIMARY KEY | Log UUID |
|
||||
| guild_id | TEXT | FK guilds | Guild context |
|
||||
| actor_id | TEXT | FK users | Admin/moderator |
|
||||
| action | TEXT | NOT NULL | ACTION_TYPE |
|
||||
| target_id | TEXT | | Affected entity |
|
||||
| target_type | TEXT | | Entity type |
|
||||
| reason | TEXT | | Reason for action |
|
||||
| changes | TEXT | | JSON of old/new |
|
||||
| created_at | TIMESTAMP | NOT NULL | Action time |
|
||||
|
||||
---
|
||||
|
||||
## Migration Strategy
|
||||
|
||||
### Phase 1.1 (Current)
|
||||
|
||||
```sql
|
||||
-- Already exists
|
||||
CREATE TABLE users
|
||||
CREATE TABLE sessions
|
||||
CREATE TABLE channels
|
||||
CREATE TABLE messages
|
||||
CREATE TABLE direct_messages
|
||||
CREATE TABLE dm_messages
|
||||
```
|
||||
|
||||
### Phase 1.2
|
||||
|
||||
```sql
|
||||
-- Add guild system
|
||||
CREATE TABLE guilds
|
||||
CREATE TABLE guild_members
|
||||
CREATE TABLE categories
|
||||
CREATE TABLE roles
|
||||
CREATE TABLE member_roles
|
||||
CREATE TABLE channel_overrides
|
||||
CREATE TABLE invites
|
||||
CREATE TABLE presence
|
||||
CREATE TABLE friends
|
||||
CREATE TABLE audit_logs
|
||||
```
|
||||
|
||||
### Backward Compatibility
|
||||
|
||||
During transition to guilds:
|
||||
- Create a default guild per deployment
|
||||
- Migrate existing channels to default guild
|
||||
- Add all existing users as guild members
|
||||
|
||||
---
|
||||
|
||||
## Performance Considerations
|
||||
|
||||
### Indexes
|
||||
|
||||
Key composite indexes:
|
||||
- `messages(channel_id, created_at DESC)` — range queries
|
||||
- `dm_messages(dm_id, created_at DESC)` — pagination
|
||||
- `presence(status, last_seen)` — online user queries
|
||||
- `guild_members(guild_id, joined_at)` — membership lists
|
||||
|
||||
### Query Patterns
|
||||
|
||||
Optimize for:
|
||||
- Message history fetch (paginated, DESC)
|
||||
- Channel member list
|
||||
- Permission resolution (cached)
|
||||
- Online presence broadcast
|
||||
- Recent DM threads
|
||||
|
||||
### Caching
|
||||
|
||||
Recommended client-side caches:
|
||||
- Guild data (invalidated on GUILD_UPDATE)
|
||||
- Channel list (invalidated on CHANNEL_CREATE/DELETE)
|
||||
- User presence (invalidated on PRESENCE_UPDATE)
|
||||
- Role permissions (cached by guild)
|
||||
|
||||
---
|
||||
|
||||
## PostgreSQL vs SQLite
|
||||
|
||||
### SQLite (Development, Single-Server)
|
||||
|
||||
```toml
|
||||
[database]
|
||||
type = "sqlite"
|
||||
path = "data/vnox.db"
|
||||
```
|
||||
|
||||
### PostgreSQL (Production, Distributed)
|
||||
|
||||
```toml
|
||||
[database]
|
||||
type = "postgres"
|
||||
url = "postgresql://user:pass@localhost/vnox"
|
||||
pool_size = 32
|
||||
```
|
||||
|
||||
Both use identical schema; driver handles translation of:
|
||||
- `INTEGER` ↔ `BIGINT`
|
||||
- `TEXT` ↔ `VARCHAR`
|
||||
- UUID handling
|
||||
52
docs/LICENSE.md
Normal file
52
docs/LICENSE.md
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
# License
|
||||
|
||||
VNOX and LNEx are licensed under the **GNU General Public License v3.0 (GPL-3.0)**.
|
||||
|
||||
```
|
||||
Copyright (C) 2026 VNOX Contributors
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
```
|
||||
|
||||
Full license text: `LICENSE` in the repository root.
|
||||
|
||||
---
|
||||
|
||||
## What this means
|
||||
|
||||
You can:
|
||||
- use VNOX for any purpose
|
||||
- study and modify the source code
|
||||
- distribute copies of VNOX
|
||||
- distribute modified versions — but they must also be GPL-3.0
|
||||
|
||||
You cannot:
|
||||
- distribute VNOX or derivatives under a proprietary license
|
||||
- remove copyright notices
|
||||
|
||||
## Protocol specification
|
||||
|
||||
The LNEx protocol specification (`docs/02-protocol/`) is additionally
|
||||
licensed under **CC0 1.0 Universal (Public Domain)**.
|
||||
|
||||
This means anyone can implement the LNEx protocol in any language,
|
||||
under any license, without restriction. Third-party clients and servers
|
||||
are explicitly encouraged.
|
||||
|
||||
## Plugins
|
||||
|
||||
Plugins you write for VNOX are your own code. The GPL does not automatically
|
||||
apply to plugin code — plugins communicate with VNOX via WebSocket RPC,
|
||||
which is considered a separate program. You may license your plugins however
|
||||
you choose.
|
||||
38
docs/README.md
Normal file
38
docs/README.md
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
# VNOX documentation
|
||||
|
||||
Self-hosted realtime voice and chat. Decentralized. Lightweight. Moddable.
|
||||
Built on LNEx, a custom protocol layer for low-latency federated communication.
|
||||
|
||||
Not Discord. Not TeamSpeak. Not cloud.
|
||||
|
||||
## Start here
|
||||
|
||||
- [Current status (what actually works)](00-status.md)
|
||||
- [Overview](00-overview.md)
|
||||
- [Architecture](01-architecture.md)
|
||||
- [Roadmap](06-roadmap.md)
|
||||
- [Changelog](../CHANGELOG.md)
|
||||
|
||||
## Phase 1.2+ Planning
|
||||
|
||||
- [Community Model](07-community-model.md) — Guilds, channels, roles, members
|
||||
- [Gateway Events](08-gateway-events.md) — Real-time event system
|
||||
- [Database Schema](10-database.md) — SQLite/PostgreSQL structure
|
||||
|
||||
## By topic
|
||||
|
||||
| Topic | Entry point |
|
||||
|-------|-------------|
|
||||
| Protocol | [02-protocol/README.md](02-protocol/README.md) |
|
||||
| Server | [03-server/README.md](03-server/README.md) |
|
||||
| Clients | [04-clients/README.md](04-clients/README.md) |
|
||||
| Plugins | [community/plugins.md](community/plugins.md) |
|
||||
| Contributing | [community/contributing.md](community/contributing.md) |
|
||||
|
||||
## Quick paths
|
||||
|
||||
- Try it locally: [dev/README.md](../dev/README.md)
|
||||
- Deploy a node: [03-server/deployment.md](03-server/deployment.md)
|
||||
- Understand the protocol: [02-protocol/README.md](02-protocol/README.md)
|
||||
- Understand the community model: [07-community-model.md](07-community-model.md)
|
||||
|
||||
176
docs/community/contributing.md
Normal file
176
docs/community/contributing.md
Normal file
|
|
@ -0,0 +1,176 @@
|
|||
# Contributing
|
||||
|
||||
---
|
||||
|
||||
## License
|
||||
|
||||
VNOX is licensed under **GPL-3.0**. See `../LICENSE.md` for the full text and
|
||||
what this means for plugins and protocol implementations.
|
||||
|
||||
The LNEx protocol specification is additionally licensed under **CC0** (public domain) —
|
||||
anyone can implement it under any license.
|
||||
|
||||
---
|
||||
|
||||
## Before you start
|
||||
|
||||
1. Check existing issues — your idea or bug may already be tracked.
|
||||
2. For significant changes, open an issue first to discuss approach.
|
||||
3. For small fixes (typos, docs, obvious bugs), PRs are welcome directly.
|
||||
|
||||
---
|
||||
|
||||
## Setting up the dev environment
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- Rust stable (latest) — `rustup update stable`
|
||||
- System audio libraries:
|
||||
- Linux: `apt install libasound2-dev libopus-dev pkg-config`
|
||||
- macOS: `brew install opus`
|
||||
- Windows: opus ships with the crate, no extra step
|
||||
- Docker (optional, for running a local node)
|
||||
|
||||
### Build
|
||||
|
||||
```bash
|
||||
git clone https://github.com/vnox/vnox
|
||||
cd vnox
|
||||
|
||||
# Build everything
|
||||
cargo build
|
||||
|
||||
# Build release
|
||||
cargo build --release
|
||||
|
||||
# Run gateway (dev mode)
|
||||
cargo run -p vnox-gateway -- --config dev/config.toml
|
||||
|
||||
# Run voice node
|
||||
cargo run -p vnox-voice-node -- --config dev/config.toml
|
||||
|
||||
# Run client
|
||||
cargo run -p vnox-client
|
||||
```
|
||||
|
||||
### Running tests
|
||||
|
||||
```bash
|
||||
# All tests
|
||||
cargo test
|
||||
|
||||
# Specific crate
|
||||
cargo test -p vnox-gateway
|
||||
|
||||
# With logs
|
||||
RUST_LOG=debug cargo test -p vnox-gateway -- --nocapture
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Repository structure
|
||||
|
||||
```
|
||||
vnox/
|
||||
├── client/ # Desktop client (egui + wgpu)
|
||||
│ └── src/
|
||||
│ ├── ui/ # egui panels and widgets
|
||||
│ ├── audio/ # cpal capture/playback, Opus encode/decode
|
||||
│ ├── net/ # LNEx client, quinn UDP
|
||||
│ └── identity/ # keypair, auth
|
||||
│
|
||||
├── gateway/ # TCP gateway
|
||||
│ └── src/
|
||||
│ ├── auth/ # identity verification, sessions
|
||||
│ ├── channels/ # channel management
|
||||
│ ├── proto/ # LNEx packet handling
|
||||
│ └── storage/ # SQLite / Postgres
|
||||
│
|
||||
├── voice-node/ # UDP voice relay
|
||||
│ └── src/
|
||||
│ ├── relay/ # packet routing per channel
|
||||
│ └── jitter/ # jitter buffer
|
||||
│
|
||||
├── protocol/ # LNEx .proto schemas
|
||||
│ └── *.proto
|
||||
│
|
||||
├── plugins/ # Plugin runtime + example plugins
|
||||
│ └── examples/
|
||||
│
|
||||
├── sdk/ # Client SDK (future)
|
||||
├── docs/ # This documentation
|
||||
└── dev/ # Dev config, test fixtures
|
||||
└── config.toml
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Code style
|
||||
|
||||
- `cargo fmt` before committing — enforced in CI
|
||||
- `cargo clippy -- -D warnings` must pass — enforced in CI
|
||||
- No `unwrap()` in library code — use proper error propagation
|
||||
- No `unsafe` without a comment explaining why it's safe
|
||||
- Public API items must have doc comments
|
||||
|
||||
---
|
||||
|
||||
## Commit messages
|
||||
|
||||
Follow conventional commits:
|
||||
|
||||
```
|
||||
feat(gateway): add rate limiting per IP
|
||||
fix(client): handle reconnect on TCP drop
|
||||
docs(protocol): clarify voice packet sequence field
|
||||
chore(deps): update tokio to 1.37
|
||||
```
|
||||
|
||||
Types: `feat`, `fix`, `docs`, `chore`, `refactor`, `test`, `perf`
|
||||
|
||||
Scope is the crate or subsystem: `gateway`, `client`, `voice-node`, `protocol`, `docs`.
|
||||
|
||||
---
|
||||
|
||||
## Pull requests
|
||||
|
||||
- Keep PRs focused — one concern per PR
|
||||
- Include tests for non-trivial changes
|
||||
- Update docs if the change affects user-facing behavior
|
||||
- CI must pass before merge
|
||||
|
||||
PR title follows the same format as commit messages.
|
||||
|
||||
---
|
||||
|
||||
## Governance
|
||||
|
||||
VNOX uses the **BDFL model** — the maintainer makes final decisions on all matters.
|
||||
|
||||
For large or breaking changes (especially LNEx protocol changes), open an issue
|
||||
and discuss before implementing. Protocol PRs without prior discussion will not be merged.
|
||||
|
||||
---
|
||||
|
||||
## Protocol changes
|
||||
|
||||
Changes to the LNEx protocol are treated differently from implementation changes.
|
||||
|
||||
- Any change to packet format, auth flow, or behavior must be documented
|
||||
in `docs/02-protocol/` before implementation
|
||||
- Breaking changes require a LNEx version bump
|
||||
- Non-breaking additions are allowed within the same version with a changelog entry
|
||||
- Protocol PRs require more review time than implementation PRs
|
||||
|
||||
---
|
||||
|
||||
## Where to start
|
||||
|
||||
Good first issues are tagged `good first issue` on GitHub.
|
||||
|
||||
If you want to contribute but don't know where:
|
||||
|
||||
1. Run a node locally and report anything confusing about the setup process
|
||||
2. Improve documentation — anything unclear in `docs/` is a valid fix
|
||||
3. Write tests for existing gateway or voice-node code
|
||||
4. Implement a feature from the Phase 1 checklist in `../06-roadmap.md`
|
||||
188
docs/community/plugins.md
Normal file
188
docs/community/plugins.md
Normal file
|
|
@ -0,0 +1,188 @@
|
|||
# Plugins
|
||||
|
||||
VNOX has a plugin system for extending server-side behavior.
|
||||
Plugins run on the node, not on the client.
|
||||
|
||||
---
|
||||
|
||||
## Supported languages
|
||||
|
||||
- TypeScript
|
||||
- JavaScript
|
||||
|
||||
---
|
||||
|
||||
## Runtime
|
||||
|
||||
**Deno** — chosen as the plugin runtime.
|
||||
|
||||
Reasons:
|
||||
- TypeScript native, no transpile step for plugin authors
|
||||
- built-in permissions model — plugins explicitly declare what they need
|
||||
- active ecosystem, good Rust embedding via `deno_core`
|
||||
|
||||
---
|
||||
|
||||
## API
|
||||
|
||||
Plugins communicate with the gateway via **WebSocket RPC**.
|
||||
The gateway exposes a local WebSocket endpoint that plugins connect to.
|
||||
|
||||
```
|
||||
Plugin process
|
||||
│ WebSocket (localhost)
|
||||
▼
|
||||
Gateway plugin API (localhost:7800)
|
||||
```
|
||||
|
||||
### RPC format
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "req-1",
|
||||
"method": "channel.send_message",
|
||||
"params": {
|
||||
"channel_id": "general",
|
||||
"content": "Hello from plugin"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Response:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "req-1",
|
||||
"result": { "message_id": "abc123" }
|
||||
}
|
||||
```
|
||||
|
||||
Errors:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "req-1",
|
||||
"error": { "code": 403, "message": "permission denied" }
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Available methods
|
||||
|
||||
### Events (subscribe)
|
||||
|
||||
```typescript
|
||||
// Subscribe to all events
|
||||
ws.on('message', (event) => {
|
||||
const e = JSON.parse(event);
|
||||
// e.event, e.data
|
||||
});
|
||||
```
|
||||
|
||||
| Event | Payload |
|
||||
|-------|---------|
|
||||
| `user.join` | `{ user_id, channel_id }` |
|
||||
| `user.leave` | `{ user_id, channel_id }` |
|
||||
| `message.created` | `{ message_id, channel_id, sender_id, content }` |
|
||||
| `voice.speaking` | `{ user_id, channel_id, state }` |
|
||||
| `user.muted` | `{ user_id }` |
|
||||
|
||||
### Commands
|
||||
|
||||
| Method | Description |
|
||||
|--------|-------------|
|
||||
| `channel.send_message` | Send a message to a channel |
|
||||
| `channel.list` | List all channels |
|
||||
| `user.kick` | Kick a user from a channel |
|
||||
| `user.mute` | Mute a user (server-side) |
|
||||
| `user.ban` | Ban a user by pubkey |
|
||||
| `user.get` | Get user info by pubkey |
|
||||
| `node.get_stats` | Get node statistics |
|
||||
|
||||
---
|
||||
|
||||
## Plugin manifest
|
||||
|
||||
Each plugin is a directory with a `plugin.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "relay-switcher",
|
||||
"version": "0.1.2",
|
||||
"author": "raven",
|
||||
"description": "Auto-selects the nearest relay node",
|
||||
"main": "index.ts",
|
||||
"permissions": [
|
||||
"node.stats",
|
||||
"channel.read"
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Permissions are declared in the manifest and granted by the node admin.
|
||||
A plugin that requests permissions not granted to it will receive `403` on those methods.
|
||||
|
||||
---
|
||||
|
||||
## Example plugin
|
||||
|
||||
A simple moderation bot that deletes messages containing a banned word:
|
||||
|
||||
```typescript
|
||||
// index.ts
|
||||
|
||||
const ws = new WebSocket("ws://localhost:7800/plugins");
|
||||
const BANNED = ["badword"];
|
||||
|
||||
ws.addEventListener("message", async (event) => {
|
||||
const e = JSON.parse(event.data);
|
||||
|
||||
if (e.event === "message.created") {
|
||||
const { message_id, channel_id, content } = e.data;
|
||||
const lower = content.toLowerCase();
|
||||
|
||||
if (BANNED.some(word => lower.includes(word))) {
|
||||
await rpc("message.delete", { message_id, channel_id });
|
||||
console.log(`Deleted message ${message_id}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
function rpc(method: string, params: object): Promise<any> {
|
||||
return new Promise((resolve) => {
|
||||
const id = crypto.randomUUID();
|
||||
ws.send(JSON.stringify({ id, method, params }));
|
||||
ws.addEventListener("message", function handler(e) {
|
||||
const res = JSON.parse(e.data);
|
||||
if (res.id === id) {
|
||||
ws.removeEventListener("message", handler);
|
||||
resolve(res.result);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Installing a plugin
|
||||
|
||||
```bash
|
||||
# Copy plugin directory to node's plugin folder
|
||||
cp -r my-plugin/ /var/lib/vnox/plugins/
|
||||
|
||||
# Restart gateway (or use hot-reload if supported)
|
||||
systemctl restart vnox-gateway
|
||||
```
|
||||
|
||||
Or via the client: Settings → Plugins → Install → select directory.
|
||||
|
||||
---
|
||||
|
||||
## Plugin marketplace
|
||||
|
||||
A community plugin registry is planned for Phase 3.
|
||||
Plugins will be installable directly from the client.
|
||||
|
||||
Until then, plugins are distributed as source code repositories.
|
||||
276
docs/superpowers/specs/2026-05-31-slint-migration-design.md
Normal file
276
docs/superpowers/specs/2026-05-31-slint-migration-design.md
Normal file
|
|
@ -0,0 +1,276 @@
|
|||
# Slint UI Migration — Design Spec
|
||||
|
||||
**Date:** 2026-05-31
|
||||
**Status:** Draft
|
||||
**Target:** Phase 1.1 — Big-bang rewrite of egui → Slint
|
||||
|
||||
## Overview
|
||||
|
||||
Replace the existing egui (eframe + wgpu) desktop client with a Slint-based UI.
|
||||
The HTML design at `vnox-ui.html` serves as the pixel-accurate visual reference:
|
||||
Discord-like dark theme, orange accent (#ff6b35), bubble-style messages,
|
||||
voice overlay, settings modal, profile card, connect screen.
|
||||
|
||||
**Migration approach:** Big-bang. One focused branch, no side-by-side egui/Slint hybrid.
|
||||
All Slint components are built from scratch following the HTML reference.
|
||||
|
||||
## Architecture
|
||||
|
||||
### Render stack
|
||||
|
||||
```
|
||||
Slint (winit + FemtoVG backend)
|
||||
├── .slint files (declarative UI)
|
||||
└── Rust glue (app.rs, state.rs, callbacks)
|
||||
```
|
||||
|
||||
- Backend: `winit` + `FemtoVG` (default, no extra deps)
|
||||
- No Qt, no external rendering libs
|
||||
- Slint version: latest stable (2.x)
|
||||
|
||||
### Component tree
|
||||
|
||||
```
|
||||
Window
|
||||
├── if (connection-state == "disconnected"): ConnectScreen
|
||||
├── if (connection-state == "connected"): MainScreen
|
||||
│ ├── Rail (левая панель иконок серверов)
|
||||
│ ├── ChannelsPanel (поиск + список каналов)
|
||||
│ │ ├── ServerHeader
|
||||
│ │ ├── SearchInput
|
||||
│ │ ├── ChannelSection[] (text / voice)
|
||||
│ │ │ └── ChannelRow[]
|
||||
│ │ └── VoiceUserList[]
|
||||
│ ├── UserBar (аватар + имя + кнопки)
|
||||
│ ├── ChatArea (основная область)
|
||||
│ │ ├── ChatHeader
|
||||
│ │ ├── MessageList
|
||||
│ │ │ ├── DaySeparator
|
||||
│ │ │ ├── SystemMessage
|
||||
│ │ │ └── MessageGroup[]
|
||||
│ │ │ └── MessageBubble[]
|
||||
│ │ └── InputArea
|
||||
│ └── MembersPanel (участники)
|
||||
│ └── MemberSection[]
|
||||
│ └── MemberRow[]
|
||||
├── if (show-voice): VoiceOverlay
|
||||
├── if (show-settings): SettingsOverlay
|
||||
│ ├── SettingsHeader
|
||||
│ ├── TabNav
|
||||
│ ├── TabAudio
|
||||
│ ├── TabIdentity (inline)
|
||||
│ ├── TabAppearance (inline)
|
||||
│ ├── TabNetwork (inline)
|
||||
│ └── TabAdvanced (inline)
|
||||
└── if (show-profile): ProfileCard
|
||||
```
|
||||
|
||||
### State management
|
||||
|
||||
**Rust owns the state.** `UiState` struct stays in Rust. Slint reads it
|
||||
through `in-out property` bindings updated on a 16ms timer (60 fps),
|
||||
and writes back through `callback → Rust handler → NetCommand` channel.
|
||||
|
||||
```
|
||||
Rust (UiState) ──[16ms timer sync]──▶ Slint properties
|
||||
Slint (callback) ──[on_* handlers]──▶ Rust (event → net layer)
|
||||
```
|
||||
|
||||
Key callbacks:
|
||||
- `send-message(string)` → net:SendText
|
||||
- `join-voice(string)` → net:JoinVoice
|
||||
- `toggle-mute()` → UiState.mic_enabled
|
||||
- `open-settings()`, `close-settings()`
|
||||
- `show-profile(string)`, `hide-profile()`
|
||||
|
||||
## File structure
|
||||
|
||||
Every `.slint` / `.rs` file stays ≤ 200 lines.
|
||||
|
||||
```
|
||||
client/src/
|
||||
├── main.rs // entry point, slint Window::run()
|
||||
├── ui/
|
||||
│ ├── app.rs // setup, sync timer, callback wiring
|
||||
│ ├── main.slint // root Window component
|
||||
│ ├── state.rs // UiState (unchanged core)
|
||||
│ │
|
||||
│ ├── components/ // reusable UI primitives
|
||||
│ │ ├── avatar.slint
|
||||
│ │ ├── toggle.slint
|
||||
│ │ ├── badge.slint
|
||||
│ │ ├── icon_button.slint
|
||||
│ │ ├── slider.slint
|
||||
│ │ └── level_bar.slint
|
||||
│ │
|
||||
│ ├── theme/
|
||||
│ │ ├── palette.slint // colors from HTML :root
|
||||
│ │ ├── typography.slint // IBM Plex Sans/Mono
|
||||
│ │ └── spacing.slint // radii, paddings
|
||||
│ │
|
||||
│ ├── connect/
|
||||
│ │ ├── connect.slint // connect screen
|
||||
│ │ └── connect.rs // connect/disconnect logic
|
||||
│ │
|
||||
│ ├── main_area/
|
||||
│ │ ├── main_area.slint // grid layout of main window
|
||||
│ │ ├── rail.slint // server icon rail
|
||||
│ │ ├── sidebar.slint // channel list + search
|
||||
│ │ ├── userbar.slint // bottom user bar
|
||||
│ │ ├── members.slint // right members panel
|
||||
│ │ └── chat/
|
||||
│ │ ├── chat_header.slint
|
||||
│ │ ├── message_list.slint
|
||||
│ │ ├── message_group.slint
|
||||
│ │ ├── message_bubble.slint
|
||||
│ │ └── input_area.slint
|
||||
│ │
|
||||
│ ├── voice_overlay/
|
||||
│ │ ├── voice_overlay.slint
|
||||
│ │ └── voice_overlay.rs
|
||||
│ │
|
||||
│ ├── settings/
|
||||
│ │ ├── settings.slint // modal + all tabs except audio
|
||||
│ │ └── settings_audio.slint // audio tab only
|
||||
│ │
|
||||
│ └── profile_card/
|
||||
│ ├── profile_card.slint
|
||||
│ └── profile_card.rs
|
||||
```
|
||||
|
||||
Total: ~30 .slint files, ~10 .rs files.
|
||||
|
||||
## Screen specifications
|
||||
|
||||
### 1. ConnectScreen (`connect/`)
|
||||
- Centered card, max 340px wide
|
||||
- Logo block (VNOX icon + text), tagline "secure voice & text · quic/v1"
|
||||
- Three inputs: Address, Username, Password
|
||||
- Two buttons: Connect (primary, accent), Keypair (ghost)
|
||||
- Recent servers section below with ping
|
||||
- Background: dark with radial gradient glow
|
||||
|
||||
### 2. MainScreen — Rail (`main_area/rail.slint`)
|
||||
- Fixed 56px wide, full height
|
||||
- Logo icon (36px, rounded, accent bg, tooltip "VNOX")
|
||||
- Thin separator
|
||||
- Server icons (36px circles, 2-letter initials, active state with left highlight bar)
|
||||
- Dashed "+" button (add server, fixed to bottom)
|
||||
- Monospace "VNOX" label at very bottom
|
||||
- Tooltips on hover via [data-tip] pattern
|
||||
|
||||
### 3. MainScreen — ChannelsSidebar (`main_area/sidebar.slint`)
|
||||
- ServerHeader: name + lnex:// address in monospace
|
||||
- SearchInput: icon + field, dark bg, thin border
|
||||
- Collapsible sections: TEXT, VOICE
|
||||
- ChannelRow: icon (# / ▶) + name + optional badge (ping/green, unread/orange, count)
|
||||
- VoiceUserRow inside voice channels: avatar, name, speaking dot
|
||||
- Active channel: left accent bar + highlighted bg
|
||||
|
||||
### 4. MainScreen — UserBar (`main_area/userbar.slint`)
|
||||
- 52px height, border-top + border-right
|
||||
- Avatar 30px + status dot
|
||||
- Username + RTT/loss stats in monospace
|
||||
- Three icon buttons: Mute (toggle red), Deafen (toggle), Settings
|
||||
|
||||
### 5. MainScreen — ChatArea (`main_area/chat/`)
|
||||
- ChatHeader: icon + name + separator + description + action buttons (pin, search, toggle-members)
|
||||
- MessageList (scrollable, flex)
|
||||
- DaySeparator: "── today ──" style
|
||||
- SystemMessage: "connected · server" centered
|
||||
- MessageGroup: avatar 34px + bubble (bg2, border, radius 4/12/12/12)
|
||||
- Header: author name (colored, clickable for profile) + time
|
||||
- Content: text, word-break
|
||||
- Continuation messages: same group, inline style (margin-left 46px)
|
||||
- Hover actions bar above group: reply, react, copy
|
||||
- InputArea: bg2 box with focus accent border
|
||||
- Top row: #channel-name prefix + text input
|
||||
- Bottom row: Attach, Emoji buttons, then right-aligned E2E badge + Send button
|
||||
|
||||
### 6. MainScreen — MembersPanel (`main_area/members.slint`)
|
||||
- 252px wide, border-left
|
||||
- Header: "УЧАСТНИКИ — N"
|
||||
- Sections by role: Admin, Moderator, Online, Offline
|
||||
- MemberRow: avatar 30px + status dot + name + role tag
|
||||
- Offline members at 35% opacity
|
||||
|
||||
### 7. VoiceOverlay (`voice_overlay/`)
|
||||
- Full-screen overlay, dark backdrop with blur
|
||||
- Header: channel name, quality indicator, RTT, codec, loss
|
||||
- Main speaker: large card (500px), avatar 88px, name, status badges
|
||||
- Active speaker: green border glow on card + avatar border
|
||||
- Secondary speakers: small cards row, avatar 48px, name, mute/deafen icons
|
||||
- Control bar: mic toggle, headphones, screen share, camera, disconnect (accent)
|
||||
|
||||
### 8. SettingsOverlay (`settings/`)
|
||||
- Modal 700px, centered, dark backdrop with blur
|
||||
- Header: logo, node name + status dot, badge, close button
|
||||
- Tab navigation bar: Audio, Identity, Appearance, Network, Advanced
|
||||
- Audio tab: input device, gain slider + level bar, noise gate toggle, RNNoise toggle, codec select, bitrate select, FEC toggle, DTX toggle, output device, volume slider + level bar
|
||||
- Identity tab: username input, status select, public key, fingerprint
|
||||
- Appearance tab: theme select, accent color picker, compact toggle
|
||||
- Network tab: protocol select, RTT, packet loss, jitter metrics
|
||||
- Advanced tab: packet stats toggle, verbose logging toggle, version badge, runtime info
|
||||
|
||||
### 9. ProfileCard (`profile_card/`)
|
||||
- 300px card, centered overlay
|
||||
- Banner 72px gradient, avatar 56px overlapping
|
||||
- Name + tag (#0001 · server)
|
||||
- Role with colored badge
|
||||
- Info rows: status, RTT, joined date, encryption
|
||||
- Action buttons: Message (primary), Voice Invite, Mute
|
||||
|
||||
## Theme
|
||||
|
||||
All CSS variables from `vnox-ui.html` converted to Slint `property`:
|
||||
|
||||
```slint
|
||||
export global Palette {
|
||||
in-out property <color> bg0: #0c0c0c;
|
||||
in-out property <color> bg1: #111111;
|
||||
in-out property <color> bg2: #181818;
|
||||
in-out property <color> bg3: #202020;
|
||||
in-out property <color> bg4: #2a2a2a;
|
||||
in-out property <color> accent: #ff6b35;
|
||||
in-out property <color> accent-hover: #e85d28;
|
||||
in-out property <color> border: #222222;
|
||||
in-out property <color> border2: #2e2e2e;
|
||||
in-out property <color> text1: #f0f2f5;
|
||||
in-out property <color> text2: #9ca3af;
|
||||
in-out property <color> text3: #4a5568;
|
||||
in-out property <color> green: #4ade80;
|
||||
in-out property <color> red: #f87171;
|
||||
in-out property <color> yellow: #fbbf24;
|
||||
in-out property <color> teal: #5bbf9f;
|
||||
}
|
||||
```
|
||||
|
||||
Accent color is user-configurable via the Appearance tab (color picker input).
|
||||
|
||||
Avatar color palette (8 colours, indexed by hash of user ID):
|
||||
`#c0522a`, `#7a52c4`, `#3a9e5f`, `#c43a7a`, `#3a7ac4`, `#9e7a3a`, `#3a9e9e`, `#8a3ac4`
|
||||
|
||||
## Dependencies
|
||||
|
||||
**Removed:**
|
||||
- `eframe = "0.33"` (and transitive egui/wgpu deps)
|
||||
|
||||
**Added:**
|
||||
- `slint = "2.x"` (winit + FemtoVG backend)
|
||||
|
||||
Everything else (tokio, serde, crypto, opus, cpal, rodio, nnnoiseless) stays.
|
||||
|
||||
## Migration steps
|
||||
|
||||
1. Create new `client/src/ui/` structure with Slint files
|
||||
2. Wire `app.rs` with `slint::Window::run()` replacing `eframe::run_native()`
|
||||
3. Implement `theme/` — palette, typography, spacing
|
||||
4. Implement `components/` — avatar, toggle, badge, icon_button, slider, level_bar
|
||||
5. Implement `connect/` — connect screen (independent, good first block)
|
||||
6. Implement `main_area/` — rail, sidebar, userbar, chat, members
|
||||
7. Implement `voice_overlay/` — full voice UI
|
||||
8. Implement `settings/` — modal with all tabs
|
||||
9. Implement `profile_card/`
|
||||
10. Wire all callbacks → existing net/audio layers
|
||||
11. Remove old egui code, clean up `Cargo.toml`
|
||||
12. Test build, fix clippy, cargo fmt
|
||||
792
docs/vnox-ui.html
Normal file
792
docs/vnox-ui.html
Normal file
|
|
@ -0,0 +1,792 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>VNOX</title>
|
||||
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Sans:wght@300;400;500;600;700&family=IBM+Plex+Mono:wght@400;500&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
|
||||
:root{
|
||||
--bg0:#0c0c0c;--bg1:#111111;--bg2:#181818;--bg3:#202020;--bg4:#2a2a2a;
|
||||
--accent:#ff6b35;--accent2:#e85d28;
|
||||
--border:#222222;--border2:#2e2e2e;
|
||||
--text1:#f0f2f5;--text2:#9ca3af;--text3:#4a5568;
|
||||
--green:#4ade80;--red:#f87171;--yellow:#fbbf24;--teal:#5bbf9f;
|
||||
--font:'IBM Plex Sans',sans-serif;--mono:'IBM Plex Mono',monospace;
|
||||
}
|
||||
html,body{height:100%;overflow:hidden;background:var(--bg0);color:var(--text1);font-family:var(--font);font-size:14px;-webkit-font-smoothing:antialiased}
|
||||
::-webkit-scrollbar{width:3px}::-webkit-scrollbar-track{background:transparent}::-webkit-scrollbar-thumb{background:var(--bg4);border-radius:4px}
|
||||
|
||||
/* ═══ LAYOUT ═══ */
|
||||
.app{display:grid;grid-template-columns:56px 228px 1fr 252px;grid-template-rows:1fr 52px;height:100vh}
|
||||
|
||||
/* ═══ RAIL ═══ */
|
||||
.rail{grid-column:1;grid-row:1/3;background:var(--bg1);border-right:1px solid var(--border);display:flex;flex-direction:column;align-items:center;padding:10px 0 14px;gap:6px;overflow:hidden}
|
||||
.rail-logo{width:36px;height:36px;border-radius:10px;background:var(--accent);display:flex;align-items:center;justify-content:center;cursor:pointer;transition:border-radius .2s,transform .1s;flex-shrink:0;margin-bottom:4px}
|
||||
.rail-logo:hover{border-radius:14px;transform:scale(1.06)}
|
||||
.rail-logo svg{width:18px;height:18px;fill:#fff}
|
||||
.rail-sep{width:28px;height:1px;background:var(--border);flex-shrink:0;margin:2px 0}
|
||||
.srv{width:36px;height:36px;border-radius:10px;display:flex;align-items:center;justify-content:center;font-weight:700;font-size:12px;cursor:pointer;transition:border-radius .2s,transform .1s;position:relative;flex-shrink:0}
|
||||
.srv::before{content:'';position:absolute;left:-8px;top:50%;transform:translateY(-50%);width:3px;border-radius:0 3px 3px 0;background:var(--text1);transition:height .15s;height:0}
|
||||
.srv:hover{border-radius:14px;transform:scale(1.05)}.srv:hover::before{height:8px}
|
||||
.srv.active{border-radius:14px}.srv.active::before{height:20px}
|
||||
.srv-a{background:#1e1e1e;color:#c88b5a;border:1px solid #2a2a2a}
|
||||
.srv-b{background:#1e1a1e;color:#b07cc6;border:1px solid #2a242a}
|
||||
.srv-add{background:transparent;color:var(--text3);font-size:18px;border:1px dashed var(--border2);margin-top:auto;margin-bottom:18px}
|
||||
.srv-add:hover{background:var(--bg2);color:var(--green);border-color:rgba(74,222,128,.3)}
|
||||
/* tooltip */
|
||||
[data-tip]{position:relative}
|
||||
[data-tip]:hover::after{content:attr(data-tip);position:absolute;left:calc(100% + 12px);top:50%;transform:translateY(-50%);background:var(--bg4);color:var(--text1);font-size:12px;white-space:nowrap;padding:5px 10px;border-radius:6px;border:1px solid var(--border2);pointer-events:none;z-index:200}
|
||||
.rail-vnox{font-size:9px;font-weight:700;letter-spacing:.18em;color:var(--text3);font-family:var(--mono);margin-top:auto}
|
||||
|
||||
/* ═══ CHANNELS ═══ */
|
||||
.channels{grid-column:2;grid-row:1;background:var(--bg1);border-right:1px solid var(--border);display:flex;flex-direction:column;overflow:hidden}
|
||||
.srv-hdr{padding:14px 14px 10px;border-bottom:1px solid var(--border);flex-shrink:0}
|
||||
.srv-hdr-name{font-weight:700;font-size:14px;color:var(--text1)}
|
||||
.srv-hdr-id{font-size:10px;color:var(--text3);font-family:var(--mono);margin-top:2px}
|
||||
.ch-search{padding:8px 10px;flex-shrink:0}
|
||||
.ch-search-inner{display:flex;align-items:center;gap:6px;background:var(--bg0);border:1px solid var(--border);border-radius:6px;padding:5px 10px;transition:border-color .15s}
|
||||
.ch-search-inner:focus-within{border-color:var(--border2)}
|
||||
.ch-search-inner svg{width:12px;height:12px;fill:var(--text3);flex-shrink:0}
|
||||
.ch-search-input{background:none;border:none;outline:none;font-family:var(--font);font-size:12px;color:var(--text2);width:100%}
|
||||
.ch-search-input::placeholder{color:var(--text3)}
|
||||
.ch-scroll{flex:1;overflow-y:auto;padding:4px 8px 8px}
|
||||
.ch-sec-hdr{display:flex;align-items:center;gap:5px;padding:8px 6px 3px;font-size:10px;font-weight:600;letter-spacing:.1em;text-transform:uppercase;color:var(--text3);cursor:pointer;border-radius:4px;transition:color .15s}
|
||||
.ch-sec-hdr:hover{color:var(--text2)}
|
||||
.ch-sec-hdr svg{width:11px;height:11px;fill:currentColor;flex-shrink:0}
|
||||
.ch-item{display:flex;align-items:center;gap:7px;padding:6px 6px;border-radius:5px;cursor:pointer;color:var(--text3);font-size:13px;font-weight:500;transition:background .1s,color .1s;position:relative}
|
||||
.ch-item:hover{background:var(--bg2);color:var(--text2)}
|
||||
.ch-item.active{background:var(--bg3);color:var(--text1)}
|
||||
.ch-item.active::before{content:'';position:absolute;left:-8px;top:4px;bottom:4px;width:2px;background:var(--accent);border-radius:0 2px 2px 0}
|
||||
.ch-icon{flex-shrink:0;width:16px;text-align:center;font-size:11px}
|
||||
.ch-badge{margin-left:auto;font-size:10px;font-family:var(--mono);padding:1px 5px;border-radius:8px;letter-spacing:.02em}
|
||||
.ch-badge.ping{color:var(--green);background:rgba(74,222,128,.08)}
|
||||
.ch-badge.unread{color:#fff;background:var(--accent);min-width:18px;text-align:center}
|
||||
.ch-badge.count{color:var(--text3);background:var(--bg3)}
|
||||
.vu-list{padding:2px 0 4px 20px}
|
||||
.vu-row{display:flex;align-items:center;gap:7px;padding:3px 6px;border-radius:5px;cursor:pointer;font-size:12px;color:var(--text3);transition:background .1s}
|
||||
.vu-row:hover{background:var(--bg2);color:var(--text2)}
|
||||
.vu-av{width:18px;height:18px;border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:7px;font-weight:700;flex-shrink:0;position:relative;color:#fff}
|
||||
.vu-dot{width:7px;height:7px;border-radius:50%;position:absolute;bottom:-1px;right:-1px;border:1px solid var(--bg1)}
|
||||
.vu-speaking{font-size:8px;color:var(--green);margin-left:auto}
|
||||
|
||||
/* ═══ USERBAR ═══ */
|
||||
.userbar{grid-column:2;grid-row:2;background:var(--bg1);border-right:1px solid var(--border);border-top:1px solid var(--border);display:flex;align-items:center;padding:0 8px;gap:8px}
|
||||
.ub-av{width:30px;height:30px;border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:11px;font-weight:700;flex-shrink:0;cursor:pointer;position:relative;color:#fff;transition:transform .15s}
|
||||
.ub-av:hover{transform:scale(1.08)}
|
||||
.ub-status{width:9px;height:9px;border-radius:50%;position:absolute;bottom:-1px;right:-1px;border:2px solid var(--bg1)}
|
||||
.ub-info{flex:1;min-width:0}
|
||||
.ub-name{font-size:12px;font-weight:600;color:var(--text1);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
||||
.ub-sub{font-size:10px;color:var(--text3);font-family:var(--mono)}
|
||||
.ub-icons{display:flex;gap:2px}
|
||||
.ub-btn{width:26px;height:26px;border-radius:5px;display:flex;align-items:center;justify-content:center;cursor:pointer;color:var(--text3);transition:background .15s,color .15s}
|
||||
.ub-btn:hover{background:var(--bg3);color:var(--text1)}
|
||||
.ub-btn.muted{color:var(--red)}
|
||||
.ub-btn svg{width:14px;height:14px;fill:currentColor}
|
||||
|
||||
/* ═══ MAIN ═══ */
|
||||
.main{grid-column:3;grid-row:1/3;display:flex;flex-direction:column;background:var(--bg0);overflow:hidden}
|
||||
.main-hdr{height:44px;flex-shrink:0;border-bottom:1px solid var(--border);display:flex;align-items:center;padding:0 16px;gap:8px;background:var(--bg0)}
|
||||
.mh-icon{font-size:14px;font-weight:700;color:var(--text3)}
|
||||
.mh-name{font-weight:600;font-size:14px;color:var(--text1)}
|
||||
.mh-sep{width:1px;height:14px;background:var(--border2)}
|
||||
.mh-desc{font-size:12px;color:var(--text3);flex:1;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
||||
.mh-actions{display:flex;gap:2px}
|
||||
.mh-btn{width:28px;height:28px;border-radius:5px;display:flex;align-items:center;justify-content:center;cursor:pointer;color:var(--text3);transition:background .15s,color .15s}
|
||||
.mh-btn:hover{background:var(--bg2);color:var(--text1)}
|
||||
.mh-btn svg{width:14px;height:14px;fill:currentColor}
|
||||
|
||||
/* Messages */
|
||||
.messages{flex:1;overflow-y:auto;padding:8px 0 4px}
|
||||
.day-sep{display:flex;align-items:center;gap:10px;padding:10px 16px;font-size:10px;color:var(--text3);font-family:var(--mono);letter-spacing:.06em}
|
||||
.day-sep::before,.day-sep::after{content:'';flex:1;height:1px;background:var(--border)}
|
||||
.sys-msg{display:flex;align-items:center;gap:10px;padding:6px 16px;font-size:11px;color:var(--text3);font-family:var(--mono)}
|
||||
.sys-msg::before,.sys-msg::after{content:'';flex:1;height:1px;background:var(--border)}
|
||||
|
||||
/* Message groups — bubble style */
|
||||
.msg-group{padding:4px 16px;position:relative;transition:background .08s}
|
||||
.msg-group:hover{background:rgba(255,255,255,.015)}
|
||||
.msg-group.ns{margin-top:10px}
|
||||
.msg-group:hover .msg-actions{opacity:1;pointer-events:all}
|
||||
.msg-layout{display:flex;gap:12px;align-items:flex-start}
|
||||
.msg-av{width:34px;height:34px;border-radius:50%;flex-shrink:0;display:flex;align-items:center;justify-content:center;font-size:11px;font-weight:700;cursor:pointer;color:#fff;transition:transform .1s;margin-top:1px}
|
||||
.msg-av:hover{transform:scale(1.06)}
|
||||
.msg-cont{flex:1;min-width:0;background:var(--bg2);border:1px solid var(--border);border-radius:4px 12px 12px 12px;padding:8px 12px}
|
||||
.msg-hdr{display:flex;align-items:baseline;gap:8px;margin-bottom:4px}
|
||||
.msg-author{font-size:13px;font-weight:600;cursor:pointer}
|
||||
.msg-author:hover{text-decoration:underline}
|
||||
.msg-time{font-size:10px;color:var(--text3);font-family:var(--mono)}
|
||||
.msg-text{font-size:13px;color:var(--text2);line-height:1.6;word-break:break-word}
|
||||
.msg-text a{color:var(--teal);text-decoration:none}
|
||||
.msg-text a:hover{text-decoration:underline}
|
||||
/* continuation messages in same group */
|
||||
.msg-cont-inline{margin-left:46px;background:var(--bg2);border:1px solid var(--border);border-radius:4px 12px 12px 12px;padding:7px 12px;margin-top:3px}
|
||||
/* floating actions */
|
||||
.msg-actions{position:absolute;right:16px;top:-14px;background:var(--bg3);border:1px solid var(--border2);border-radius:7px;display:flex;gap:1px;padding:3px;opacity:0;pointer-events:none;transition:opacity .1s;z-index:10}
|
||||
.ma-btn{width:26px;height:26px;border-radius:4px;display:flex;align-items:center;justify-content:center;cursor:pointer;font-size:12px;color:var(--text3);transition:background .1s,color .1s}
|
||||
.ma-btn:hover{background:var(--bg4);color:var(--text1)}
|
||||
/* sys msg inside group */
|
||||
.msg-group.sys-g .msg-cont{background:transparent;border:none;padding:0}
|
||||
.msg-group.sys-g .msg-av{background:var(--bg3) !important;color:var(--text3);font-size:14px}
|
||||
|
||||
/* Input */
|
||||
.input-area{padding:0 12px 12px;flex-shrink:0}
|
||||
.input-box{background:var(--bg2);border:1px solid var(--border2);border-radius:9px;overflow:hidden;transition:border-color .2s}
|
||||
.input-box:focus-within{border-color:rgba(255,107,53,.3)}
|
||||
.input-top{display:flex;align-items:center;padding:0 14px;gap:10px}
|
||||
.input-ch{font-family:var(--mono);font-size:11px;color:var(--text3);flex-shrink:0;padding:11px 0;border-right:1px solid var(--border);padding-right:10px}
|
||||
.input-field{flex:1;background:none;border:none;outline:none;font-family:var(--font);font-size:13px;color:var(--text1);padding:11px 0}
|
||||
.input-field::placeholder{color:var(--text3)}
|
||||
.input-actions{display:flex;gap:3px;padding:0 10px 8px;border-top:1px solid var(--border);margin-top:0}
|
||||
.ia-btn{display:flex;align-items:center;gap:4px;padding:4px 8px;border-radius:5px;cursor:pointer;font-size:11px;color:var(--text3);transition:background .1s,color .1s}
|
||||
.ia-btn:hover{background:var(--bg3);color:var(--text2)}
|
||||
.ia-btn svg{width:12px;height:12px;fill:currentColor;flex-shrink:0}
|
||||
.ia-right{margin-left:auto;display:flex;align-items:center;gap:4px}
|
||||
.ia-send{background:var(--accent);color:#fff;border-radius:5px;padding:4px 12px;font-weight:600;font-size:11px;cursor:pointer;transition:background .15s}
|
||||
.ia-send:hover{background:var(--accent2)}
|
||||
.ia-enc{display:flex;align-items:center;gap:4px;font-size:10px;color:var(--text3);font-family:var(--mono);padding:4px 8px}
|
||||
.ia-enc svg{width:11px;height:11px;fill:var(--green)}
|
||||
|
||||
/* ═══ MEMBERS ═══ */
|
||||
.members{grid-column:4;grid-row:1/3;background:var(--bg1);border-left:1px solid var(--border);overflow-y:auto;padding:14px 8px;transition:width .2s,opacity .2s}
|
||||
.members.hidden{display:none}
|
||||
.mb-label{font-size:10px;font-weight:600;letter-spacing:.1em;text-transform:uppercase;color:var(--text3);padding:0 8px;margin-bottom:5px;font-family:var(--mono)}
|
||||
.mb-section{margin-bottom:18px}
|
||||
.mb-item{display:flex;align-items:center;gap:9px;padding:5px 8px;border-radius:6px;cursor:pointer;transition:background .1s}
|
||||
.mb-item:hover{background:var(--bg2)}
|
||||
.mb-av{width:30px;height:30px;border-radius:50%;flex-shrink:0;display:flex;align-items:center;justify-content:center;font-size:11px;font-weight:700;position:relative;color:#fff}
|
||||
.mb-status{width:9px;height:9px;border-radius:50%;position:absolute;bottom:-1px;right:-1px;border:2px solid var(--bg1)}
|
||||
.mb-name{font-size:13px;font-weight:500;color:var(--text2);transition:color .1s}
|
||||
.mb-item:hover .mb-name{color:var(--text1)}
|
||||
.mb-tag{font-size:10px;color:var(--text3);margin-top:1px;font-family:var(--mono)}
|
||||
.st-on{background:var(--green)}.st-idle{background:var(--yellow)}.st-dnd{background:var(--red)}.st-off{background:var(--text3)}
|
||||
|
||||
/* Avatar color palette — 8 saturated */
|
||||
.av1{background:#c0522a}.av2{background:#7a52c4}.av3{background:#3a9e5f}.av4{background:#c43a7a}.av5{background:#3a7ac4}.av6{background:#9e7a3a}.av7{background:#3a9e9e}.av8{background:#8a3ac4}
|
||||
|
||||
/* ═══ VOICE OVERLAY ═══ */
|
||||
.voice-ov{position:fixed;inset:0;background:rgba(8,8,8,.96);backdrop-filter:blur(20px);display:none;z-index:50;flex-direction:column;align-items:center;justify-content:center;gap:24px}
|
||||
.voice-ov.show{display:flex;animation:fadeIn .18s}
|
||||
@keyframes fadeIn{from{opacity:0}to{opacity:1}}
|
||||
.vo-hdr{text-align:center}
|
||||
.vo-title{font-size:20px;font-weight:700;letter-spacing:.02em}
|
||||
.vo-meta{display:flex;align-items:center;justify-content:center;gap:14px;margin-top:6px;font-size:11px;font-family:var(--mono);color:var(--text3)}
|
||||
.vo-meta-item{display:flex;align-items:center;gap:5px}
|
||||
.vo-meta-item .dot{width:6px;height:6px;border-radius:50%;background:var(--green)}
|
||||
/* Speaker grid — main speaker large, rest small */
|
||||
.vo-grid{display:flex;flex-direction:column;align-items:center;gap:12px;width:100%;max-width:640px;padding:0 24px}
|
||||
.vo-main{width:100%;max-width:500px;background:var(--bg2);border:2px solid var(--border2);border-radius:16px;padding:32px 24px;display:flex;flex-direction:column;align-items:center;gap:14px;transition:border-color .2s,box-shadow .2s;position:relative}
|
||||
.vo-main.speaking{border-color:rgba(74,222,128,.5);box-shadow:0 0 32px rgba(74,222,128,.1)}
|
||||
.vo-main-av{width:88px;height:88px;border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:28px;font-weight:700;color:#fff;border:3px solid transparent;transition:border-color .2s}
|
||||
.vo-main.speaking .vo-main-av{border-color:var(--green)}
|
||||
.vo-main-name{font-size:16px;font-weight:600;color:var(--text1)}
|
||||
.vo-main-status{display:flex;align-items:center;gap:6px;font-size:11px;font-family:var(--mono);color:var(--text3)}
|
||||
.vo-badge{padding:2px 8px;border-radius:4px;border:1px solid var(--border2);font-size:10px;font-family:var(--mono)}
|
||||
.vo-small-row{display:flex;gap:10px;justify-content:center;flex-wrap:wrap}
|
||||
.vo-small{background:var(--bg2);border:1px solid var(--border2);border-radius:12px;padding:14px 20px;display:flex;flex-direction:column;align-items:center;gap:8px;cursor:pointer;transition:border-color .2s,background .15s;min-width:110px}
|
||||
.vo-small:hover{background:var(--bg3)}
|
||||
.vo-small.speaking{border-color:rgba(74,222,128,.4)}
|
||||
.vo-small-av{width:48px;height:48px;border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:16px;font-weight:700;color:#fff;border:2px solid transparent;transition:border-color .2s}
|
||||
.vo-small.speaking .vo-small-av{border-color:var(--green)}
|
||||
.vo-small-name{font-size:12px;font-weight:600;color:var(--text2)}
|
||||
.vo-small-icons{display:flex;gap:4px}
|
||||
.vo-ico{width:18px;height:18px;border-radius:4px;display:flex;align-items:center;justify-content:center;font-size:9px;background:var(--bg3);color:var(--text3)}
|
||||
.vo-ico.muted{color:var(--red);background:rgba(248,113,113,.1)}
|
||||
/* Controls */
|
||||
.vo-controls{display:flex;gap:8px}
|
||||
.vo-btn{width:44px;height:44px;border-radius:50%;display:flex;align-items:center;justify-content:center;cursor:pointer;background:var(--bg2);border:1px solid var(--border2);transition:background .15s,border-color .15s,transform .1s}
|
||||
.vo-btn:hover{background:var(--bg3);transform:scale(1.06)}
|
||||
.vo-btn svg{width:16px;height:16px;fill:var(--text2)}
|
||||
.vo-btn.tog{background:rgba(248,113,113,.12);border-color:rgba(248,113,113,.3)}
|
||||
.vo-btn.tog svg{fill:var(--red)}
|
||||
.vo-btn.disc{background:var(--accent);border-color:var(--accent2)}
|
||||
.vo-btn.disc svg{fill:#fff}
|
||||
.vo-btn.disc:hover{background:var(--accent2)}
|
||||
|
||||
/* ═══ SETTINGS OVERLAY ═══ */
|
||||
.settings-ov{position:fixed;inset:0;background:rgba(8,8,8,.7);backdrop-filter:blur(16px);display:none;z-index:60;align-items:center;justify-content:center}
|
||||
.settings-ov.show{display:flex;animation:fadeIn .18s}
|
||||
.s-modal{width:700px;max-height:88vh;background:var(--bg1);border:1px solid var(--border2);border-radius:14px;overflow:hidden;display:flex;flex-direction:column;box-shadow:0 32px 80px rgba(0,0,0,.6)}
|
||||
.s-hdr{height:52px;background:var(--bg0);border-bottom:1px solid var(--border);display:flex;align-items:center;padding:0 20px;gap:12px;flex-shrink:0}
|
||||
.s-hdr-logo{font-size:14px;font-weight:700;letter-spacing:.1em;color:var(--accent);font-family:var(--mono)}
|
||||
.s-hdr-node{font-size:11px;color:var(--text2);display:flex;align-items:center;gap:6px;font-family:var(--mono)}
|
||||
.s-hdr-dot{width:6px;height:6px;border-radius:50%;background:var(--green)}
|
||||
.s-badge{font-size:10px;padding:2px 7px;border-radius:4px;border:1px solid rgba(255,107,53,.25);background:rgba(255,107,53,.08);color:var(--accent);font-family:var(--mono)}
|
||||
.s-close{margin-left:auto;width:28px;height:28px;border-radius:6px;display:flex;align-items:center;justify-content:center;cursor:pointer;color:var(--text3);font-size:14px;transition:background .15s,color .15s}
|
||||
.s-close:hover{background:var(--bg3);color:var(--text1)}
|
||||
/* Tab nav */
|
||||
.s-tabs{display:flex;gap:2px;padding:12px 20px;border-bottom:1px solid var(--border);flex-shrink:0;background:var(--bg1)}
|
||||
.s-tab{display:flex;align-items:center;gap:7px;padding:7px 14px;border-radius:7px;font-size:13px;color:var(--text3);cursor:pointer;transition:background .1s,color .1s;font-weight:500}
|
||||
.s-tab:hover{background:var(--bg2);color:var(--text2)}
|
||||
.s-tab.active{background:var(--bg3);color:var(--text1)}
|
||||
.s-tab svg{width:14px;height:14px;fill:currentColor;flex-shrink:0}
|
||||
.s-content{flex:1;overflow-y:auto;padding:24px 28px}
|
||||
.pg{display:none}.pg.active{display:block}
|
||||
.sc-title{font-size:17px;font-weight:700;color:var(--text1);margin-bottom:3px}
|
||||
.sc-sub{font-size:11px;color:var(--text3);margin-bottom:20px;font-family:var(--mono)}
|
||||
.sc-group{margin-bottom:22px}
|
||||
.sc-gl{font-size:10px;font-weight:600;letter-spacing:.1em;text-transform:uppercase;color:var(--text3);border-bottom:1px solid var(--border);padding-bottom:5px;margin-bottom:10px}
|
||||
.sc-row{display:flex;align-items:center;justify-content:space-between;padding:8px 0;border-bottom:1px solid rgba(255,255,255,.025);gap:12px}
|
||||
.sc-rl{font-size:13px;color:var(--text2)}
|
||||
.sc-rs{font-size:11px;color:var(--text3);margin-top:2px;font-family:var(--mono)}
|
||||
.s-select{background:var(--bg2);border:1px solid var(--border2);border-radius:6px;color:var(--text2);font-family:var(--font);font-size:12px;padding:6px 28px 6px 10px;outline:none;cursor:pointer;min-width:150px;-webkit-appearance:none;background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6'%3E%3Cpath d='M1 1l4 4 4-4' stroke='%234a5568' stroke-width='1.5' fill='none' stroke-linecap='round'/%3E%3C/svg%3E");background-repeat:no-repeat;background-position:right 10px center;transition:border-color .15s}
|
||||
.s-select:focus{border-color:rgba(255,107,53,.4)}
|
||||
.s-input{background:var(--bg2);border:1px solid var(--border2);border-radius:6px;color:var(--text2);font-family:var(--font);font-size:12px;padding:6px 10px;outline:none;width:100%;max-width:260px;transition:border-color .15s,color .15s}
|
||||
.s-input:focus{border-color:rgba(255,107,53,.4);color:var(--text1)}
|
||||
/* Sliders */
|
||||
.s-slider{-webkit-appearance:none;width:160px;height:3px;border-radius:2px;background:var(--bg4);outline:none;cursor:pointer}
|
||||
.s-slider::-webkit-slider-thumb{-webkit-appearance:none;width:14px;height:14px;border-radius:50%;background:var(--accent);cursor:pointer;box-shadow:0 0 6px rgba(255,107,53,.4)}
|
||||
/* Toggle */
|
||||
.toggle{position:relative;width:34px;height:18px;cursor:pointer;flex-shrink:0}
|
||||
.toggle input{display:none}
|
||||
.t-track{width:34px;height:18px;border-radius:9px;background:var(--bg4);border:1px solid var(--border2);transition:background .15s,border-color .15s}
|
||||
.toggle input:checked+.t-track{background:rgba(255,107,53,.2);border-color:rgba(255,107,53,.35)}
|
||||
.t-thumb{position:absolute;top:3px;left:3px;width:12px;height:12px;border-radius:50%;background:var(--text3);transition:left .15s cubic-bezier(.2,0,0,1.4),background .15s}
|
||||
.toggle input:checked~.t-thumb{left:19px;background:var(--accent)}
|
||||
/* Level bars */
|
||||
.lvl-wrap{display:flex;align-items:center;gap:10px}
|
||||
.lvl-bar{width:120px;height:3px;background:var(--bg4);border-radius:2px;overflow:hidden}
|
||||
.lvl-fill{height:100%;border-radius:2px;transition:width .1s}
|
||||
.lvl-fill.in{background:var(--accent)}.lvl-fill.out{background:var(--green)}
|
||||
.lvl-val{font-size:11px;color:var(--text3);font-family:var(--mono);min-width:36px}
|
||||
.key-badge{font-size:11px;color:var(--text3);border:1px solid var(--border2);padding:2px 8px;border-radius:4px;font-family:var(--mono);background:var(--bg2)}
|
||||
|
||||
/* ═══ CONNECT SCREEN ═══ */
|
||||
.connect-scr{position:fixed;inset:0;background:var(--bg0);display:none;z-index:70;align-items:center;justify-content:center}
|
||||
.connect-scr.show{display:flex;animation:fadeIn .2s}
|
||||
.cn-bg{position:absolute;inset:0;background:radial-gradient(ellipse 50% 40% at 50% 50%,rgba(255,107,53,.05) 0%,transparent 70%);pointer-events:none}
|
||||
.cn-box{width:340px;position:relative;z-index:1}
|
||||
.cn-logo-wrap{display:flex;align-items:center;gap:10px;margin-bottom:8px}
|
||||
.cn-logo-icon{width:36px;height:36px;border-radius:10px;background:var(--accent);display:flex;align-items:center;justify-content:center}
|
||||
.cn-logo-icon svg{width:18px;height:18px;fill:#fff}
|
||||
.cn-logo-text{font-size:22px;font-weight:700;letter-spacing:.12em;color:var(--text1);font-family:var(--mono)}
|
||||
.cn-tagline{font-size:11px;color:var(--text3);margin-bottom:28px;font-family:var(--mono)}
|
||||
.cn-field{margin-bottom:14px}
|
||||
.cn-label{font-size:10px;font-weight:600;color:var(--text3);text-transform:uppercase;letter-spacing:.1em;margin-bottom:6px;display:block;font-family:var(--mono)}
|
||||
.cn-input{width:100%;background:var(--bg1);border:1px solid var(--border2);border-radius:7px;color:var(--text1);font-family:var(--mono);font-size:13px;padding:10px 14px;outline:none;transition:border-color .15s}
|
||||
.cn-input:focus{border-color:rgba(255,107,53,.4)}
|
||||
.cn-row{display:flex;gap:8px;margin-top:20px}
|
||||
.cn-btn{flex:1;background:var(--accent);border:none;border-radius:7px;color:#fff;font-family:var(--font);font-size:13px;font-weight:600;padding:10px;cursor:pointer;transition:background .15s,transform .1s}
|
||||
.cn-btn:hover{background:var(--accent2);transform:translateY(-1px)}
|
||||
.cn-btn-ghost{background:var(--bg2);border:1px solid var(--border2);border-radius:7px;color:var(--text2);font-family:var(--font);font-size:13px;padding:10px 16px;cursor:pointer;transition:background .15s}
|
||||
.cn-btn-ghost:hover{background:var(--bg3)}
|
||||
.cn-recent{margin-top:20px;border-top:1px solid var(--border);padding-top:14px}
|
||||
.cn-recent-label{font-size:10px;font-weight:600;color:var(--text3);text-transform:uppercase;letter-spacing:.1em;margin-bottom:8px;font-family:var(--mono)}
|
||||
.cn-srv{display:flex;align-items:center;gap:10px;padding:7px 10px;border-radius:6px;cursor:pointer;transition:background .1s;border:1px solid transparent}
|
||||
.cn-srv:hover{background:var(--bg2);border-color:var(--border)}
|
||||
.cn-srv-av{width:28px;height:28px;border-radius:7px;display:flex;align-items:center;justify-content:center;font-size:11px;font-weight:700;color:#fff}
|
||||
.cn-srv-name{font-size:13px;font-weight:600;color:var(--text2)}
|
||||
.cn-srv-addr{font-size:10px;color:var(--text3);font-family:var(--mono)}
|
||||
.cn-srv-ping{font-size:10px;font-family:var(--mono)}
|
||||
|
||||
/* ═══ PROFILE CARD ═══ */
|
||||
.profile-ov{position:fixed;inset:0;background:rgba(8,8,8,.7);backdrop-filter:blur(12px);display:none;z-index:55;align-items:center;justify-content:center}
|
||||
.profile-ov.show{display:flex;animation:fadeIn .18s}
|
||||
.profile-card{width:300px;background:var(--bg1);border:1px solid var(--border2);border-radius:14px;overflow:hidden;box-shadow:0 24px 60px rgba(0,0,0,.5)}
|
||||
.pc-banner{height:72px;background:linear-gradient(135deg,#1a1a1a,#2a2a2a);position:relative}
|
||||
.pc-banner::after{content:'';position:absolute;inset:0;background:rgba(255,107,53,.06)}
|
||||
.pc-av-wrap{position:absolute;bottom:-28px;left:16px}
|
||||
.pc-av{width:56px;height:56px;border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:18px;font-weight:700;color:#fff;border:3px solid var(--bg1)}
|
||||
.pc-body{padding:36px 16px 16px}
|
||||
.pc-name{font-size:15px;font-weight:700;color:var(--text1)}
|
||||
.pc-tag{font-size:11px;color:var(--text3);font-family:var(--mono);margin-top:2px}
|
||||
.pc-role{display:inline-flex;align-items:center;gap:5px;margin-top:8px;padding:3px 8px;border-radius:4px;font-size:11px;font-weight:600;border:1px solid}
|
||||
.pc-info{margin-top:12px;border-top:1px solid var(--border);padding-top:10px}
|
||||
.pc-info-row{display:flex;justify-content:space-between;align-items:center;padding:4px 0;font-size:12px}
|
||||
.pc-info-l{color:var(--text3);font-family:var(--mono)}
|
||||
.pc-info-r{color:var(--text2)}
|
||||
.pc-actions{display:flex;gap:6px;margin-top:14px;padding-top:12px;border-top:1px solid var(--border)}
|
||||
.pc-btn{flex:1;padding:7px;border-radius:7px;font-size:12px;font-weight:600;cursor:pointer;text-align:center;transition:background .15s;border:1px solid var(--border2);color:var(--text2);background:var(--bg2)}
|
||||
.pc-btn:hover{background:var(--bg3);color:var(--text1)}
|
||||
.pc-btn.primary{background:var(--accent);border-color:var(--accent2);color:#fff}
|
||||
.pc-btn.primary:hover{background:var(--accent2)}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<!-- ═══ CONNECT SCREEN ═══ -->
|
||||
<div class="connect-scr show" id="connectScr">
|
||||
<div class="cn-bg"></div>
|
||||
<div class="cn-box">
|
||||
<div class="cn-logo-wrap">
|
||||
<div class="cn-logo-icon"><svg viewBox="0 0 24 24"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-1 14H9V8h2v8zm4 0h-2V8h2v8z"/></svg></div>
|
||||
<div class="cn-logo-text">VNOX</div>
|
||||
</div>
|
||||
<div class="cn-tagline">// secure voice & text · quic/v1</div>
|
||||
<div class="cn-field"><label class="cn-label">Address</label><input class="cn-input" type="text" value="nightcore.lnex" placeholder="server.address"></div>
|
||||
<div class="cn-field"><label class="cn-label">Username</label><input class="cn-input" type="text" value="nekit" placeholder="username"></div>
|
||||
<div class="cn-field"><label class="cn-label">Password</label><input class="cn-input" type="password" placeholder="passphrase"></div>
|
||||
<div class="cn-row">
|
||||
<button class="cn-btn" onclick="document.getElementById('connectScr').classList.remove('show')">Connect</button>
|
||||
<button class="cn-btn-ghost">Keypair</button>
|
||||
</div>
|
||||
<div class="cn-recent">
|
||||
<div class="cn-recent-label">Recent</div>
|
||||
<div class="cn-srv" onclick="document.getElementById('connectScr').classList.remove('show')">
|
||||
<div class="cn-srv-av av1">NC</div>
|
||||
<div style="flex:1"><div class="cn-srv-name">nightcore community</div><div class="cn-srv-addr">nightcore.lnex</div></div>
|
||||
<div class="cn-srv-ping" style="color:var(--green)">24ms</div>
|
||||
</div>
|
||||
<div class="cn-srv">
|
||||
<div class="cn-srv-av av2">VD</div>
|
||||
<div style="flex:1"><div class="cn-srv-name">void.lnex</div><div class="cn-srv-addr">void.lnex</div></div>
|
||||
<div class="cn-srv-ping" style="color:var(--yellow)">81ms</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ═══ SETTINGS (modal overlay) ═══ -->
|
||||
<div class="settings-ov" id="settingsOv">
|
||||
<div class="s-modal">
|
||||
<div class="s-hdr">
|
||||
<div class="s-hdr-logo">VNOX</div>
|
||||
<div style="width:1px;height:16px;background:var(--border2)"></div>
|
||||
<div class="s-hdr-node"><div class="s-hdr-dot"></div>nightcore.lnex</div>
|
||||
<div class="s-badge">quic/v1</div>
|
||||
<div class="s-close" onclick="document.getElementById('settingsOv').classList.remove('show')">✕</div>
|
||||
</div>
|
||||
<div class="s-tabs">
|
||||
<div class="s-tab active" onclick="switchTab('voice',this)">
|
||||
<svg viewBox="0 0 16 16"><path d="M5 3a3 3 0 0 1 6 0v5a3 3 0 0 1-6 0V3z"/><path d="M3.5 6.5A.5.5 0 0 1 4 7v1a4 4 0 0 0 8 0V7a.5.5 0 0 1 1 0v1a5 5 0 0 1-4.5 4.975V15h3a.5.5 0 0 1 0 1h-7a.5.5 0 0 1 0-1h3v-2.025A5 5 0 0 1 3 8V7a.5.5 0 0 1 .5-.5z"/></svg>Audio
|
||||
</div>
|
||||
<div class="s-tab" onclick="switchTab('identity',this)">
|
||||
<svg viewBox="0 0 16 16"><path d="M8 8a3 3 0 1 0 0-6 3 3 0 0 0 0 6zm-5 6s-1 0-1-1 1-4 6-4 6 3 6 4-1 1-1 1H3z"/></svg>Identity
|
||||
</div>
|
||||
<div class="s-tab" onclick="switchTab('appearance',this)">
|
||||
<svg viewBox="0 0 16 16"><path d="M8 5a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3zm4 3a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3zM5.5 7a1.5 1.5 0 1 1-3 0 1.5 1.5 0 0 1 3 0zm.5 6a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3z"/><path d="M16 8c0 3.15-1.866 2.585-3.567 2.07C11.42 9.763 10.465 9.5 10 9.5c-.943 0-2 .115-2 2.5a2.5 2.5 0 0 1-2.5 2.5C3.016 14.5 0 12.88 0 8a8 8 0 1 1 16 0z"/></svg>Appearance
|
||||
</div>
|
||||
<div class="s-tab" onclick="switchTab('network',this)">
|
||||
<svg viewBox="0 0 16 16"><path d="M8 1a7 7 0 1 0 0 14A7 7 0 0 0 8 1zM0 8a8 8 0 1 1 16 0A8 8 0 0 1 0 8z"/></svg>Network
|
||||
</div>
|
||||
<div class="s-tab" onclick="switchTab('advanced',this)">
|
||||
<svg viewBox="0 0 16 16"><path d="M9.405 1.05c-.413-1.4-2.397-1.4-2.81 0l-.1.34a1.464 1.464 0 0 1-2.105.872l-.31-.17c-1.283-.698-2.686.705-1.987 1.987l.169.311c.446.82.023 1.841-.872 2.105l-.34.1c-1.4.413-1.4 2.397 0 2.81l.34.1a1.464 1.464 0 0 1 .872 2.105l-.17.31c-.698 1.283.705 2.686 1.987 1.987l.311-.169a1.464 1.464 0 0 1 2.105.872l.1.34c.413 1.4 2.397 1.4 2.81 0l.1-.34a1.464 1.464 0 0 1 2.105-.872l.31.17c1.283.698 2.686-.705 1.987-1.987l-.169-.311a1.464 1.464 0 0 1 .872-2.105l.34-.1c1.4-.413 1.4-2.397 0-2.81l-.34-.1a1.464 1.464 0 0 1-.872-2.105l.17-.31c.698-1.283-.705-2.686-1.987-1.987l-.311.169a1.464 1.464 0 0 1-2.105-.872l-.1-.34zM8 10.93a2.929 2.929 0 1 1 0-5.86 2.929 2.929 0 0 1 0 5.858z"/></svg>Advanced
|
||||
</div>
|
||||
</div>
|
||||
<div class="s-content">
|
||||
<div class="pg active" id="pg-voice">
|
||||
<div class="sc-title">Audio</div><div class="sc-sub">// input · output · encoding</div>
|
||||
<div class="sc-group"><div class="sc-gl">Input</div>
|
||||
<div class="sc-row"><div><div class="sc-rl">Microphone</div><div class="sc-rs">Capture device</div></div><select class="s-select"><option>Shure MV7</option><option>HyperX QuadCast</option><option>Built-in Mic</option></select></div>
|
||||
<div class="sc-row"><div><div class="sc-rl">Input gain</div></div><div class="lvl-wrap"><input type="range" class="s-slider" min="0" max="100" value="72"><span class="lvl-val">72%</span></div></div>
|
||||
<div class="sc-row"><div><div class="sc-rl">Input level</div></div><div class="lvl-wrap"><div class="lvl-bar"><div class="lvl-fill in" id="lvl1" style="width:72%"></div></div><span class="lvl-val" id="lvlv1">-8 dB</span></div></div>
|
||||
<div class="sc-row"><div><div class="sc-rl">Noise gate</div><div class="sc-rs">Cut below threshold</div></div><label class="toggle"><input type="checkbox" checked><div class="t-track"></div><div class="t-thumb"></div></label></div>
|
||||
<div class="sc-row"><div><div class="sc-rl">RNNoise</div><div class="sc-rs">ML noise suppression</div></div><label class="toggle"><input type="checkbox" checked><div class="t-track"></div><div class="t-thumb"></div></label></div>
|
||||
</div>
|
||||
<div class="sc-group"><div class="sc-gl">Encoding</div>
|
||||
<div class="sc-row"><div><div class="sc-rl">Codec</div></div><select class="s-select"><option>Opus 48kHz</option><option>Opus 24kHz</option></select></div>
|
||||
<div class="sc-row"><div><div class="sc-rl">Bitrate</div></div><select class="s-select"><option>96 kbps</option><option>64 kbps</option><option>128 kbps</option></select></div>
|
||||
<div class="sc-row"><div><div class="sc-rl">FEC</div><div class="sc-rs">Forward error correction</div></div><label class="toggle"><input type="checkbox" checked><div class="t-track"></div><div class="t-thumb"></div></label></div>
|
||||
<div class="sc-row"><div><div class="sc-rl">DTX</div><div class="sc-rs">Discontinuous tx</div></div><label class="toggle"><input type="checkbox"><div class="t-track"></div><div class="t-thumb"></div></label></div>
|
||||
</div>
|
||||
<div class="sc-group"><div class="sc-gl">Output</div>
|
||||
<div class="sc-row"><div><div class="sc-rl">Device</div></div><select class="s-select"><option>SteelSeries Arctis</option><option>Built-in Output</option></select></div>
|
||||
<div class="sc-row"><div><div class="sc-rl">Output volume</div></div><div class="lvl-wrap"><input type="range" class="s-slider" min="0" max="100" value="80"><span class="lvl-val">80%</span></div></div>
|
||||
<div class="sc-row"><div><div class="sc-rl">Output level</div></div><div class="lvl-wrap"><div class="lvl-bar"><div class="lvl-fill out" id="lvl2" style="width:55%"></div></div><span class="lvl-val">-14 dB</span></div></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="pg" id="pg-identity">
|
||||
<div class="sc-title">Identity</div><div class="sc-sub">// display · keypair</div>
|
||||
<div class="sc-group"><div class="sc-gl">Display</div>
|
||||
<div class="sc-row"><div><div class="sc-rl">Username</div></div><input class="s-input" value="nekit"></div>
|
||||
<div class="sc-row"><div><div class="sc-rl">Status</div></div><select class="s-select"><option>Online</option><option>Away</option><option>Do not disturb</option></select></div>
|
||||
</div>
|
||||
<div class="sc-group"><div class="sc-gl">Keypair</div>
|
||||
<div class="sc-row"><div><div class="sc-rl">Public key</div><div class="sc-rs" style="font-family:var(--mono)">a1b2c3d4…9f0e</div></div></div>
|
||||
<div class="sc-row"><div><div class="sc-rl">Fingerprint</div><div class="sc-rs" style="font-family:var(--mono)">SHA256:xK7m…</div></div></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="pg" id="pg-appearance">
|
||||
<div class="sc-title">Appearance</div><div class="sc-sub">// theme · density</div>
|
||||
<div class="sc-group"><div class="sc-gl">Theme</div>
|
||||
<div class="sc-row"><div><div class="sc-rl">Base theme</div></div><select class="s-select"><option>Dark Graphite</option></select></div>
|
||||
<div class="sc-row"><div><div class="sc-rl">Accent color</div></div><input type="color" value="#ff6b35" style="width:36px;height:28px;border:none;border-radius:5px;cursor:pointer;background:none"></div>
|
||||
<div class="sc-row"><div><div class="sc-rl">Compact messages</div></div><label class="toggle"><input type="checkbox"><div class="t-track"></div><div class="t-thumb"></div></label></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="pg" id="pg-network">
|
||||
<div class="sc-title">Network</div><div class="sc-sub">// transport · diagnostics</div>
|
||||
<div class="sc-group"><div class="sc-gl">Transport</div>
|
||||
<div class="sc-row"><div><div class="sc-rl">Protocol</div></div><select class="s-select"><option>QUIC (LNEx v1)</option><option>TCP fallback</option></select></div>
|
||||
<div class="sc-row"><div><div class="sc-rl">RTT</div></div><span style="font-family:var(--mono);font-size:12px;color:var(--green)">24ms</span></div>
|
||||
<div class="sc-row"><div><div class="sc-rl">Packet loss</div></div><span style="font-family:var(--mono);font-size:12px;color:var(--text3)">0.0%</span></div>
|
||||
<div class="sc-row"><div><div class="sc-rl">Jitter</div></div><span style="font-family:var(--mono);font-size:12px;color:var(--text3)">1.2ms</span></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="pg" id="pg-advanced">
|
||||
<div class="sc-title">Advanced</div><div class="sc-sub">// debug · build</div>
|
||||
<div class="sc-group"><div class="sc-gl">Debug</div>
|
||||
<div class="sc-row"><div><div class="sc-rl">Show packet stats</div></div><label class="toggle"><input type="checkbox"><div class="t-track"></div><div class="t-thumb"></div></label></div>
|
||||
<div class="sc-row"><div><div class="sc-rl">Verbose logging</div></div><label class="toggle"><input type="checkbox"><div class="t-track"></div><div class="t-thumb"></div></label></div>
|
||||
</div>
|
||||
<div class="sc-group"><div class="sc-gl">Build</div>
|
||||
<div class="sc-row"><div><div class="sc-rl">Version</div></div><span class="key-badge">0.4.1-dev</span></div>
|
||||
<div class="sc-row"><div><div class="sc-rl">Runtime</div></div><span style="font-size:11px;font-family:var(--mono);color:var(--text3)">Tokio 1.x · CPAL</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ═══ VOICE OVERLAY ═══ -->
|
||||
<div class="voice-ov" id="voiceOv">
|
||||
<div class="vo-hdr">
|
||||
<div class="vo-title">Lounge</div>
|
||||
<div class="vo-meta">
|
||||
<div class="vo-meta-item"><div class="dot"></div><span style="color:var(--green)">Excellent</span></div>
|
||||
<span style="color:var(--border2)">·</span>
|
||||
<span style="color:var(--green)">24ms</span><span style="color:var(--text3)">rtt</span>
|
||||
<span style="color:var(--border2)">·</span>
|
||||
<span>Opus 48kHz</span>
|
||||
<span style="color:var(--border2)">·</span>
|
||||
<span style="color:var(--text3)">0.0% loss</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="vo-grid">
|
||||
<!-- Main speaker (large) -->
|
||||
<div class="vo-main speaking" id="voMain">
|
||||
<div class="vo-main-av av1" id="voMainAv">NK</div>
|
||||
<div class="vo-main-name" id="voMainName">nekit</div>
|
||||
<div class="vo-main-status">
|
||||
<div class="vo-badge" style="color:var(--green);border-color:rgba(74,222,128,.2);background:rgba(74,222,128,.05)">speaking</div>
|
||||
<div class="vo-badge" style="color:var(--text3)">admin</div>
|
||||
<div class="vo-badge" style="color:var(--accent);border-color:rgba(255,107,53,.2)">🔒 E2E</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Secondary speakers (small) -->
|
||||
<div class="vo-small-row">
|
||||
<div class="vo-small" onclick="focusUser('sylvans','av2','SY')">
|
||||
<div class="vo-small-av av2">SY</div>
|
||||
<div class="vo-small-name">sylvans</div>
|
||||
<div class="vo-small-icons"><div class="vo-ico muted">🎤</div><div class="vo-ico">🎧</div></div>
|
||||
</div>
|
||||
<div class="vo-small" onclick="focusUser('morfey','av3','MF')">
|
||||
<div class="vo-small-av av3">MF</div>
|
||||
<div class="vo-small-name">morfey</div>
|
||||
<div class="vo-small-icons"><div class="vo-ico">🎤</div><div class="vo-ico">🎧</div></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="vo-controls">
|
||||
<div class="vo-btn tog" id="voMicBtn" title="Muted" onclick="this.classList.toggle('tog')">
|
||||
<svg viewBox="0 0 16 16"><path d="M6.717 3.55A.5.5 0 0 1 7 4v8a.5.5 0 0 1-.812.39L3.825 10.5H1.5A.5.5 0 0 1 1 10V6a.5.5 0 0 1 .5-.5h2.325L6.188 3.61a.5.5 0 0 1 .53-.06z"/><path d="M10.707 11.182A4.486 4.486 0 0 0 12.025 8a4.486 4.486 0 0 0-1.318-3.182L9.99 5.525A3.489 3.489 0 0 1 11.025 8 3.49 3.49 0 0 1 9.99 10.475l.717.707z"/></svg>
|
||||
</div>
|
||||
<div class="vo-btn" title="Headphones" onclick="this.classList.toggle('tog')">
|
||||
<svg viewBox="0 0 16 16"><path d="M8 3a5 5 0 0 0-5 5v1h1a1 1 0 0 1 1 1v3a1 1 0 0 1-1 1H3a1 1 0 0 1-1-1V8a6 6 0 1 1 12 0v5a1 1 0 0 1-1 1h-1a1 1 0 0 1-1-1v-3a1 1 0 0 1 1-1h1V8a5 5 0 0 0-5-5z"/></svg>
|
||||
</div>
|
||||
<div class="vo-btn" title="Screen share">
|
||||
<svg viewBox="0 0 16 16"><path d="M0 4s0-2 2-2h12s2 0 2 2v6s0 2-2 2h-4c0 .667.083 1.167.25 1.5H11a.5.5 0 0 1 0 1H5a.5.5 0 0 1 0-1h.75c.167-.333.25-.833.25-1.5H2s-2 0-2-2V4zm1.398-.855a.758.758 0 0 0-.254.302A1.46 1.46 0 0 0 1 4.01V10c0 .325.078.502.145.602.07.105.17.188.302.254a1.464 1.464 0 0 0 .538.143L2.01 11H14c.325 0 .502-.078.602-.145a.758.758 0 0 0 .254-.302 1.464 1.464 0 0 0 .143-.538L15 9.99V4c0-.325-.078-.502-.145-.602a.757.757 0 0 0-.302-.254A1.46 1.46 0 0 0 13.99 3H2c-.325 0-.502.078-.602.145z"/></svg>
|
||||
</div>
|
||||
<div class="vo-btn" title="Camera" onclick="this.classList.toggle('tog')">
|
||||
<svg viewBox="0 0 16 16"><path d="M0 5a2 2 0 0 1 2-2h7.5a2 2 0 0 1 1.983 1.738l1.482-1.482A.5.5 0 0 1 14 3.5v9a.5.5 0 0 1-.85.354l-1.481-1.481A2 2 0 0 1 9.5 13H2a2 2 0 0 1-2-2V5z"/></svg>
|
||||
</div>
|
||||
<div class="vo-btn disc" title="Disconnect" onclick="document.getElementById('voiceOv').classList.remove('show')">
|
||||
<svg viewBox="0 0 16 16" style="fill:#fff"><path d="M1.166 4.25c.08.03.15.08.2.14.8.5.15 1.07.15 1.14l-.4 1.22a1.1 1.1 0 0 0 .17 1.01l.06.08c.3.4.77.63 1.26.63h.87a1.1 1.1 0 0 0 .87-.43l.46-.62a.5.5 0 0 1 .4-.2h2a.5.5 0 0 1 .4.2l.46.62c.2.27.52.43.87.43h.87c.49 0 .96-.23 1.26-.63l.06-.08a1.1 1.1 0 0 0 .17-1.01l-.4-1.22c0-.07.07-.64.15-1.14.05-.06.12-.11.2-.14.34-.12.54-.47.46-.82-.27-1.2-1.6-2-3.3-2.12A15.3 15.3 0 0 0 8 2c-.83 0-1.63.05-2.33.14C4.04 2.27 2.7 3.07 2.43 4.27c-.08.35.12.7.46.82l.27.14z"/></svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ═══ PROFILE CARD ═══ -->
|
||||
<div class="profile-ov" id="profileOv">
|
||||
<div class="profile-card">
|
||||
<div class="pc-banner"><div class="pc-av-wrap"><div class="pc-av av1" id="pcAv">NK</div></div></div>
|
||||
<div class="pc-body">
|
||||
<div class="pc-name" id="pcName">nekit</div>
|
||||
<div class="pc-tag" id="pcTag">nekit#0001 · nightcore.lnex</div>
|
||||
<div class="pc-role" id="pcRole" style="color:#fb923c;border-color:rgba(251,146,60,.2);background:rgba(251,146,60,.05)">Admin</div>
|
||||
<div class="pc-info">
|
||||
<div class="pc-info-row"><span class="pc-info-l">status</span><span class="pc-info-r" style="color:var(--green)">● Online</span></div>
|
||||
<div class="pc-info-row"><span class="pc-info-l">rtt</span><span class="pc-info-r" style="font-family:var(--mono)">24ms</span></div>
|
||||
<div class="pc-info-row"><span class="pc-info-l">joined</span><span class="pc-info-r">Mar 2025</span></div>
|
||||
<div class="pc-info-row"><span class="pc-info-l">encryption</span><span class="pc-info-r" style="color:var(--green)">🔒 E2E</span></div>
|
||||
</div>
|
||||
<div class="pc-actions">
|
||||
<div class="pc-btn primary">Message</div>
|
||||
<div class="pc-btn">Voice Invite</div>
|
||||
<div class="pc-btn">Mute</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style="position:absolute;inset:0;z-index:-1" onclick="document.getElementById('profileOv').classList.remove('show')"></div>
|
||||
</div>
|
||||
|
||||
<!-- ═══ MAIN APP ═══ -->
|
||||
<div class="app">
|
||||
|
||||
<!-- RAIL -->
|
||||
<div class="rail">
|
||||
<div class="rail-logo" data-tip="VNOX" onclick="document.getElementById('connectScr').classList.add('show')">
|
||||
<svg viewBox="0 0 24 24"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-1 14H9V8h2v8zm4 0h-2V8h2v8z"/></svg>
|
||||
</div>
|
||||
<div class="rail-sep"></div>
|
||||
<div class="srv srv-a active" data-tip="nightcore.lnex">NC</div>
|
||||
<div class="srv srv-b" data-tip="void.lnex">VD</div>
|
||||
<div class="rail-sep"></div>
|
||||
<div class="srv srv-add" data-tip="Add server">+</div>
|
||||
<div class="rail-vnox">VNOX</div>
|
||||
</div>
|
||||
|
||||
<!-- CHANNELS -->
|
||||
<div class="channels">
|
||||
<div class="srv-hdr">
|
||||
<div class="srv-hdr-name">nightcore community</div>
|
||||
<div class="srv-hdr-id">lnex://nc.a1b2c3d4</div>
|
||||
</div>
|
||||
<div class="ch-search">
|
||||
<div class="ch-search-inner">
|
||||
<svg viewBox="0 0 16 16"><path d="M11.742 10.344a6.5 6.5 0 1 0-1.397 1.398h-.001c.03.04.062.078.098.115l3.85 3.85a1 1 0 0 0 1.415-1.414l-3.85-3.85a1.007 1.007 0 0 0-.115-.1zM12 6.5a5.5 5.5 0 1 1-11 0 5.5 5.5 0 0 1 11 0z"/></svg>
|
||||
<input class="ch-search-input" placeholder="Search channels…">
|
||||
</div>
|
||||
</div>
|
||||
<div class="ch-scroll">
|
||||
<div class="ch-sec-hdr">
|
||||
<svg viewBox="0 0 16 16"><path d="M2 2a2 2 0 0 1 2-2h8a2 2 0 0 1 2 2v13.5a.5.5 0 0 1-.777.416L8 13.101l-5.223 2.815A.5.5 0 0 1 2 15.5V2zm2-1a1 1 0 0 0-1 1v12.566l4.723-2.482a.5.5 0 0 1 .554 0L13 14.566V2a1 1 0 0 0-1-1H4z"/></svg>
|
||||
TEXT
|
||||
</div>
|
||||
<div class="ch-item active"><span class="ch-icon">#</span><span>general</span><span class="ch-badge ping">24ms</span></div>
|
||||
<div class="ch-item"><span class="ch-icon">#</span><span>announcements</span><span class="ch-badge unread">3</span></div>
|
||||
<div class="ch-item"><span class="ch-icon">#</span><span>tech-support</span><span class="ch-badge unread">1</span></div>
|
||||
<div class="ch-item"><span class="ch-icon">#</span><span>off-topic</span></div>
|
||||
<div class="ch-item"><span class="ch-icon">#</span><span>music-sharing</span></div>
|
||||
<div class="ch-sec-hdr" style="margin-top:6px">
|
||||
<svg viewBox="0 0 16 16"><path d="M5 3a3 3 0 0 1 6 0v5a3 3 0 0 1-6 0V3z"/><path d="M3.5 6.5A.5.5 0 0 1 4 7v1a4 4 0 0 0 8 0V7a.5.5 0 0 1 1 0v1a5 5 0 0 1-4.5 4.975V15h3a.5.5 0 0 1 0 1h-7a.5.5 0 0 1 0-1h3v-2.025A5 5 0 0 1 3 8V7a.5.5 0 0 1 .5-.5z"/></svg>
|
||||
VOICE
|
||||
</div>
|
||||
<div class="ch-item"><span class="ch-icon" style="font-size:9px;color:var(--text3)">▷</span><span>Lobby</span></div>
|
||||
<div class="ch-item" style="color:var(--text2)" onclick="document.getElementById('voiceOv').classList.add('show')">
|
||||
<span class="ch-icon" style="font-size:9px;color:var(--green)">▶</span><span>Lounge</span><span class="ch-badge count">3</span>
|
||||
</div>
|
||||
<div class="vu-list">
|
||||
<div class="vu-row"><div class="vu-av av1">NK<div class="vu-dot st-on"></div></div><span>nekit</span><span class="vu-speaking">●</span></div>
|
||||
<div class="vu-row"><div class="vu-av av2">SY<div class="vu-dot st-on"></div></div><span>sylvans</span></div>
|
||||
<div class="vu-row"><div class="vu-av av3">MF<div class="vu-dot st-on"></div></div><span>morfey</span></div>
|
||||
</div>
|
||||
<div class="ch-item"><span class="ch-icon" style="font-size:9px;color:var(--text3)">▷</span><span>Gaming</span></div>
|
||||
<div class="ch-item"><span class="ch-icon" style="font-size:9px;color:var(--text3)">▷</span><span>Music</span></div>
|
||||
<div class="ch-item"><span class="ch-icon" style="font-size:9px;color:var(--text3)">▷</span><span>AFK</span></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- USERBAR -->
|
||||
<div class="userbar">
|
||||
<div class="ub-av av1" onclick="showProfile('nekit','av1','NK','Admin','admin')">NK<div class="ub-status st-on"></div></div>
|
||||
<div class="ub-info">
|
||||
<div class="ub-name">nekit</div>
|
||||
<div class="ub-sub"><span style="color:var(--green)">24ms</span> · 0.0%</div>
|
||||
</div>
|
||||
<div class="ub-icons">
|
||||
<div class="ub-btn muted" title="Muted" onclick="this.classList.toggle('muted')">
|
||||
<svg viewBox="0 0 16 16"><path d="M6.717 3.55A.5.5 0 0 1 7 4v8a.5.5 0 0 1-.812.39L3.825 10.5H1.5A.5.5 0 0 1 1 10V6a.5.5 0 0 1 .5-.5h2.325L6.188 3.61a.5.5 0 0 1 .53-.06z"/><path d="M10.707 11.182A4.486 4.486 0 0 0 12.025 8a4.486 4.486 0 0 0-1.318-3.182L9.99 5.525A3.489 3.489 0 0 1 11.025 8 3.49 3.49 0 0 1 9.99 10.475l.717.707z"/></svg>
|
||||
</div>
|
||||
<div class="ub-btn" title="Deafen" onclick="this.classList.toggle('muted')">
|
||||
<svg viewBox="0 0 16 16"><path d="M8 3a5 5 0 0 0-5 5v1h1a1 1 0 0 1 1 1v3a1 1 0 0 1-1 1H3a1 1 0 0 1-1-1V8a6 6 0 1 1 12 0v5a1 1 0 0 1-1 1h-1a1 1 0 0 1-1-1v-3a1 1 0 0 1 1-1h1V8a5 5 0 0 0-5-5z"/></svg>
|
||||
</div>
|
||||
<div class="ub-btn" title="Settings" onclick="document.getElementById('settingsOv').classList.add('show')">
|
||||
<svg viewBox="0 0 16 16"><path d="M9.405 1.05c-.413-1.4-2.397-1.4-2.81 0l-.1.34a1.464 1.464 0 0 1-2.105.872l-.31-.17c-1.283-.698-2.686.705-1.987 1.987l.169.311c.446.82.023 1.841-.872 2.105l-.34.1c-1.4.413-1.4 2.397 0 2.81l.34.1a1.464 1.464 0 0 1 .872 2.105l-.17.31c-.698 1.283.705 2.686 1.987 1.987l.311-.169a1.464 1.464 0 0 1 2.105.872l.1.34c.413 1.4 2.397 1.4 2.81 0l.1-.34a1.464 1.464 0 0 1 2.105-.872l.31.17c1.283.698 2.686-.705 1.987-1.987l-.169-.311a1.464 1.464 0 0 1 .872-2.105l.34-.1c1.4-.413 1.4-2.397 0-2.81l-.34-.1a1.464 1.464 0 0 1-.872-2.105l.17-.31c.698-1.283-.705-2.686-1.987-1.987l-.311.169a1.464 1.464 0 0 1-2.105-.872l-.1-.34zM8 10.93a2.929 2.929 0 1 1 0-5.86 2.929 2.929 0 0 1 0 5.858z"/></svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- MAIN CHAT -->
|
||||
<div class="main">
|
||||
<div class="main-hdr">
|
||||
<span class="mh-icon">#</span>
|
||||
<span class="mh-name">general</span>
|
||||
<div class="mh-sep"></div>
|
||||
<span class="mh-desc">Общий чат для всех участников сервера.</span>
|
||||
<div class="mh-actions">
|
||||
<div class="mh-btn" title="Pinned"><svg viewBox="0 0 16 16"><path d="M4.146.146A.5.5 0 0 1 4.5 0h7a.5.5 0 0 1 .5.5c0 .68-.342 1.174-.646 1.479-.126.125-.25.224-.354.298v4.431l.078.048c.203.127.476.314.751.555C12.36 7.775 13 8.527 13 9.5a.5.5 0 0 1-.5.5h-4v4.5c0 .276-.224 2-.5 2s-.5-1.724-.5-2V10h-4a.5.5 0 0 1-.5-.5c0-.973.64-1.725 1.17-2.189A5.921 5.921 0 0 1 5 6.708V2.277a2.77 2.77 0 0 1-.354-.298C4.342 1.674 4 1.179 4 .5a.5.5 0 0 1 .146-.354z"/></svg></div>
|
||||
<div class="mh-btn" title="Search"><svg viewBox="0 0 16 16"><path d="M11.742 10.344a6.5 6.5 0 1 0-1.397 1.398h-.001c.03.04.062.078.098.115l3.85 3.85a1 1 0 0 0 1.415-1.414l-3.85-3.85a1.007 1.007 0 0 0-.115-.1zM12 6.5a5.5 5.5 0 1 1-11 0 5.5 5.5 0 0 1 11 0z"/></svg></div>
|
||||
<div class="mh-btn" title="Toggle members" onclick="toggleMb()"><svg viewBox="0 0 16 16"><path d="M15 14s1 0 1-1-1-4-5-4-5 3-5 4 1 1 1 1h8zm-7.978-1A.261.261 0 0 1 7 12.996c.001-.264.167-1.03.76-1.72C8.312 10.629 9.282 10 11 10c1.717 0 2.687.63 3.24 1.276.593.69.758 1.457.76 1.72l-.008.002-.014.002H7.022zM11 7a2 2 0 1 0 0-4 2 2 0 0 0 0 4zm3-2a3 3 0 1 1-6 0 3 3 0 0 1 6 0zM6.936 9.28a5.88 5.88 0 0 0-1.23-.247A7.35 7.35 0 0 0 5 9c-4 0-5 3-5 4 0 .667.333 1 1 1h4.216A2.238 2.238 0 0 1 5 13c0-1.01.377-2.042 1.09-2.904.243-.294.526-.569.846-.816zM4.92 10A5.493 5.493 0 0 0 4 13H1c0-.26.164-1.03.76-1.724.545-.636 1.492-1.256 3.16-1.275zM1.5 5.5a3 3 0 1 1 6 0 3 3 0 0 1-6 0zm3-2a2 2 0 1 0 0 4 2 2 0 0 0 0-4z"/></svg></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="messages" id="msgs">
|
||||
<div class="sys-msg">connected · nightcore.lnex · quic/v1</div>
|
||||
<!-- Group 1: nekit (2 messages) -->
|
||||
<div class="msg-group ns">
|
||||
<div class="msg-layout">
|
||||
<div class="msg-av av1" onclick="showProfile('nekit','av1','NK','Admin','admin')">NK</div>
|
||||
<div class="msg-cont">
|
||||
<div class="msg-hdr"><span class="msg-author" style="color:#c88b5a" onclick="showProfile('nekit','av1','NK','Admin','admin')">nekit</span><span class="msg-time">Сегодня, 14:32</span></div>
|
||||
<div class="msg-text">Всем привет! Как у вас дела?</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="msg-actions"><div class="ma-btn">↩</div><div class="ma-btn">☺</div><div class="ma-btn">⎘</div></div>
|
||||
</div>
|
||||
<!-- Group 2: morfey -->
|
||||
<div class="msg-group ns">
|
||||
<div class="msg-layout">
|
||||
<div class="msg-av av4" onclick="showProfile('morfey','av4','MF','Admin','admin')">MF</div>
|
||||
<div class="msg-cont">
|
||||
<div class="msg-hdr"><span class="msg-author" style="color:#c43a7a" onclick="showProfile('morfey','av4','MF','Admin','admin')">morfey</span><span class="msg-time">14:33</span></div>
|
||||
<div class="msg-text">Привет! Всё отлично, тестирую новый клиент 👌</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="msg-actions"><div class="ma-btn">↩</div><div class="ma-btn">☺</div><div class="ma-btn">⎘</div></div>
|
||||
</div>
|
||||
<!-- Group 3: sylvans -->
|
||||
<div class="msg-group ns">
|
||||
<div class="msg-layout">
|
||||
<div class="msg-av av2" onclick="showProfile('sylvans','av2','SY','Moderator','moderator')">SY</div>
|
||||
<div class="msg-cont">
|
||||
<div class="msg-hdr"><span class="msg-author" style="color:#9e7a9e" onclick="showProfile('sylvans','av2','SY','Moderator','moderator')">sylvans</span><span class="msg-time">14:34</span></div>
|
||||
<div class="msg-text">Звук просто огонь, спасибо за апдейт!</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="msg-actions"><div class="ma-btn">↩</div><div class="ma-btn">☺</div><div class="ma-btn">⎘</div></div>
|
||||
</div>
|
||||
<!-- Group 4: nekit (2 messages grouped) -->
|
||||
<div class="msg-group ns">
|
||||
<div class="msg-layout">
|
||||
<div class="msg-av av1" onclick="showProfile('nekit','av1','NK','Admin','admin')">NK</div>
|
||||
<div class="msg-cont">
|
||||
<div class="msg-hdr"><span class="msg-author" style="color:#c88b5a">nekit</span><span class="msg-time">14:35</span></div>
|
||||
<div class="msg-text">Рад, что нравится!</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="msg-actions"><div class="ma-btn">↩</div><div class="ma-btn">☺</div><div class="ma-btn">⎘</div></div>
|
||||
</div>
|
||||
<div class="msg-group">
|
||||
<div class="msg-cont-inline">
|
||||
<div class="msg-text">Не забывайте читать <a href="#">#announcements</a> — важные апдейты по протоколу.</div>
|
||||
</div>
|
||||
<div class="msg-actions"><div class="ma-btn">↩</div><div class="ma-btn">☺</div><div class="ma-btn">⎘</div></div>
|
||||
</div>
|
||||
<div class="day-sep">today</div>
|
||||
<!-- System event -->
|
||||
<div class="msg-group ns sys-g">
|
||||
<div class="msg-layout">
|
||||
<div class="msg-av" style="background:var(--bg3);color:var(--text3);font-size:13px">⚙</div>
|
||||
<div class="msg-cont">
|
||||
<div class="msg-hdr"><span class="msg-author" style="color:var(--text3)">system</span><span class="msg-time">14:36</span></div>
|
||||
<div class="msg-text" style="color:var(--text3);font-family:var(--mono);font-size:11px">Guest_42 joined · lnex://nc.a1b2c3d4</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Group 5: Guest_42 -->
|
||||
<div class="msg-group ns">
|
||||
<div class="msg-layout">
|
||||
<div class="msg-av av6">G4</div>
|
||||
<div class="msg-cont">
|
||||
<div class="msg-hdr"><span class="msg-author" style="color:#9e7a3a">Guest_42</span><span class="msg-time">14:36</span></div>
|
||||
<div class="msg-text">Всем ку!</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="msg-actions"><div class="ma-btn">↩</div><div class="ma-btn">☺</div><div class="ma-btn">⎘</div></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="input-area">
|
||||
<div class="input-box">
|
||||
<div class="input-top">
|
||||
<span class="input-ch">#general</span>
|
||||
<input class="input-field" type="text" placeholder="Message #general…" id="msgInput" onkeydown="sendMsg(event)">
|
||||
</div>
|
||||
<div class="input-actions">
|
||||
<div class="ia-btn"><svg viewBox="0 0 16 16"><path d="M.5 9.9a.5.5 0 0 1 .5.5v2.5a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1v-2.5a.5.5 0 0 1 1 0v2.5a2 2 0 0 1-2 2H2a2 2 0 0 1-2-2v-2.5a.5.5 0 0 1 .5-.5z"/><path d="M7.646 11.854a.5.5 0 0 0 .708 0l3-3a.5.5 0 0 0-.708-.708L8.5 10.293V1.5a.5.5 0 0 0-1 0v8.793L5.354 8.146a.5.5 0 1 0-.708.708l3 3z"/></svg>Attach</div>
|
||||
<div class="ia-btn"><svg viewBox="0 0 16 16"><path d="M8 15A7 7 0 1 1 8 1a7 7 0 0 1 0 14zm0 1A8 8 0 1 0 8 0a8 8 0 0 0 0 16z"/><path d="M4.285 9.567a.5.5 0 0 1 .683.183A3.498 3.498 0 0 0 8 11.5a3.498 3.498 0 0 0 3.032-1.75.5.5 0 1 1 .866.5A4.498 4.498 0 0 1 8 12.5a4.498 4.498 0 0 1-3.898-2.25.5.5 0 0 1 .183-.683zM7 6.5C7 7.328 6.552 8 6 8s-1-.672-1-1.5S5.448 5 6 5s1 .672 1 1.5zm4 0c0 .828-.448 1.5-1 1.5s-1-.672-1-1.5S9.448 5 10 5s1 .672 1 1.5z"/></svg>Emoji</div>
|
||||
<div class="ia-right">
|
||||
<div class="ia-enc"><svg viewBox="0 0 16 16"><path d="M8 1a2 2 0 0 1 2 2v4H6V3a2 2 0 0 1 2-2zm3 6V3a3 3 0 0 0-6 0v4a2 2 0 0 0-2 2v5a2 2 0 0 0 2 2h6a2 2 0 0 0 2-2V9a2 2 0 0 0-2-2z"/></svg>E2E</div>
|
||||
<div class="ia-send" onclick="doSend()">Send</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- MEMBERS -->
|
||||
<div class="members" id="mbPanel">
|
||||
<div class="mb-label" style="margin-bottom:12px">УЧАСТНИКИ — 12</div>
|
||||
<div class="mb-section">
|
||||
<div class="mb-label">ADMIN (2)</div>
|
||||
<div class="mb-item" onclick="showProfile('nekit','av1','NK','Admin','admin')"><div class="mb-av av1">NK<div class="mb-status st-on"></div></div><div><div class="mb-name">nekit</div><div class="mb-tag" style="color:#fb923c">Admin</div></div></div>
|
||||
<div class="mb-item" onclick="showProfile('morfey','av4','MF','Admin','admin')"><div class="mb-av av4">MF<div class="mb-status st-on"></div></div><div><div class="mb-name">morfey</div><div class="mb-tag" style="color:#fb923c">Admin</div></div></div>
|
||||
</div>
|
||||
<div class="mb-section">
|
||||
<div class="mb-label">MODERATOR (1)</div>
|
||||
<div class="mb-item" onclick="showProfile('sylvans','av2','SY','Moderator','moderator')"><div class="mb-av av2">SY<div class="mb-status st-on"></div></div><div><div class="mb-name">sylvans</div><div class="mb-tag" style="color:#a07cc6">Moderator</div></div></div>
|
||||
</div>
|
||||
<div class="mb-section">
|
||||
<div class="mb-label">ONLINE (7)</div>
|
||||
<div class="mb-item"><div class="mb-av av6">G4<div class="mb-status st-on"></div></div><div><div class="mb-name">Guest_42</div></div></div>
|
||||
<div class="mb-item"><div class="mb-av av5">DM<div class="mb-status st-on"></div></div><div><div class="mb-name">diman</div></div></div>
|
||||
<div class="mb-item"><div class="mb-av av7">RV<div class="mb-status st-idle"></div></div><div><div class="mb-name">reverse</div></div></div>
|
||||
<div class="mb-item"><div class="mb-av av8">SH<div class="mb-status st-on"></div></div><div><div class="mb-name">shizo</div></div></div>
|
||||
<div class="mb-item"><div class="mb-av av1">KT<div class="mb-status st-on"></div></div><div><div class="mb-name">kitsune</div></div></div>
|
||||
<div class="mb-item"><div class="mb-av av3">LG<div class="mb-status st-dnd"></div></div><div><div class="mb-name">l1ght</div></div></div>
|
||||
<div class="mb-item"><div class="mb-av av2">M4<div class="mb-status st-on"></div></div><div><div class="mb-name">m41k</div></div></div>
|
||||
</div>
|
||||
<div class="mb-section">
|
||||
<div class="mb-label">OFFLINE (2)</div>
|
||||
<div class="mb-item" style="opacity:.35"><div class="mb-av" style="background:var(--bg3);color:var(--text3);font-size:10px">GH<div class="mb-status st-off"></div></div><div><div class="mb-name" style="color:var(--text3)">ghost</div></div></div>
|
||||
<div class="mb-item" style="opacity:.35"><div class="mb-av" style="background:var(--bg3);color:var(--text3);font-size:10px">OL<div class="mb-status st-off"></div></div><div><div class="mb-name" style="color:var(--text3)">oldman</div></div></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Settings tabs
|
||||
function switchTab(name, el) {
|
||||
document.querySelectorAll('.pg').forEach(p => p.classList.remove('active'));
|
||||
document.getElementById('pg-' + name).classList.add('active');
|
||||
document.querySelectorAll('.s-tab').forEach(t => t.classList.remove('active'));
|
||||
el.classList.add('active');
|
||||
}
|
||||
// Members toggle
|
||||
function toggleMb() {
|
||||
document.getElementById('mbPanel').classList.toggle('hidden');
|
||||
}
|
||||
// Send message
|
||||
function sendMsg(e) { if(e.key === 'Enter') doSend(); }
|
||||
function doSend() {
|
||||
const inp = document.getElementById('msgInput');
|
||||
const text = inp.value.trim(); if(!text) return; inp.value = '';
|
||||
const area = document.getElementById('msgs');
|
||||
const now = new Date();
|
||||
const t = now.getHours().toString().padStart(2,'0') + ':' + now.getMinutes().toString().padStart(2,'0');
|
||||
const g = document.createElement('div'); g.className = 'msg-group ns';
|
||||
g.innerHTML = `<div class="msg-layout"><div class="msg-av av1">NK</div><div class="msg-cont"><div class="msg-hdr"><span class="msg-author" style="color:#c88b5a">nekit</span><span class="msg-time">${t}</span></div><div class="msg-text">${text.replace(/</g,'<')}</div></div></div><div class="msg-actions"><div class="ma-btn">↩</div><div class="ma-btn">☺</div><div class="ma-btn">⎘</div></div>`;
|
||||
area.appendChild(g); area.scrollTop = area.scrollHeight;
|
||||
}
|
||||
// Profile card
|
||||
const roleColors = {
|
||||
admin: {color:'#fb923c',border:'rgba(251,146,60,.2)',bg:'rgba(251,146,60,.05)'},
|
||||
moderator: {color:'#a07cc6',border:'rgba(160,124,198,.2)',bg:'rgba(160,124,198,.05)'},
|
||||
member: {color:'var(--text3)',border:'var(--border)',bg:'transparent'}
|
||||
};
|
||||
function showProfile(name, avClass, initials, role, roleKey) {
|
||||
const ov = document.getElementById('profileOv');
|
||||
const av = document.getElementById('pcAv');
|
||||
av.className = 'pc-av ' + avClass;
|
||||
av.textContent = initials;
|
||||
document.getElementById('pcName').textContent = name;
|
||||
document.getElementById('pcTag').textContent = name + '#000' + (Math.floor(Math.random()*9)+1) + ' · nightcore.lnex';
|
||||
const roleEl = document.getElementById('pcRole');
|
||||
const rc = roleColors[roleKey] || roleColors.member;
|
||||
roleEl.textContent = role;
|
||||
roleEl.style.color = rc.color;
|
||||
roleEl.style.borderColor = rc.border;
|
||||
roleEl.style.background = rc.bg;
|
||||
ov.classList.add('show');
|
||||
}
|
||||
// Voice focus
|
||||
function focusUser(name, avClass, initials) {
|
||||
document.getElementById('voMain').classList.add('speaking');
|
||||
const av = document.getElementById('voMainAv');
|
||||
av.className = 'vo-main-av ' + avClass;
|
||||
av.textContent = initials;
|
||||
document.getElementById('voMainName').textContent = name;
|
||||
}
|
||||
// Level bars animation
|
||||
setInterval(() => {
|
||||
const l1 = document.getElementById('lvl1'), l2 = document.getElementById('lvl2');
|
||||
if(l1) l1.style.width = Math.max(5, Math.min(96, 68+(Math.random()-.5)*34))+'%';
|
||||
if(l2) l2.style.width = Math.max(5, Math.min(88, 52+(Math.random()-.5)*28))+'%';
|
||||
}, 150);
|
||||
// Close settings/profile on backdrop click
|
||||
document.getElementById('settingsOv').addEventListener('click', function(e) {
|
||||
if(e.target === this) this.classList.remove('show');
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Loading…
Add table
Add a link
Reference in a new issue