add client docs, README, CHANGELOG
This commit is contained in:
parent
98ad2c4d75
commit
c465f53307
16 changed files with 2933 additions and 0 deletions
202
CHANGELOG.md
Normal file
202
CHANGELOG.md
Normal file
|
|
@ -0,0 +1,202 @@
|
||||||
|
# Changelog
|
||||||
|
|
||||||
|
All notable changes to this project are documented here.
|
||||||
|
Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
||||||
|
|
||||||
|
## [Unreleased]
|
||||||
|
|
||||||
|
### Added — Phase 2 guild member list + identity export/import + channel rate limit (fourth batch)
|
||||||
|
|
||||||
|
#### Phase 2 guild member list with kick / role-assign UI (Phase E4)
|
||||||
|
- **New PacketIds:** `GuildMemberListFetch` (0x010E), `GuildMemberList` (0x010F), `GuildRoleAssign` (0x0110), `GuildRoleUnassign` (0x0111), `GuildRoleListFetch` (0x0112), `GuildRoleList` (0x0113).
|
||||||
|
- **New storage methods:**
|
||||||
|
- `Storage::list_guild_members(guild_id)` — returns each member's nickname, joined_at, highest-role color + name (via SQL subqueries on `member_roles`/`roles`).
|
||||||
|
- `Storage::assign_role(guild_id, user_id, role_id)` — idempotent insert into `member_roles`.
|
||||||
|
- `Storage::remove_role_from_user(guild_id, user_id, role_id)`.
|
||||||
|
- `Storage::list_guild_roles(guild_id)` — full role rows with permissions + position.
|
||||||
|
- **New gateway handlers:** `handle_member_list_fetch`, `handle_role_assign`, `handle_role_unassign`, `handle_role_list_fetch`.
|
||||||
|
- Role assign/unassign require `MANAGE_ROLES` permission (or owner bypass) and append to the audit log.
|
||||||
|
- **Client wiring:** `NetCommand::{GuildMemberListFetch, GuildRoleAssign, GuildRoleUnassign, GuildRoleListFetch}` + `NetEvent::{GuildMemberList, GuildRoleList}` + dispatch + UI handling.
|
||||||
|
- **👥 button in guild bar** — opens the guild members modal (disabled when no guild is selected). On open, fetches both member list and role list in parallel.
|
||||||
|
- **Guild members modal:**
|
||||||
|
- Member rows with role-colored avatar ring + nickname + role name + truncated user ID.
|
||||||
|
- 👑 crown indicator for the guild owner.
|
||||||
|
- 👢 kick button (hidden for owner) — calls `GuildMemberKick` and auto-refreshes the list after 200 ms.
|
||||||
|
- "+ role" combo — lists all guild roles; clicking a role name calls `GuildRoleAssign`.
|
||||||
|
- ↻ refresh button.
|
||||||
|
- "N members" header with close button.
|
||||||
|
|
||||||
|
#### Phase 2 server-side channel rate limit (Phase E6)
|
||||||
|
- `handle_channel_create` now consumes a token from the per-session rate limiter (same bucket as chat/DM messages).
|
||||||
|
- When exceeded, replies `ErrorPayload{ RateLimited }` with message "slow down — too many channel operations".
|
||||||
|
- Bumps `rate_limited_events_total` counter.
|
||||||
|
- Default channels ("general", "voice") remain protected from deletion (already in place from previous batch).
|
||||||
|
|
||||||
|
#### Phase 2 identity export / import to keyfile (Phase E7)
|
||||||
|
- **`identity::export_keyfile(identity, passphrase)`** — serializes the identity to JSON, seals it with the same Argon2id + ChaCha20-Poly1305 scheme as the on-disk vault, returns the keyfile JSON string. Passphrase is optional (empty = plain JSON).
|
||||||
|
- **`identity::import_keyfile(keyfile_json, passphrase)`** — opens a keyfile and returns the deserialized identity (without persisting — caller calls `save()` separately).
|
||||||
|
- **Export keyfile modal** in Settings → Identity:
|
||||||
|
- Optional passphrase + confirm field (with mismatch check).
|
||||||
|
- "Generate keyfile" button produces the JSON output.
|
||||||
|
- Output shown in a scrollable multiline text box.
|
||||||
|
- "⧉ Copy" button to clipboard.
|
||||||
|
- "Save to disk" button writes to `~/Documents/vnox-<nickname>-<pubkey8>.vnoxkey`.
|
||||||
|
- **Import keyfile modal:**
|
||||||
|
- Multiline paste area for the keyfile JSON.
|
||||||
|
- Optional passphrase field.
|
||||||
|
- "Import & Replace Identity" button — calls `import_keyfile` + `save(identity, None)`.
|
||||||
|
- Warning that the current identity will be replaced; restart required.
|
||||||
|
- New `UiState` fields: `export_keyfile_open`, `export_keyfile_pass`, `export_keyfile_confirm`, `export_keyfile_output`, `export_keyfile_error`, `import_keyfile_open`, `import_keyfile_input`, `import_keyfile_pass`, `import_keyfile_error`.
|
||||||
|
|
||||||
|
### Build verification
|
||||||
|
|
||||||
|
- `cargo clippy --workspace` — 0 warnings.
|
||||||
|
- `cargo test --workspace` — 41/41 tests pass.
|
||||||
|
|
||||||
|
### Added — Phase 2 server-side channels + identity vault UI + audit log (third batch, kept for history)
|
||||||
|
|
||||||
|
#### Phase 2 server-side channel management
|
||||||
|
- **`ChannelCreate` packet (0x0033)** — register a new channel in the gateway's channel store.
|
||||||
|
- Validates `kind` ("text" or "voice"); rejects empty `channel_id`.
|
||||||
|
- Returns `ChannelState` to the creator and broadcasts `ChannelCreate` to all other sessions.
|
||||||
|
- Returns `ErrorPayload{ ChannelNotFound }` (reused code) if a channel with the same id exists.
|
||||||
|
- **`ChannelDelete` packet (0x0034)** — remove a channel from the store.
|
||||||
|
- Protects default channels ("general", "voice") with `PermissionDenied`.
|
||||||
|
- Broadcasts `ChannelDelete` to all sessions; client removes it from sidebar and clears cached messages.
|
||||||
|
- **`ChannelList` packet (0x0035)** — fetch all known channels. Client replaces its local list with the server-authoritative one (preserving member lists for channels already joined).
|
||||||
|
- New `channels::create` / `channels::delete` / `channels::list` ops on the gateway.
|
||||||
|
- Client wiring: `NetCommand::{ChannelCreate, ChannelDelete, ChannelList}` + `NetEvent::{ChannelCreated, ChannelDeleted, ChannelListEvent}` + dispatch + UI handling.
|
||||||
|
- **Channel create popup** now sends `ChannelCreate` to the gateway instead of adding locally — sidebar updates come back through the broadcast.
|
||||||
|
- **Channel delete context menu** — right-click any non-default channel in the sidebar to delete it.
|
||||||
|
|
||||||
|
#### Phase 2 identity vault UI
|
||||||
|
- **Set-passphrase modal** in Settings → Identity:
|
||||||
|
- Two passphrase fields with match check + min-8-char warning.
|
||||||
|
- Calls `identity::save(identity, Some(passphrase))` to encrypt the keypair at rest with Argon2id + ChaCha20-Poly1305.
|
||||||
|
- Shows error messages on save failure.
|
||||||
|
- **Remove-passphrase modal** — type `REMOVE` to confirm; calls `identity::save(identity, None)` to revert to plain JSON.
|
||||||
|
- **Vault status block** — shows current encryption state (🔒 encrypted / 🔓 plain) with explanatory text.
|
||||||
|
- **Copy pubkey button** — copies the hex pubkey to clipboard via `egui::Context::copy_text`.
|
||||||
|
- New `UiState` fields: `vault_set_open`, `vault_remove_open`, `vault_passphrase_input`, `vault_passphrase_confirm`, `vault_error`.
|
||||||
|
|
||||||
|
#### Phase 2 audit log viewer
|
||||||
|
- **`GuildAuditLogFetch` packet (0x010C)** — admin-only endpoint; requires `MANAGE_GUILD` permission (or owner).
|
||||||
|
- Returns last 50 audit log entries (clamped 1..=200) via `GuildAuditLogPayload`.
|
||||||
|
- New storage method `Storage::get_audit_log(guild_id, limit)` — newest first.
|
||||||
|
- Client wiring: `NetCommand::GuildAuditLogFetch`, `NetEvent::GuildAuditLog`, dispatch, `UiState.audit_log_entries`.
|
||||||
|
- **📋 button in guild bar** — opens the audit log modal for the active guild (disabled when no guild is selected).
|
||||||
|
- **Audit log modal** — scrollable list of entries with:
|
||||||
|
- Action-specific icons (🏗 create, 🗑 delete, 👢 kick, 🏷 role, 🔗 invite, ✓ accept).
|
||||||
|
- Actor ID (truncated to 8 hex chars), target ID, reason (italic).
|
||||||
|
- Relative timestamp ("2m ago", "3h ago", "5d ago").
|
||||||
|
- Empty state with explanation.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Renumbered `PID_FRIEND_EVENT` from 0x0155 to 0x0158 to avoid collision with server `BlockUser=0x0155` (already done in previous batch, re-confirmed here).
|
||||||
|
|
||||||
|
### Build verification (third batch)
|
||||||
|
|
||||||
|
- `cargo clippy --workspace` — 0 warnings.
|
||||||
|
- `cargo test --workspace` — 41/41 tests pass.
|
||||||
|
|
||||||
|
### Added — Phase 2 hardening + Phase 1.3 polish (second batch, kept for history)
|
||||||
|
|
||||||
|
#### Phase 2 server hardening
|
||||||
|
- **Admin HTTP server (axum) on gateway.** Default bind `0.0.0.0:7601`, configurable via `[gateway] admin_bind`.
|
||||||
|
- `GET /health` — JSON `{status, uptime_seconds, node, address, private_mode}` for orchestrator liveness probes.
|
||||||
|
- `GET /version` — `{name, version, lnex_version}`.
|
||||||
|
- `GET /metrics` — Prometheus text exposition format v0.0.4 with counters/gauges for messages, DMs, voice packets, connections, auth failures, rate-limited events, errors, guilds, friends requests, sessions, channels, uptime.
|
||||||
|
- **Per-session token-bucket rate limiting** on chat and DM messages.
|
||||||
|
- Configurable via `[gateway] message_rate_per_sec` (default 5) and `message_rate_burst` (default 10).
|
||||||
|
- When a session exceeds the limit, server replies `ErrorPayload{ code: RateLimited }` and bumps `rate_limited_events_total` counter.
|
||||||
|
- Idle buckets are pruned on session disconnect via `RateLimiter::remove()`.
|
||||||
|
- **Prometheus metrics module** (`gateway/src/admin/metrics.rs`) with lock-free atomic counters shared across all session tasks.
|
||||||
|
- **`ensure_column` idempotent migration helper** — adds new SQLite columns on existing databases without dropping data. Used to add `messages.reply_to` for the reply feature.
|
||||||
|
|
||||||
|
#### Phase 2 identity security
|
||||||
|
- **Encrypted identity vault** (`client/src/identity_vault.rs`) — Argon2id passphrase + ChaCha20-Poly1305 AEAD.
|
||||||
|
- Vault format: `{version, scheme, salt, nonce, ciphertext, plaintext}` (JSON).
|
||||||
|
- When passphrase is set, identity is encrypted at rest with Argon2id (m=64 MiB, t=3, p=4) for KDF + ChaCha20-Poly1305 AEAD.
|
||||||
|
- When passphrase is empty, falls back to plain JSON (legacy behavior preserved).
|
||||||
|
- Backward-compatible: legacy `identity.json` is auto-migrated to `identity.vault.json` on next save.
|
||||||
|
- `is_vault_encrypted()` for UI to show lock state.
|
||||||
|
- Unit tests: roundtrip encrypted, wrong passphrase fails, plaintext roundtrip, empty passphrase treated as plain.
|
||||||
|
|
||||||
|
#### Phase 1.3 chat UX
|
||||||
|
- **Message context menu** (right-click on any message row):
|
||||||
|
- Quick-reactions row (👍 ❤️ 😂 🎉 👀 🤔) — toggles reaction on click.
|
||||||
|
- Reply — sets `replying_to` state, preview bar shown above chat input.
|
||||||
|
- Copy text — copies message content to clipboard via `egui::Context::copy_text`.
|
||||||
|
- Edit (own messages only) — fills chat input with current content, switches to edit mode.
|
||||||
|
- Delete (own messages only) — sends MessageDelete to gateway.
|
||||||
|
- **Reply feature** end-to-end:
|
||||||
|
- Wire protocol: `ChatMessagePayload.reply_to: Option<String>` (optional message_id).
|
||||||
|
- DB schema: `messages.reply_to TEXT` column (added via `ensure_column` migration).
|
||||||
|
- `NetCommand::SendChat { reply_to: Option<String> }` carries the reference through the network layer.
|
||||||
|
- UI: italic "↳ alice: <snippet>" preview above the message body when `reply_to` is set.
|
||||||
|
- **Custom status + activity status UI**:
|
||||||
|
- Right-click on user-bar status label opens a context menu.
|
||||||
|
- Status picker: Online / Idle / DND / Invisible (with color dots).
|
||||||
|
- Custom status text editor (Discord-style "what's on your mind?").
|
||||||
|
- Activity type combo (Playing / Listening / Watching / Streaming) + activity text input.
|
||||||
|
- All changes sync to gateway via `PresenceUpdate` with `activity_type`/`activity_text`/`custom_status` fields.
|
||||||
|
- User bar displays: voice state → custom status → activity (icon + text) → online/latency.
|
||||||
|
|
||||||
|
#### Phase 1.3 block list (was: placeholder)
|
||||||
|
- **Block / Unblock commands** wired end-to-end:
|
||||||
|
- Client: `NetCommand::BlockUser`, `UnblockUser`, `BlockList` + payloads + PID constants (0x0155-0x0157).
|
||||||
|
- Server handlers (already present) now reachable.
|
||||||
|
- Client events: `NetEvent::BlockList`, `BlockedUser`, `UnblockedUser` dispatched to `social::handle`.
|
||||||
|
- `blocked_users: Vec<String>` field on `UiState`.
|
||||||
|
- Friends panel "Blocked" tab now shows: input row to block by user ID, list of blocked users with "Unblock" button.
|
||||||
|
- Auto-fetches block list on first open of the Blocked tab.
|
||||||
|
|
||||||
|
#### Phase 1.3 per-user speaking attribution
|
||||||
|
- **Voice packet protocol extended** — plaintext now includes `[sender_id:32]` (raw Ed25519 pubkey) between channel_id and opus data.
|
||||||
|
- `voice::build_packet()` accepts `sender_pubkey: &[u8; 32]`.
|
||||||
|
- `voice::spawn_recv()` parses sender_id and emits hex-encoded `sender_id` in `NetEvent::VoicePacket`.
|
||||||
|
- Legacy packets (no sender_id) gracefully handled — empty `sender_id` string.
|
||||||
|
- Unit test `build_packet_header_layout` updated to verify sender_id roundtrip.
|
||||||
|
- **Per-user speaking indicator in voice panel**:
|
||||||
|
- `last_remote_speaker_id` field on `UiState`, updated on every received voice packet.
|
||||||
|
- Voice panel matches `last_remote_speaker_id` against channel members and highlights only the speaking member (green border, name, 🔊 emoji).
|
||||||
|
- Voice activity banner now shows the speaker's nickname: "🔊 alice is talking" instead of generic "someone is talking".
|
||||||
|
- Speaker ID decays after 500 ms of silence (driven by `remote_speaking(500)`).
|
||||||
|
|
||||||
|
#### Phase 1.3 channel creation UI
|
||||||
|
- **"Create a Channel" popup** — accessible from the channel list header via the "+" button.
|
||||||
|
- Input: channel name (free text).
|
||||||
|
- Type selector: Text (`#`) or Voice (`🔊`).
|
||||||
|
- Creates the channel in the local `s.channels` list so it appears in the sidebar immediately.
|
||||||
|
- Future JoinChannel attempt will register with the gateway (server-side channel-create is Phase 2 backend work).
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **NetEvent dispatch bug (from previous commit):** `GuildMemberKicked`, `InviteCreated/Accepted/Deleted`, `RoleCreated/Deleted` were silently dropped — now routed to `social::handle`.
|
||||||
|
- **`FriendAccepted` handler (from previous commit):** now uses `user_id` and adds the friend to the list.
|
||||||
|
- **PID collision:** `PID_FRIEND_EVENT` was 0x0155, conflicting with server `BlockUser=0x0155`. Renumbered: `BlockUser=0x0155`, `UnblockUser=0x0156`, `BlockList=0x0157`, `FriendEvent=0x0158`.
|
||||||
|
- **PresenceUpdate wire format mismatch:** client was sending `{custom_status, activity}` but server expected `{activity_type, activity_text}`. Now the client serializes `activity_type`/`activity_text`/`custom_status` correctly, matching the gateway's `PresenceUpdatePayload`.
|
||||||
|
|
||||||
|
### Documentation
|
||||||
|
|
||||||
|
- `docs/00-status.md` and `docs/06-roadmap.md` updated in previous commit — Phase 1.1/1.2/1.3 marked DONE where implemented.
|
||||||
|
- `CHANGELOG.md` expanded with all new entries.
|
||||||
|
|
||||||
|
### Build verification (second batch)
|
||||||
|
|
||||||
|
- `cargo clippy --workspace` — 0 warnings.
|
||||||
|
- `cargo test --workspace` — 41/41 tests pass (22 in client lib incl. new rate_limit + vault tests, 10 in gateway, 9 in voice-node).
|
||||||
|
|
||||||
|
## [0.1.0] - 2025-05-21
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Initial workspace: gateway, voice-node, client, sdk stub, federation stub.
|
||||||
|
- LNEx Phase 1 over TCP (JSON payloads): auth, channels, text chat.
|
||||||
|
- UDP voice relay between clients in the same channel.
|
||||||
|
- SQLite message history and user records.
|
||||||
|
- Desktop client shell with egui UI and net layer.
|
||||||
|
|
||||||
|
[Unreleased]: https://github.com/loki5512344/Vnox/compare/v0.1.0...HEAD
|
||||||
|
[0.1.0]: https://github.com/loki5512344/Vnox/releases/tag/v0.1.0
|
||||||
74
README.md
Normal file
74
README.md
Normal file
|
|
@ -0,0 +1,74 @@
|
||||||
|
# VNOX
|
||||||
|
|
||||||
|
Self-hosted realtime voice and chat. Decentralized. Lightweight. Moddable.
|
||||||
|
Built on LNEx, a custom protocol for low-latency federated communication.
|
||||||
|
|
||||||
|
Not Discord. Not TeamSpeak. Not cloud.
|
||||||
|
|
||||||
|
```
|
||||||
|
vnox://server/channel
|
||||||
|
```
|
||||||
|
|
||||||
|
## Quick links
|
||||||
|
|
||||||
|
- [Architecture](docs/01-architecture.md)
|
||||||
|
- [Protocol](docs/02-protocol/README.md)
|
||||||
|
- [Current status and limitations](docs/00-status.md)
|
||||||
|
- [Server setup](docs/03-server/deployment.md)
|
||||||
|
- [Local dev config](dev/README.md)
|
||||||
|
- [Contributing](docs/community/contributing.md)
|
||||||
|
- [Changelog](CHANGELOG.md)
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
Phase 1: implemented, not production ready.
|
||||||
|
|
||||||
|
See [docs/00-status.md](docs/00-status.md) for an honest list of what works,
|
||||||
|
what is only specified on paper, and known gaps.
|
||||||
|
|
||||||
|
| Component | Status |
|
||||||
|
|----------------|--------|
|
||||||
|
| Gateway | TCP listener, LNEx handshake, channels, chat, SQLite |
|
||||||
|
| Voice node | UDP relay, voice packet routing |
|
||||||
|
| Desktop client | egui UI, net layer, audio pipeline (partial) |
|
||||||
|
| LNEx protocol | Specified and implemented (JSON in Phase 1) |
|
||||||
|
| Federation | Planned (Phase 3) |
|
||||||
|
| Mobile client | Planned (Phase 3) |
|
||||||
|
|
||||||
|
Traffic in v0.1.x is **unencrypted plaintext**. Do not use in production.
|
||||||
|
|
||||||
|
## Running locally
|
||||||
|
|
||||||
|
Requires Rust 1.85+.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
# 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
|
||||||
|
|
||||||
|
# terminal 3 - client
|
||||||
|
cargo run -p vnox-client
|
||||||
|
```
|
||||||
|
|
||||||
|
The client connects to `127.0.0.1:7600` by default (editable in the UI).
|
||||||
|
Config details: [dev/README.md](dev/README.md).
|
||||||
|
|
||||||
|
### Opus on Windows
|
||||||
|
|
||||||
|
`audiopus_sys` builds libopus from source via CMake.
|
||||||
|
CMake 4.x requires a policy flag, already set in `.cargo/config.toml`:
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[env]
|
||||||
|
CMAKE_POLICY_VERSION_MINIMUM = "3.5"
|
||||||
|
```
|
||||||
|
|
||||||
|
No manual steps needed.
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
GPL-3.0. See [docs/LICENSE.md](docs/LICENSE.md).
|
||||||
|
|
||||||
|
The LNEx protocol specification is CC0 (public domain).
|
||||||
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
|
||||||
19
docs/README.md
Normal file
19
docs/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/design-system.md
Normal file
400
docs/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/desktop.md
Normal file
195
docs/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/mobile.md
Normal file
71
docs/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/overlay.md
Normal file
106
docs/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.
|
||||||
Loading…
Add table
Add a link
Reference in a new issue