mirror of
https://github.com/jkingsman/Remote-Terminal-for-MeshCore.git
synced 2026-03-28 17:43:05 +01:00
Outgoing WS now echoes, websock reclamation after unmount cleanup, hash fix for empty contacts, no double bot broadcast, AGENTS.md + test fixes (this should have been more than one commit lol)
This commit is contained in:
@@ -316,7 +316,7 @@ Read state (`last_read_at`) is tracked **server-side** for consistency across de
|
||||
|
||||
**State Tracking Keys (Frontend)**: Generated by `getStateKey()` for message times (sidebar sorting):
|
||||
- Channels: `channel-{channel_key}`
|
||||
- Contacts: `contact-{12-char-pubkey-prefix}`
|
||||
- Contacts: `contact-{full-public-key}`
|
||||
|
||||
**Note:** These are NOT the same as `Message.conversation_key` (the database field).
|
||||
|
||||
@@ -359,7 +359,7 @@ mc.subscribe(EventType.ACK, handler)
|
||||
| `MESHCORE_BLE_PIN` | *(required with BLE)* | BLE PIN code |
|
||||
| `MESHCORE_DATABASE_PATH` | `data/meshcore.db` | SQLite database location |
|
||||
|
||||
**Note:** `max_radio_contacts` and `experimental_channel_double_send` are runtime settings stored in the database (`app_settings` table), not environment variables. They are configured via `PATCH /api/settings`.
|
||||
**Note:** Runtime app settings are stored in the database (`app_settings` table), not environment variables. These include `max_radio_contacts`, `experimental_channel_double_send`, `auto_decrypt_dm_on_advert`, `sidebar_sort_order`, `advert_interval`, `last_advert_time`, `favorites`, `last_message_times`, and `bots`. They are configured via `GET/PATCH /api/settings` (and related settings endpoints).
|
||||
|
||||
`experimental_channel_double_send` is an opt-in experimental setting: when enabled, channel sends perform a second byte-perfect resend after a 3-second delay.
|
||||
|
||||
|
||||
826
app/AGENTS.md
826
app/AGENTS.md
@@ -1,717 +1,207 @@
|
||||
# Backend AGENTS.md
|
||||
|
||||
This document provides context for AI assistants and developers working on the FastAPI backend.
|
||||
This document is the backend working guide for agents and developers.
|
||||
Keep it aligned with `app/` source files and router behavior.
|
||||
|
||||
## Technology Stack
|
||||
## Stack
|
||||
|
||||
- **FastAPI** - Async web framework with automatic OpenAPI docs
|
||||
- **aiosqlite** - Async SQLite driver
|
||||
- **meshcore** - MeshCore radio library (local dependency at `../meshcore_py`)
|
||||
- **Pydantic** - Data validation and settings management
|
||||
- **PyCryptodome** - AES-128 encryption for packet decryption
|
||||
- **UV** - Python package manager
|
||||
- FastAPI
|
||||
- aiosqlite
|
||||
- Pydantic
|
||||
- MeshCore Python library (`references/meshcore_py`)
|
||||
- PyCryptodome
|
||||
|
||||
## Directory Structure
|
||||
## Backend Map
|
||||
|
||||
```
|
||||
```text
|
||||
app/
|
||||
├── main.py # FastAPI app, lifespan, router registration, static file serving
|
||||
├── config.py # Pydantic settings (env vars: MESHCORE_*)
|
||||
├── database.py # SQLite schema, connection management, runs migrations
|
||||
├── migrations.py # Database migrations using SQLite user_version pragma
|
||||
├── models.py # Pydantic models for API request/response
|
||||
├── repository.py # Database CRUD (ContactRepository, ChannelRepository, etc.)
|
||||
├── radio.py # RadioManager - serial connection to MeshCore device
|
||||
├── radio_sync.py # Periodic sync, contact auto-loading to radio
|
||||
├── decoder.py # Packet decryption (channel + direct messages)
|
||||
├── packet_processor.py # Raw packet processing, advertisement handling
|
||||
├── keystore.py # Ephemeral key store (private key in memory only)
|
||||
├── event_handlers.py # Radio event subscriptions, ACK tracking, repeat detection
|
||||
├── websocket.py # WebSocketManager for real-time client updates
|
||||
└── routers/ # All routes prefixed with /api
|
||||
├── health.py # GET /api/health
|
||||
├── radio.py # Radio config, advertise, private key, reboot
|
||||
├── contacts.py # Contact CRUD, radio sync, mark-read
|
||||
├── channels.py # Channel CRUD, radio sync, mark-read
|
||||
├── messages.py # Message list and send (direct/channel)
|
||||
├── packets.py # Raw packet endpoints, historical decryption
|
||||
├── read_state.py # Read state: unread counts, mark-all-read
|
||||
├── settings.py # App settings (max_radio_contacts, experimental_channel_double_send, etc.)
|
||||
└── ws.py # WebSocket endpoint at /api/ws
|
||||
├── main.py # App startup/lifespan, router registration, static frontend mounting
|
||||
├── config.py # Env-driven runtime settings
|
||||
├── database.py # SQLite connection + base schema + migration runner
|
||||
├── migrations.py # Schema migrations (SQLite user_version)
|
||||
├── models.py # Pydantic request/response models
|
||||
├── repository.py # Data access layer
|
||||
├── radio.py # RadioManager + auto-reconnect monitor
|
||||
├── radio_sync.py # Polling, sync, periodic advertisement loop
|
||||
├── decoder.py # Packet parsing/decryption
|
||||
├── packet_processor.py # Raw packet pipeline, dedup, path handling
|
||||
├── event_handlers.py # MeshCore event subscriptions and ACK tracking
|
||||
├── websocket.py # WS manager + broadcast helpers
|
||||
├── bot.py # Bot execution and outbound bot sends
|
||||
├── dependencies.py # Shared FastAPI dependency providers
|
||||
├── frontend_static.py # Mount/serve built frontend (production)
|
||||
└── routers/
|
||||
├── health.py
|
||||
├── radio.py
|
||||
├── contacts.py
|
||||
├── channels.py
|
||||
├── messages.py
|
||||
├── packets.py
|
||||
├── read_state.py
|
||||
├── settings.py
|
||||
└── ws.py
|
||||
```
|
||||
|
||||
## Intentional Security Design Decisions
|
||||
## Core Runtime Flows
|
||||
|
||||
The following are **deliberate design choices**, not bugs. They are documented in the README with appropriate warnings. Do not "fix" these or flag them as vulnerabilities.
|
||||
### Incoming data
|
||||
|
||||
1. **No CORS restrictions**: `CORSMiddleware` in `main.py` allows all origins, methods, and headers. This lets users access their radio from any device/origin on their network.
|
||||
2. **No authentication or authorization**: All API endpoints and the WebSocket are openly accessible. The app is designed for trusted networks only (home LAN, VPN).
|
||||
3. **Arbitrary bot code execution**: `bot.py` uses `exec()` with full `__builtins__` to run user-provided Python code. This is intentional — bots are a power-user automation feature. Safeguards are limited to timeouts and concurrency limits, not sandboxing.
|
||||
1. Radio emits events.
|
||||
2. `on_rx_log_data` stores raw packet and tries decrypt/pipeline handling.
|
||||
3. Decrypted messages are inserted into `messages` and broadcast over WS.
|
||||
4. `CONTACT_MSG_RECV` is a fallback DM path when packet pipeline cannot decrypt.
|
||||
|
||||
## Key Architectural Patterns
|
||||
### Outgoing messages
|
||||
|
||||
### Repository Pattern
|
||||
1. Send endpoints in `routers/messages.py` call MeshCore commands.
|
||||
2. Message is persisted as outgoing.
|
||||
3. Endpoint broadcasts WS `message` event so all live clients update.
|
||||
4. ACK/repeat updates arrive later as `message_acked` events.
|
||||
|
||||
All database operations go through repository classes in `repository.py`:
|
||||
### Connection lifecycle
|
||||
|
||||
```python
|
||||
from app.repository import ContactRepository, ChannelRepository, MessageRepository, RawPacketRepository, AppSettingsRepository
|
||||
- `RadioManager.start_connection_monitor()` checks health every 5s.
|
||||
- On reconnect, monitor runs `post_connect_setup()` before broadcasting healthy state.
|
||||
- Setup includes handler registration, key export, time sync, contact/channel sync, polling/advert tasks.
|
||||
|
||||
# Examples
|
||||
contact = await ContactRepository.get_by_key_prefix("abc123")
|
||||
await MessageRepository.create(msg_type="PRIV", text="Hello", received_at=timestamp)
|
||||
await RawPacketRepository.mark_decrypted(packet_id, message_id)
|
||||
## Important Behaviors
|
||||
|
||||
# App settings (single-row pattern)
|
||||
settings = await AppSettingsRepository.get()
|
||||
await AppSettingsRepository.update(auto_decrypt_dm_on_advert=True)
|
||||
await AppSettingsRepository.update(experimental_channel_double_send=True)
|
||||
await AppSettingsRepository.add_favorite("contact", public_key)
|
||||
```
|
||||
### Read/unread state
|
||||
|
||||
### Radio Connection
|
||||
- Server is source of truth (`contacts.last_read_at`, `channels.last_read_at`).
|
||||
- `GET /api/read-state/unreads` returns counts, mention flags, and `last_message_times`.
|
||||
|
||||
`RadioManager` in `radio.py` handles radio connection over Serial, TCP, or BLE:
|
||||
### Echo/repeat dedup
|
||||
|
||||
```python
|
||||
from app.radio import radio_manager
|
||||
- Message uniqueness: `(type, conversation_key, text, sender_timestamp)`.
|
||||
- Duplicate insert is treated as an echo/repeat; ACK count/path list is updated.
|
||||
|
||||
# Access meshcore instance
|
||||
if radio_manager.meshcore:
|
||||
await radio_manager.meshcore.commands.send_msg(dst, msg)
|
||||
### Periodic advertisement
|
||||
|
||||
# Check connection info (e.g. "Serial: /dev/ttyUSB0", "TCP: 192.168.1.1:4000", "BLE: AA:BB:CC:DD:EE:FF")
|
||||
print(radio_manager.connection_info)
|
||||
```
|
||||
- Controlled by `app_settings.advert_interval` (seconds).
|
||||
- `0` means disabled.
|
||||
- Last send time tracked in `app_settings.last_advert_time`.
|
||||
|
||||
Transport is configured via env vars (see root AGENTS.md). When no transport env vars are set, serial auto-detection is used.
|
||||
|
||||
### Event-Driven Architecture
|
||||
|
||||
Radio events flow through `event_handlers.py`:
|
||||
|
||||
| Event | Handler | Actions |
|
||||
|-------|---------|---------|
|
||||
| `CONTACT_MSG_RECV` | `on_contact_message` | **Fallback only** - stores DM if packet processor didn't handle it |
|
||||
| `RX_LOG_DATA` | `on_rx_log_data` | Store packet, decrypt channels/DMs, broadcast via WS |
|
||||
| `PATH_UPDATE` | `on_path_update` | Update contact path info |
|
||||
| `NEW_CONTACT` | `on_new_contact` | Sync contact from radio's internal database |
|
||||
| `ACK` | `on_ack` | Match pending ACKs, mark message acked, broadcast |
|
||||
|
||||
**Note on DM handling**: Direct messages are primarily handled by the packet processor via
|
||||
`RX_LOG_DATA`, which decrypts using the exported private key. The `CONTACT_MSG_RECV` handler
|
||||
exists as a fallback for radios without `ENABLE_PRIVATE_KEY_EXPORT=1` in firmware.
|
||||
|
||||
### WebSocket Broadcasting
|
||||
|
||||
Real-time updates use `ws_manager` singleton:
|
||||
|
||||
```python
|
||||
from app.websocket import ws_manager
|
||||
|
||||
# Broadcast to all connected clients
|
||||
await ws_manager.broadcast("message", {"id": 1, "text": "Hello"})
|
||||
```
|
||||
|
||||
Event types: `health`, `contacts`, `channels`, `message`, `contact`, `raw_packet`, `message_acked`, `error`
|
||||
|
||||
**Note:** The WebSocket initial connect only sends `health`. Contacts and channels are fetched
|
||||
via REST (`GET /api/contacts`, `GET /api/channels`) for faster parallel loading. The WS still
|
||||
broadcasts real-time `contacts`/`channels` updates when data changes.
|
||||
|
||||
Helper functions for common broadcasts:
|
||||
|
||||
```python
|
||||
from app.websocket import broadcast_error, broadcast_health
|
||||
|
||||
# Notify clients of errors (shows toast in frontend)
|
||||
broadcast_error("Operation failed", "Additional details")
|
||||
|
||||
# Notify clients of connection status change
|
||||
broadcast_health(radio_connected=True, connection_info="Serial: /dev/ttyUSB0")
|
||||
```
|
||||
|
||||
### Connection Monitoring
|
||||
|
||||
`RadioManager` includes a background task that monitors connection status:
|
||||
|
||||
- Checks connection every 5 seconds
|
||||
- Broadcasts `health` event on status change
|
||||
- Attempts automatic reconnection when connection lost
|
||||
- **Runs full `post_connect_setup()` after successful reconnect** (event handlers, key export, time sync, contact/channel sync, advertisements, message polling)
|
||||
- Resilient to transient errors (logs and continues rather than crashing)
|
||||
- Supports manual reconnection via `POST /api/radio/reconnect`
|
||||
|
||||
```python
|
||||
from app.radio import radio_manager
|
||||
|
||||
# Manual reconnection
|
||||
success = await radio_manager.reconnect()
|
||||
|
||||
# Background monitor (started automatically in app lifespan)
|
||||
await radio_manager.start_connection_monitor()
|
||||
await radio_manager.stop_connection_monitor()
|
||||
```
|
||||
|
||||
### Message Polling
|
||||
|
||||
Periodic message polling serves as a fallback for platforms where push events are unreliable.
|
||||
Use `pause_polling()` to temporarily suspend polling during operations that need exclusive
|
||||
radio access (e.g., repeater CLI commands):
|
||||
|
||||
```python
|
||||
from app.radio_sync import pause_polling, is_polling_paused
|
||||
|
||||
# Pause polling during sensitive operations (supports nesting)
|
||||
async with pause_polling():
|
||||
# Polling is paused here
|
||||
await do_repeater_operation()
|
||||
|
||||
async with pause_polling():
|
||||
# Still paused (nested)
|
||||
await do_another_operation()
|
||||
|
||||
# Still paused (outer context active)
|
||||
|
||||
# Polling resumes when all contexts exit
|
||||
|
||||
# Check current state
|
||||
if is_polling_paused():
|
||||
print("Polling is currently paused")
|
||||
```
|
||||
|
||||
### Periodic Advertisement
|
||||
|
||||
The server automatically sends an advertisement every hour to announce presence on the mesh.
|
||||
This helps maintain visibility to other nodes and refreshes routing information.
|
||||
|
||||
- Started automatically on radio connection
|
||||
- Interval: 1 hour (3600 seconds)
|
||||
- Uses flood mode for maximum reach
|
||||
|
||||
```python
|
||||
from app.radio_sync import start_periodic_advert, stop_periodic_advert, send_advertisement
|
||||
|
||||
# Start/stop periodic advertising
|
||||
start_periodic_advert() # Started automatically in lifespan
|
||||
await stop_periodic_advert()
|
||||
|
||||
# Manual advertisement
|
||||
await send_advertisement() # Returns True on success
|
||||
```
|
||||
|
||||
## Database Schema
|
||||
|
||||
```sql
|
||||
contacts (
|
||||
public_key TEXT PRIMARY KEY, -- 64-char hex
|
||||
name TEXT,
|
||||
type INTEGER DEFAULT 0, -- 0=unknown, 1=client, 2=repeater, 3=room
|
||||
flags INTEGER DEFAULT 0,
|
||||
last_path TEXT, -- Routing path hex
|
||||
last_path_len INTEGER DEFAULT -1,
|
||||
last_advert INTEGER, -- Unix timestamp of last advertisement
|
||||
lat REAL, lon REAL,
|
||||
last_seen INTEGER,
|
||||
on_radio INTEGER DEFAULT 0, -- Boolean: contact loaded on radio
|
||||
last_contacted INTEGER, -- Unix timestamp of last message sent/received
|
||||
last_read_at INTEGER -- Unix timestamp when conversation was last read
|
||||
)
|
||||
|
||||
channels (
|
||||
key TEXT PRIMARY KEY, -- 32-char hex channel key
|
||||
name TEXT NOT NULL,
|
||||
is_hashtag INTEGER DEFAULT 0, -- Key derived from SHA256(name)[:16]
|
||||
on_radio INTEGER DEFAULT 0,
|
||||
last_read_at INTEGER -- Unix timestamp when channel was last read
|
||||
)
|
||||
|
||||
messages (
|
||||
id INTEGER PRIMARY KEY,
|
||||
type TEXT NOT NULL, -- 'PRIV' or 'CHAN'
|
||||
conversation_key TEXT NOT NULL, -- User pubkey for PRIV, channel key for CHAN
|
||||
text TEXT NOT NULL,
|
||||
sender_timestamp INTEGER,
|
||||
received_at INTEGER NOT NULL,
|
||||
paths TEXT, -- JSON array of {path, received_at} for multiple delivery paths
|
||||
txt_type INTEGER DEFAULT 0,
|
||||
signature TEXT,
|
||||
outgoing INTEGER DEFAULT 0,
|
||||
acked INTEGER DEFAULT 0,
|
||||
UNIQUE(type, conversation_key, text, sender_timestamp) -- Deduplication
|
||||
)
|
||||
|
||||
raw_packets (
|
||||
id INTEGER PRIMARY KEY,
|
||||
timestamp INTEGER NOT NULL,
|
||||
data BLOB NOT NULL, -- Raw packet bytes
|
||||
message_id INTEGER, -- FK to messages if decrypted
|
||||
payload_hash TEXT, -- SHA256 of payload for deduplication (UNIQUE index)
|
||||
FOREIGN KEY (message_id) REFERENCES messages(id)
|
||||
)
|
||||
|
||||
app_settings (
|
||||
id INTEGER PRIMARY KEY CHECK (id = 1), -- Single-row pattern
|
||||
max_radio_contacts INTEGER DEFAULT 200,
|
||||
experimental_channel_double_send INTEGER DEFAULT 0, -- Experimental delayed byte-perfect channel resend
|
||||
favorites TEXT DEFAULT '[]', -- JSON array of {type, id}
|
||||
auto_decrypt_dm_on_advert INTEGER DEFAULT 0,
|
||||
sidebar_sort_order TEXT DEFAULT 'recent', -- 'recent' or 'alpha'
|
||||
last_message_times TEXT DEFAULT '{}', -- JSON object of state_key -> timestamp
|
||||
preferences_migrated INTEGER DEFAULT 0 -- One-time migration flag
|
||||
)
|
||||
```
|
||||
|
||||
## Database Migrations (`migrations.py`)
|
||||
|
||||
Schema migrations use SQLite's `user_version` pragma for version tracking:
|
||||
|
||||
```python
|
||||
from app.migrations import get_version, set_version, run_migrations
|
||||
|
||||
# Check current schema version
|
||||
version = await get_version(conn) # Returns int (0 for new/unmigrated DB)
|
||||
|
||||
# Run pending migrations (called automatically on startup)
|
||||
applied = await run_migrations(conn) # Returns number of migrations applied
|
||||
```
|
||||
|
||||
### How It Works
|
||||
|
||||
1. `database.py` calls `run_migrations()` after schema initialization
|
||||
2. Each migration checks `user_version` and runs if needed
|
||||
3. Migrations are idempotent (safe to run multiple times)
|
||||
4. `ALTER TABLE ADD COLUMN` handles existing columns gracefully
|
||||
|
||||
### Adding a New Migration
|
||||
|
||||
```python
|
||||
# In migrations.py
|
||||
async def run_migrations(conn: aiosqlite.Connection) -> int:
|
||||
version = await get_version(conn)
|
||||
applied = 0
|
||||
|
||||
if version < 1:
|
||||
await _migrate_001_add_last_read_at(conn)
|
||||
await set_version(conn, 1)
|
||||
applied += 1
|
||||
|
||||
# Add new migrations here:
|
||||
# if version < 2:
|
||||
# await _migrate_002_something(conn)
|
||||
# await set_version(conn, 2)
|
||||
# applied += 1
|
||||
|
||||
return applied
|
||||
```
|
||||
|
||||
## Packet Decryption (`decoder.py`)
|
||||
|
||||
The decoder handles MeshCore packet decryption for historical packet analysis:
|
||||
|
||||
### Packet Types
|
||||
|
||||
```python
|
||||
class PayloadType(IntEnum):
|
||||
GROUP_TEXT = 0x05 # Channel messages (decryptable)
|
||||
TEXT_MESSAGE = 0x02 # Direct messages
|
||||
ACK = 0x03
|
||||
ADVERT = 0x04
|
||||
# ... see decoder.py for full list
|
||||
```
|
||||
|
||||
### Channel Key Derivation
|
||||
|
||||
Hashtag channels derive keys from name:
|
||||
```python
|
||||
channel_key = hashlib.sha256(b"#channelname").digest()[:16]
|
||||
```
|
||||
|
||||
### Decryption Flow
|
||||
|
||||
1. Parse packet header to get payload type
|
||||
2. For `GROUP_TEXT`: extract channel_hash (1 byte), cipher_mac (2 bytes), ciphertext
|
||||
3. Verify HMAC-SHA256 using 32-byte secret (key + 16 zero bytes)
|
||||
4. Decrypt with AES-128 ECB
|
||||
5. Parse decrypted content: timestamp (4 bytes), flags (1 byte), "sender: message" text
|
||||
|
||||
```python
|
||||
from app.decoder import try_decrypt_packet_with_channel_key
|
||||
|
||||
result = try_decrypt_packet_with_channel_key(raw_bytes, channel_key)
|
||||
if result:
|
||||
print(f"{result.sender}: {result.message}")
|
||||
```
|
||||
|
||||
### Direct Message Decryption
|
||||
|
||||
Direct messages use ECDH key exchange (Ed25519 → X25519) for shared secret derivation.
|
||||
|
||||
**Key storage**: The private key is exported from the radio on startup and stored in memory
|
||||
via `keystore.py`. This enables server-side DM decryption even when contacts aren't loaded
|
||||
on the radio.
|
||||
|
||||
**Primary path (packet processor)**: When an `RX_LOG_DATA` event contains a `TEXT_MESSAGE`
|
||||
packet, `packet_processor.py` handles the complete flow:
|
||||
1. Decrypts using known contact public keys and stored private key
|
||||
2. Filters CLI responses (txt_type=1 in flags)
|
||||
3. Stores message in database
|
||||
4. Broadcasts via WebSocket
|
||||
5. Updates contact's last_contacted timestamp
|
||||
6. Triggers bot if enabled
|
||||
|
||||
**Fallback path (event handler)**: If the packet processor can't decrypt (no private key
|
||||
export, unknown contact), `on_contact_message` handles DMs from the MeshCore library's
|
||||
`CONTACT_MSG_RECV` event. DB deduplication prevents double-storage when both paths fire.
|
||||
|
||||
**Prefix-stored DMs (edge case)**: A rare scenario can occur when the radio can decrypt
|
||||
a DM (contact is on the radio) but the server cannot (private key not exported or
|
||||
contact not yet known server-side). The `CONTACT_MSG_RECV` payload may only include a
|
||||
pubkey prefix. If the full key isn't known yet, the message is stored with the prefix
|
||||
as `conversation_key`. When a full contact key becomes known (via advertisement or
|
||||
radio sync), the server attempts to **claim** those prefix messages and upgrade them
|
||||
to the full key. Claims only occur when the prefix matches exactly one contact to
|
||||
avoid mis-attribution in large contact sets. Until claimed, these DMs will not show
|
||||
in the UI because conversations are keyed by full public keys.
|
||||
|
||||
**Outgoing DMs**: Outgoing direct messages are only sent via the app's REST API
|
||||
(`POST /api/messages/direct`), which stores the plaintext directly in the database.
|
||||
No decryption is needed for outgoing DMs. The real-time packet processor may also see
|
||||
the outgoing packet via `RX_LOG_DATA`, but the DB UNIQUE constraint deduplicates it
|
||||
against the already-stored plaintext. Historical decryption intentionally skips outgoing
|
||||
packets for the same reason — the app already has the plaintext.
|
||||
|
||||
**Historical decryption**: When creating a contact with `try_historical=True`, the server
|
||||
attempts to decrypt all stored `TEXT_MESSAGE` packets for that contact. This only recovers
|
||||
**incoming** messages; outgoing DMs are already stored as plaintext by the send endpoint.
|
||||
|
||||
**Direction detection**: The decoder uses the 1-byte dest_hash and src_hash to determine
|
||||
if a message is incoming or outgoing. Edge case: when both bytes match (1/256 chance),
|
||||
defaults to treating as incoming.
|
||||
|
||||
```python
|
||||
from app.decoder import try_decrypt_dm
|
||||
|
||||
result = try_decrypt_dm(raw_bytes, private_key, contact_public_key)
|
||||
if result:
|
||||
print(f"{result.message} (timestamp={result.timestamp})")
|
||||
```
|
||||
|
||||
## Advertisement Parsing (`decoder.py`)
|
||||
|
||||
Advertisement packets contain contact information including optional GPS coordinates.
|
||||
|
||||
### Packet Structure
|
||||
|
||||
```
|
||||
Bytes 0-31: Public key (32 bytes)
|
||||
Bytes 32-35: Timestamp (4 bytes, little-endian Unix timestamp)
|
||||
Bytes 36-99: Signature (64 bytes)
|
||||
Byte 100: App flags
|
||||
Bytes 101+: Optional fields (location, name) based on flags
|
||||
```
|
||||
|
||||
### App Flags (byte 100)
|
||||
|
||||
- Bits 0-3: Device role (1=Chat, 2=Repeater, 3=Room, 4=Sensor)
|
||||
- Bit 4: Has location (lat/lon follow)
|
||||
- Bit 5: Has feature 1
|
||||
- Bit 6: Has feature 2
|
||||
- Bit 7: Has name (null-terminated string at end)
|
||||
|
||||
### GPS Extraction
|
||||
|
||||
When bit 4 is set, latitude and longitude follow as signed int32 little-endian values,
|
||||
divided by 1,000,000 to get decimal degrees:
|
||||
|
||||
```python
|
||||
from app.decoder import parse_advertisement
|
||||
|
||||
advert = parse_advertisement(payload_bytes)
|
||||
if advert:
|
||||
print(f"Device role: {advert.device_role}") # 1=Chat, 2=Repeater
|
||||
if advert.lat and advert.lon:
|
||||
print(f"Location: {advert.lat}, {advert.lon}")
|
||||
```
|
||||
|
||||
### Data Flow
|
||||
|
||||
1. `event_handlers.py` receives ADVERTISEMENT event
|
||||
2. `packet_processor.py` calls `parse_advertisement()` to extract data
|
||||
3. Contact is upserted with location data (`lat`, `lon`) and `device_role` as `type`
|
||||
4. Frontend MapView displays contacts with GPS coordinates
|
||||
|
||||
## ACK and Repeat Detection
|
||||
|
||||
The `acked` field is an integer count, not a boolean:
|
||||
- `0` = not acked
|
||||
- `1` = one ACK/echo received
|
||||
- `2+` = multiple flood echoes received
|
||||
|
||||
### Direct Message ACKs
|
||||
|
||||
When sending a direct message, an expected ACK code is tracked:
|
||||
```python
|
||||
from app.event_handlers import track_pending_ack
|
||||
|
||||
track_pending_ack(expected_ack="abc123", message_id=42, timeout_ms=30000)
|
||||
```
|
||||
|
||||
When ACK event arrives, the message's ack count is incremented.
|
||||
|
||||
### Channel Message Repeats
|
||||
|
||||
Flood messages echo back through repeaters. Repeats are detected via the database
|
||||
UNIQUE constraint on `(type, conversation_key, text, sender_timestamp)`. When an INSERT
|
||||
hits a duplicate, `_handle_duplicate_message()` in `packet_processor.py` increments the
|
||||
ack count and adds the new delivery path. There is no timestamp-windowed matching;
|
||||
deduplication is exact-match only.
|
||||
|
||||
Each repeat increments the ack count. The frontend displays:
|
||||
- `?` = no acks
|
||||
- `✓` = 1 echo
|
||||
- `✓2`, `✓3`, etc. = multiple echoes (real-time updates via WebSocket)
|
||||
|
||||
### Auto-Contact Sync to Radio
|
||||
|
||||
To enable the radio to auto-ACK incoming DMs, recent non-repeater contacts are
|
||||
automatically loaded to the radio. Configured via `max_radio_contacts` setting (default 200).
|
||||
|
||||
- Triggered on each advertisement from a non-repeater contact
|
||||
- Loads most recently contacted non-repeaters (by `last_contacted` timestamp)
|
||||
- Throttled to at most once per 30 seconds
|
||||
- `last_contacted` updated on message send/receive
|
||||
|
||||
```python
|
||||
from app.radio_sync import sync_recent_contacts_to_radio
|
||||
|
||||
result = await sync_recent_contacts_to_radio(force=True)
|
||||
# Returns: {"loaded": 5, "already_on_radio": 195, "failed": 0}
|
||||
```
|
||||
|
||||
## API Endpoints
|
||||
|
||||
All endpoints are prefixed with `/api`.
|
||||
## API Surface (all under `/api`)
|
||||
|
||||
### Health
|
||||
- `GET /api/health` - Connection status, connection info
|
||||
- `GET /health`
|
||||
|
||||
### Radio
|
||||
- `GET /api/radio/config` - Read config (public key, name, radio params)
|
||||
- `PATCH /api/radio/config` - Update name, lat/lon, tx_power, radio params
|
||||
- `PUT /api/radio/private-key` - Import private key to radio (write-only)
|
||||
- `POST /api/radio/advertise?flood=true` - Send advertisement
|
||||
- `POST /api/radio/reboot` - Reboot radio or reconnect if disconnected
|
||||
- `POST /api/radio/reconnect` - Manual reconnection attempt
|
||||
- `GET /radio/config`
|
||||
- `PATCH /radio/config`
|
||||
- `PUT /radio/private-key`
|
||||
- `POST /radio/advertise`
|
||||
- `POST /radio/reboot`
|
||||
- `POST /radio/reconnect`
|
||||
|
||||
### Contacts
|
||||
- `GET /api/contacts` - List from database
|
||||
- `GET /api/contacts/{key}` - Get by public key or prefix
|
||||
- `POST /api/contacts` - Create contact (optionally trigger historical DM decryption)
|
||||
- `POST /api/contacts/sync` - Pull from radio to database
|
||||
- `POST /api/contacts/{key}/add-to-radio` - Push to radio
|
||||
- `POST /api/contacts/{key}/remove-from-radio` - Remove from radio
|
||||
- `POST /api/contacts/{key}/mark-read` - Mark conversation as read (updates last_read_at)
|
||||
- `POST /api/contacts/{key}/telemetry` - Request telemetry from repeater (see below)
|
||||
- `GET /contacts`
|
||||
- `GET /contacts/{public_key}`
|
||||
- `POST /contacts`
|
||||
- `DELETE /contacts/{public_key}`
|
||||
- `POST /contacts/sync`
|
||||
- `POST /contacts/{public_key}/add-to-radio`
|
||||
- `POST /contacts/{public_key}/remove-from-radio`
|
||||
- `POST /contacts/{public_key}/mark-read`
|
||||
- `POST /contacts/{public_key}/telemetry`
|
||||
- `POST /contacts/{public_key}/command`
|
||||
- `POST /contacts/{public_key}/trace`
|
||||
|
||||
### Channels
|
||||
- `GET /api/channels` - List from database
|
||||
- `GET /api/channels/{key}` - Get by channel key
|
||||
- `POST /api/channels` - Create (hashtag if name starts with # or no key provided)
|
||||
- `POST /api/channels/sync` - Pull from radio
|
||||
- `POST /api/channels/{key}/mark-read` - Mark channel as read (updates last_read_at)
|
||||
- `DELETE /api/channels/{key}` - Delete channel
|
||||
|
||||
### Read State
|
||||
- `GET /api/read-state/unreads?name=X` - Server-computed unread counts, mention flags, and last message times
|
||||
- `POST /api/read-state/mark-all-read` - Mark all contacts and channels as read
|
||||
- `GET /channels`
|
||||
- `GET /channels/{key}`
|
||||
- `POST /channels`
|
||||
- `DELETE /channels/{key}`
|
||||
- `POST /channels/sync`
|
||||
- `POST /channels/{key}/mark-read`
|
||||
|
||||
### Messages
|
||||
- `GET /api/messages?type=&conversation_key=&limit=&offset=` - List with filters
|
||||
- `POST /api/messages/direct` - Send direct message
|
||||
- `POST /api/messages/channel` - Send channel message (stores outgoing immediately; response includes current ack count)
|
||||
- `GET /messages`
|
||||
- `POST /messages/direct`
|
||||
- `POST /messages/channel`
|
||||
|
||||
### Packets
|
||||
- `GET /api/packets/undecrypted/count` - Count of undecrypted packets
|
||||
- `POST /api/packets/decrypt/historical` - Try decrypting old packets with new key
|
||||
- `GET /packets/undecrypted/count`
|
||||
- `POST /packets/decrypt/historical`
|
||||
- `POST /packets/maintenance`
|
||||
|
||||
### Read state
|
||||
- `GET /read-state/unreads`
|
||||
- `POST /read-state/mark-all-read`
|
||||
|
||||
### Settings
|
||||
- `GET /api/settings` - Get all app settings
|
||||
- `PATCH /api/settings` - Update settings (max_radio_contacts, experimental_channel_double_send, auto_decrypt_dm_on_advert, sidebar_sort_order)
|
||||
- `POST /api/settings/favorites` - Add a favorite
|
||||
- `DELETE /api/settings/favorites` - Remove a favorite
|
||||
- `POST /api/settings/favorites/toggle` - Toggle favorite status
|
||||
- `POST /api/settings/migrate` - One-time migration from frontend localStorage
|
||||
- `GET /settings`
|
||||
- `PATCH /settings`
|
||||
- `POST /settings/favorites/toggle`
|
||||
- `POST /settings/migrate`
|
||||
|
||||
### WebSocket
|
||||
- `WS /api/ws` - Real-time updates (health, contacts, channels, messages, raw packets)
|
||||
- `WS /ws`
|
||||
|
||||
### Static Files (Production)
|
||||
In production, the backend serves the frontend if `frontend/dist` exists. Users must build the
|
||||
frontend first (`cd frontend && npm install && npm run build`):
|
||||
- `/` - Serves `frontend/dist/index.html`
|
||||
- `/assets/*` - Serves compiled JS/CSS from `frontend/dist/assets/`
|
||||
- `/*` - Falls back to `index.html` for SPA routing
|
||||
## WebSocket Events
|
||||
|
||||
- `health`
|
||||
- `contacts`
|
||||
- `channels`
|
||||
- `contact`
|
||||
- `message`
|
||||
- `message_acked`
|
||||
- `raw_packet`
|
||||
- `error`
|
||||
- `success`
|
||||
|
||||
Initial WS connect sends `health` only. Contacts/channels are loaded by REST.
|
||||
|
||||
## Data Model Notes
|
||||
|
||||
Main tables:
|
||||
- `contacts`
|
||||
- `channels`
|
||||
- `messages`
|
||||
- `raw_packets`
|
||||
- `app_settings`
|
||||
|
||||
`app_settings` fields in active model:
|
||||
- `max_radio_contacts`
|
||||
- `experimental_channel_double_send`
|
||||
- `favorites`
|
||||
- `auto_decrypt_dm_on_advert`
|
||||
- `sidebar_sort_order`
|
||||
- `last_message_times`
|
||||
- `preferences_migrated`
|
||||
- `advert_interval`
|
||||
- `last_advert_time`
|
||||
- `bots`
|
||||
|
||||
## Security Posture (intentional)
|
||||
|
||||
- No authn/authz.
|
||||
- No CORS restriction (`*`).
|
||||
- Bot code executes user-provided Python via `exec()`.
|
||||
|
||||
These are product decisions for trusted-network deployments; do not flag as accidental vulnerabilities.
|
||||
|
||||
## Testing
|
||||
|
||||
Run tests with:
|
||||
Run backend tests:
|
||||
|
||||
```bash
|
||||
PYTHONPATH=. uv run pytest tests/ -v
|
||||
```
|
||||
|
||||
Key test files:
|
||||
- `tests/test_decoder.py` - Channel + direct message decryption, key exchange, real-world test vectors
|
||||
- `tests/test_keystore.py` - Ephemeral key store operations
|
||||
- `tests/test_event_handlers.py` - ACK tracking, repeat detection, CLI response filtering
|
||||
- `tests/test_api.py` - API endpoint tests, read state tracking
|
||||
- `tests/test_migrations.py` - Migration system, schema versioning
|
||||
High-signal suites:
|
||||
- `tests/test_packet_pipeline.py`
|
||||
- `tests/test_event_handlers.py`
|
||||
- `tests/test_send_messages.py`
|
||||
- `tests/test_radio.py`
|
||||
- `tests/test_api.py`
|
||||
- `tests/test_migrations.py`
|
||||
|
||||
## Common Tasks
|
||||
## Editing Checklist
|
||||
|
||||
### Adding a New Endpoint
|
||||
|
||||
1. Create or update router in `app/routers/`
|
||||
2. Define Pydantic models in `app/models.py` if needed
|
||||
3. Add repository methods in `app/repository.py` for database operations
|
||||
4. Register router in `app/main.py` if new file
|
||||
5. Add tests in `tests/`
|
||||
|
||||
### Adding a New Event Handler
|
||||
|
||||
1. Define handler in `app/event_handlers.py`
|
||||
2. Register in `register_event_handlers()` function
|
||||
3. Broadcast updates via `ws_manager` as needed
|
||||
|
||||
### Working with Radio Commands
|
||||
|
||||
```python
|
||||
# Available via radio_manager.meshcore.commands
|
||||
await mc.commands.send_msg(dst, msg)
|
||||
await mc.commands.send_chan_msg(chan, msg)
|
||||
await mc.commands.get_contacts()
|
||||
await mc.commands.add_contact(contact_dict)
|
||||
await mc.commands.set_channel(idx, name, key)
|
||||
await mc.commands.send_advert(flood=True)
|
||||
```
|
||||
|
||||
## Repeater Telemetry
|
||||
|
||||
The `POST /api/contacts/{key}/telemetry` endpoint fetches status, neighbors, and ACL from repeaters (contact type=2).
|
||||
|
||||
### Request Flow
|
||||
|
||||
1. Verify contact exists and is a repeater (type=2)
|
||||
2. Add contact to radio with stored path data (from advertisements)
|
||||
3. Send login with password
|
||||
4. Request status with retries (3 attempts, 10s timeout)
|
||||
5. Fetch neighbors with `fetch_all_neighbours()` (handles pagination)
|
||||
6. Fetch ACL with `req_acl_sync()`
|
||||
7. Resolve pubkey prefixes to contact names from database
|
||||
|
||||
### ACL Permission Levels
|
||||
|
||||
```python
|
||||
ACL_PERMISSION_NAMES = {
|
||||
0: "Guest",
|
||||
1: "Read-only",
|
||||
2: "Read-write",
|
||||
3: "Admin",
|
||||
}
|
||||
```
|
||||
|
||||
### Response Models
|
||||
|
||||
```python
|
||||
class NeighborInfo(BaseModel):
|
||||
pubkey_prefix: str # 4-12 char prefix
|
||||
name: str | None # Resolved contact name
|
||||
snr: float # Signal-to-noise ratio in dB
|
||||
last_heard_seconds: int # Seconds since last heard
|
||||
|
||||
class AclEntry(BaseModel):
|
||||
pubkey_prefix: str # 12 char prefix
|
||||
name: str | None # Resolved contact name
|
||||
permission: int # 0-3
|
||||
permission_name: str # Human-readable name
|
||||
|
||||
class TelemetryResponse(BaseModel):
|
||||
# Status fields
|
||||
pubkey_prefix: str
|
||||
battery_volts: float # Converted from mV
|
||||
uptime_seconds: int
|
||||
# ... signal quality, packet counts, etc.
|
||||
|
||||
# Related data
|
||||
neighbors: list[NeighborInfo]
|
||||
acl: list[AclEntry]
|
||||
```
|
||||
|
||||
## Repeater CLI Commands
|
||||
|
||||
After login via telemetry endpoint, you can send CLI commands to repeaters:
|
||||
|
||||
### Endpoint
|
||||
|
||||
`POST /api/contacts/{key}/command` - Send a CLI command (assumes already logged in)
|
||||
|
||||
### Request/Response
|
||||
|
||||
```python
|
||||
class CommandRequest(BaseModel):
|
||||
command: str # CLI command to send
|
||||
|
||||
class CommandResponse(BaseModel):
|
||||
command: str # Echo of sent command
|
||||
response: str # Response from repeater
|
||||
sender_timestamp: int | None # Timestamp from response
|
||||
```
|
||||
|
||||
### Common Commands
|
||||
|
||||
```
|
||||
get name / set name <value> # Repeater name
|
||||
get tx / set tx <dbm> # TX power
|
||||
get radio / set radio <freq,bw,sf,cr> # Radio params
|
||||
tempradio <freq,bw,sf,cr,mins> # Temporary radio change
|
||||
setperm <pubkey> <0-3> # ACL: 0=guest, 1=ro, 2=rw, 3=admin
|
||||
clock / clock sync # Get/sync time
|
||||
ver # Firmware version
|
||||
reboot # Restart repeater
|
||||
```
|
||||
|
||||
### CLI Response Filtering
|
||||
|
||||
CLI responses have `txt_type=1` (vs `txt_type=0` for normal messages). Both DM handling
|
||||
paths filter these to prevent storage—the command endpoint returns the response directly.
|
||||
|
||||
**Packet processor path** (primary):
|
||||
```python
|
||||
# In create_dm_message_from_decrypted()
|
||||
txt_type = decrypted.flags & 0x0F
|
||||
if txt_type == 1:
|
||||
return None # Skip CLI responses
|
||||
```
|
||||
|
||||
**Event handler path** (fallback):
|
||||
```python
|
||||
# In on_contact_message()
|
||||
txt_type = payload.get("txt_type", 0)
|
||||
if txt_type == 1:
|
||||
return # Skip CLI responses
|
||||
```
|
||||
|
||||
### Helper Function
|
||||
|
||||
`prepare_repeater_connection()` handles the login dance:
|
||||
1. Add contact to radio with stored path from DB (`out_path`, `out_path_len`)
|
||||
2. Send login with password
|
||||
3. Wait for key exchange to complete
|
||||
|
||||
### Contact Path Tracking
|
||||
|
||||
When advertisements are received, path data is extracted and stored:
|
||||
- `last_path`: Hex string of routing path bytes
|
||||
- `last_path_len`: Number of hops (-1=flood/unknown, 0=direct, >0=hops through repeaters)
|
||||
|
||||
**Shortest path selection**: When receiving echoed advertisements within 60 seconds, the shortest path is kept. This ensures we use the most efficient route when multiple paths exist.
|
||||
When changing backend behavior:
|
||||
1. Update/add router and repository tests.
|
||||
2. Confirm WS event contracts when payload shape changes.
|
||||
3. Run `PYTHONPATH=. uv run pytest tests/ -v`.
|
||||
4. If API contract changed, update frontend types and AGENTS docs.
|
||||
|
||||
@@ -173,7 +173,6 @@ async def _send_single_bot_message(
|
||||
|
||||
from app.models import SendChannelMessageRequest, SendDirectMessageRequest
|
||||
from app.routers.messages import send_channel_message, send_direct_message
|
||||
from app.websocket import broadcast_event
|
||||
|
||||
# Serialize bot sends and enforce minimum spacing
|
||||
async with _bot_send_lock:
|
||||
@@ -190,15 +189,11 @@ async def _send_single_bot_message(
|
||||
if is_dm:
|
||||
logger.info("Bot sending DM reply to %s", sender_key[:12])
|
||||
request = SendDirectMessageRequest(destination=sender_key, text=message_text)
|
||||
message = await send_direct_message(request)
|
||||
# Broadcast to WebSocket (endpoint returns to HTTP caller, bot needs explicit broadcast)
|
||||
broadcast_event("message", message.model_dump())
|
||||
await send_direct_message(request)
|
||||
elif channel_key:
|
||||
logger.info("Bot sending channel reply to %s", channel_key[:8])
|
||||
request = SendChannelMessageRequest(channel_key=channel_key, text=message_text)
|
||||
message = await send_channel_message(request)
|
||||
# Broadcast to WebSocket
|
||||
broadcast_event("message", message.model_dump())
|
||||
await send_channel_message(request)
|
||||
else:
|
||||
logger.warning("Cannot send bot response: no destination")
|
||||
return # Don't update timestamp if we didn't send
|
||||
|
||||
14
app/radio.py
14
app/radio.py
@@ -365,7 +365,7 @@ class RadioManager:
|
||||
self._meshcore = None
|
||||
logger.debug("Radio disconnected")
|
||||
|
||||
async def reconnect(self) -> bool:
|
||||
async def reconnect(self, *, broadcast_on_success: bool = True) -> bool:
|
||||
"""Attempt to reconnect to the radio.
|
||||
|
||||
Returns True if reconnection was successful, False otherwise.
|
||||
@@ -399,7 +399,8 @@ class RadioManager:
|
||||
|
||||
if self.is_connected:
|
||||
logger.info("Radio reconnected successfully at %s", self._connection_info)
|
||||
broadcast_health(True, self._connection_info)
|
||||
if broadcast_on_success:
|
||||
broadcast_health(True, self._connection_info)
|
||||
return True
|
||||
else:
|
||||
logger.warning("Reconnection failed: not connected after connect()")
|
||||
@@ -435,13 +436,18 @@ class RadioManager:
|
||||
|
||||
if not current_connected:
|
||||
# Attempt reconnection on every loop while disconnected
|
||||
if not self.is_reconnecting and await self.reconnect():
|
||||
if not self.is_reconnecting and await self.reconnect(
|
||||
broadcast_on_success=False
|
||||
):
|
||||
await self.post_connect_setup()
|
||||
broadcast_health(True, self._connection_info)
|
||||
self._last_connected = True
|
||||
|
||||
elif not self._last_connected and current_connected:
|
||||
# Connection restored (might have reconnected automatically)
|
||||
# Connection restored (might have reconnected automatically).
|
||||
# Always run setup before reporting healthy.
|
||||
logger.info("Radio connection restored")
|
||||
await self.post_connect_setup()
|
||||
broadcast_health(True, self._connection_info)
|
||||
self._last_connected = True
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ from app.event_handlers import track_pending_ack
|
||||
from app.models import Message, SendChannelMessageRequest, SendDirectMessageRequest
|
||||
from app.radio import radio_manager
|
||||
from app.repository import AmbiguousPublicKeyPrefixError, MessageRepository
|
||||
from app.websocket import broadcast_event
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(prefix="/messages", tags=["messages"])
|
||||
@@ -132,6 +133,9 @@ async def send_direct_message(request: SendDirectMessageRequest) -> Message:
|
||||
acked=0,
|
||||
)
|
||||
|
||||
# Broadcast so all connected clients (not just sender) see the outgoing message immediately.
|
||||
broadcast_event("message", message.model_dump())
|
||||
|
||||
# Trigger bots for outgoing DMs (runs in background, doesn't block response)
|
||||
from app.bot import run_bot_for_message
|
||||
|
||||
@@ -281,6 +285,9 @@ async def send_channel_message(request: SendChannelMessageRequest) -> Message:
|
||||
acked=acked_count,
|
||||
)
|
||||
|
||||
# Broadcast so all connected clients (not just sender) see the outgoing message immediately.
|
||||
broadcast_event("message", message.model_dump())
|
||||
|
||||
# Trigger bots for outgoing channel messages (runs in background, doesn't block response)
|
||||
from app.bot import run_bot_for_message
|
||||
|
||||
|
||||
@@ -1,749 +1,201 @@
|
||||
# Frontend AGENTS.md
|
||||
|
||||
This document provides context for AI assistants and developers working on the React frontend.
|
||||
This document is the frontend working guide for agents and developers.
|
||||
Keep it aligned with `frontend/src` source code.
|
||||
|
||||
## Technology Stack
|
||||
## Stack
|
||||
|
||||
- **React 18** - UI framework with hooks
|
||||
- **TypeScript** - Type safety
|
||||
- **Vite** - Build tool with HMR
|
||||
- **Vitest** - Testing framework
|
||||
- **Sonner** - Toast notifications
|
||||
- **shadcn/ui components** - Sheet, Tabs, Button (in `components/ui/`)
|
||||
- **meshcore-hashtag-cracker** - WebGPU-accelerated channel key bruteforcing
|
||||
- **nosleep.js** - Prevents device sleep during cracking
|
||||
- **leaflet / react-leaflet** - Interactive map for node locations
|
||||
- React 18 + TypeScript
|
||||
- Vite
|
||||
- Vitest + Testing Library
|
||||
- shadcn/ui primitives
|
||||
- Tailwind utility classes + local CSS (`index.css`, `styles.css`)
|
||||
- Sonner (toasts)
|
||||
- Leaflet / react-leaflet (map)
|
||||
- `meshcore-hashtag-cracker` + `nosleep.js` (channel cracker)
|
||||
|
||||
## Directory Structure
|
||||
## Frontend Map
|
||||
|
||||
```
|
||||
frontend/
|
||||
├── src/
|
||||
│ ├── main.tsx # Entry point, renders App
|
||||
│ ├── App.tsx # Main component, all state management
|
||||
│ ├── api.ts # REST API client
|
||||
│ ├── types.ts # TypeScript interfaces
|
||||
│ ├── useWebSocket.ts # WebSocket hook with auto-reconnect
|
||||
│ ├── messageCache.ts # LRU message cache for conversation switching
|
||||
│ ├── styles.css # Dark theme CSS
|
||||
│ ├── hooks/
|
||||
│ │ ├── index.ts
|
||||
│ │ ├── useConversationMessages.ts # Message fetching, pagination, cache integration
|
||||
│ │ ├── useUnreadCounts.ts # Unread count tracking
|
||||
│ │ └── useRepeaterMode.ts # Repeater login/CLI mode
|
||||
│ ├── utils/
|
||||
│ │ ├── messageParser.ts # Text parsing utilities
|
||||
│ │ ├── conversationState.ts # localStorage for message times (sidebar sorting)
|
||||
│ │ ├── pubkey.ts # Public key utilities (prefix matching, display names)
|
||||
│ │ └── contactAvatar.ts # Avatar generation (colors, initials/emoji)
|
||||
│ ├── components/
|
||||
│ │ ├── ui/ # shadcn/ui components
|
||||
│ │ │ ├── sonner.tsx # Toast notifications (Sonner wrapper)
|
||||
│ │ │ ├── sheet.tsx # Slide-out panel
|
||||
│ │ │ ├── tabs.tsx # Tab navigation
|
||||
│ │ │ └── button.tsx # Button component
|
||||
│ │ ├── StatusBar.tsx # Radio status, reconnect button, config button
|
||||
│ │ ├── Sidebar.tsx # Contacts/channels list, search, unread badges
|
||||
│ │ ├── MessageList.tsx # Message display, avatars, clickable senders
|
||||
│ │ ├── MessageInput.tsx # Text input with imperative handle
|
||||
│ │ ├── ContactAvatar.tsx # Contact profile image component
|
||||
│ │ ├── RawPacketList.tsx # Raw packet feed (tertiary debug/observation tool)
|
||||
│ │ ├── MapView.tsx # Leaflet map showing node locations
|
||||
│ │ ├── CrackerPanel.tsx # WebGPU channel key cracker (lazy-loads wordlist)
|
||||
│ │ ├── NewMessageModal.tsx
|
||||
│ │ └── SettingsModal.tsx # Unified settings: radio config, identity, connectivity, database, advertise
|
||||
│ └── test/
|
||||
│ ├── setup.ts # Test setup (jsdom, matchers)
|
||||
│ ├── messageParser.test.ts
|
||||
│ ├── unreadCounts.test.ts
|
||||
│ ├── contactAvatar.test.ts
|
||||
│ ├── messageDeduplication.test.ts
|
||||
│ └── websocket.test.ts
|
||||
├── index.html
|
||||
├── vite.config.ts # API proxy config
|
||||
├── tsconfig.json
|
||||
└── package.json
|
||||
```text
|
||||
frontend/src/
|
||||
├── App.tsx # App shell and orchestration
|
||||
├── api.ts # Typed REST client
|
||||
├── types.ts # Shared TS contracts
|
||||
├── useWebSocket.ts # WS lifecycle + event dispatch
|
||||
├── messageCache.ts # Conversation-scoped cache
|
||||
├── index.css # Global styles/utilities
|
||||
├── styles.css # Additional global app styles
|
||||
├── hooks/
|
||||
│ ├── useConversationMessages.ts
|
||||
│ ├── useUnreadCounts.ts
|
||||
│ ├── useRepeaterMode.ts
|
||||
│ └── useAirtimeTracking.ts
|
||||
├── utils/
|
||||
│ ├── urlHash.ts
|
||||
│ ├── conversationState.ts
|
||||
│ ├── favorites.ts
|
||||
│ ├── messageParser.ts
|
||||
│ ├── pathUtils.ts
|
||||
│ ├── pubkey.ts
|
||||
│ └── contactAvatar.ts
|
||||
├── components/
|
||||
│ ├── StatusBar.tsx
|
||||
│ ├── Sidebar.tsx
|
||||
│ ├── MessageList.tsx
|
||||
│ ├── MessageInput.tsx
|
||||
│ ├── NewMessageModal.tsx
|
||||
│ ├── SettingsModal.tsx
|
||||
│ ├── RawPacketList.tsx
|
||||
│ ├── MapView.tsx
|
||||
│ ├── VisualizerView.tsx
|
||||
│ ├── PacketVisualizer.tsx
|
||||
│ ├── PathModal.tsx
|
||||
│ ├── CrackerPanel.tsx
|
||||
│ ├── BotCodeEditor.tsx
|
||||
│ ├── ContactAvatar.tsx
|
||||
│ └── ui/
|
||||
└── test/
|
||||
├── api.test.ts
|
||||
├── appFavorites.test.tsx
|
||||
├── appStartupHash.test.tsx
|
||||
├── contactAvatar.test.ts
|
||||
├── integration.test.ts
|
||||
├── messageCache.test.ts
|
||||
├── messageParser.test.ts
|
||||
├── pathUtils.test.ts
|
||||
├── radioPresets.test.ts
|
||||
├── repeaterMode.test.ts
|
||||
├── settingsModal.test.tsx
|
||||
├── unreadCounts.test.ts
|
||||
├── urlHash.test.ts
|
||||
├── useConversationMessages.test.ts
|
||||
├── useRepeaterMode.test.ts
|
||||
├── useWebSocket.lifecycle.test.ts
|
||||
├── websocket.test.ts
|
||||
└── setup.ts
|
||||
```
|
||||
|
||||
## Intentional Security Design Decisions
|
||||
## Architecture Notes
|
||||
|
||||
The following are **deliberate design choices**, not bugs. They are documented in the README with appropriate warnings. Do not "fix" these or flag them as vulnerabilities.
|
||||
### State ownership
|
||||
|
||||
1. **No authentication UI**: There is no login page, session management, or auth tokens. The frontend assumes open access to the backend API. The app is designed for trusted networks only (home LAN, VPN).
|
||||
2. **No CORS restrictions on the backend**: The frontend may be served from a different origin during development (Vite on `:5173` vs backend on `:8000`). The backend allows all origins intentionally.
|
||||
3. **Arbitrary bot code**: The settings UI lets users write and enable Python bot code that the backend executes via `exec()`. This is a power-user feature, not a vulnerability.
|
||||
`App.tsx` orchestrates high-level state (health, config, contacts/channels, active conversation, UI flags).
|
||||
Specialized logic is delegated to hooks:
|
||||
- `useConversationMessages`: fetch, pagination, dedup/update helpers
|
||||
- `useUnreadCounts`: unread counters, mention tracking, recent-sort timestamps
|
||||
- `useRepeaterMode`: repeater login/command workflow
|
||||
|
||||
## State Management
|
||||
### Initial load + realtime
|
||||
|
||||
All application state lives in `App.tsx` using React hooks. No external state library.
|
||||
- Initial data: REST fetches (`api.ts`) for config/settings/channels/contacts/unreads.
|
||||
- WebSocket: realtime deltas/events.
|
||||
- On WS connect, backend sends `health` only; contacts/channels still come from REST.
|
||||
|
||||
### Core State
|
||||
### Message behavior
|
||||
|
||||
```typescript
|
||||
const [health, setHealth] = useState<HealthStatus | null>(null);
|
||||
const [config, setConfig] = useState<RadioConfig | null>(null);
|
||||
const [appSettings, setAppSettings] = useState<AppSettings | null>(null);
|
||||
const [contacts, setContacts] = useState<Contact[]>([]);
|
||||
const [channels, setChannels] = useState<Channel[]>([]);
|
||||
const [messages, setMessages] = useState<Message[]>([]);
|
||||
const [rawPackets, setRawPackets] = useState<RawPacket[]>([]);
|
||||
const [activeConversation, setActiveConversation] = useState<Conversation | null>(null);
|
||||
const [unreadCounts, setUnreadCounts] = useState<Record<string, number>>({});
|
||||
```
|
||||
|
||||
### App Settings
|
||||
|
||||
App settings are stored server-side and include:
|
||||
- `favorites` - List of favorited conversations (channels/contacts)
|
||||
- `sidebar_sort_order` - 'recent' or 'alpha'
|
||||
- `auto_decrypt_dm_on_advert` - Auto-decrypt historical DMs on new contact
|
||||
- `experimental_channel_double_send` - Experimental setting to send a byte-perfect channel resend after 3 seconds
|
||||
- `last_message_times` - Map of conversation keys to last message timestamps
|
||||
|
||||
**Migration**: On first load, localStorage preferences are migrated to the server.
|
||||
The `preferences_migrated` flag prevents duplicate migrations.
|
||||
|
||||
### Message Cache (`messageCache.ts`)
|
||||
|
||||
An LRU cache stores messages for recently-visited conversations so switching back is instant
|
||||
(no spinner, no fetch). On switch-away, the active conversation's messages are saved to cache.
|
||||
On switch-to, cached messages are restored immediately, then a silent background fetch reconciles
|
||||
with the backend — only updating state if something differs (missed WS message, stale ack).
|
||||
The happy path (cache is consistent) causes zero rerenders.
|
||||
|
||||
- Cache capacity: `MAX_CACHED_CONVERSATIONS` (20) entries, `MAX_MESSAGES_PER_ENTRY` (200) messages each
|
||||
- Uses `Map` insertion-order for LRU semantics (delete + re-insert promotes to MRU)
|
||||
- WebSocket messages for non-active cached conversations are written directly to the cache
|
||||
- `reconcile(current, fetched)` compares by message ID + ack count, returns merged array or `null`
|
||||
- Deleted conversations are evicted from cache via `remove()`
|
||||
|
||||
### State Flow
|
||||
|
||||
1. **REST API** fetches initial data on mount in parallel (config, settings, channels, contacts, unreads)
|
||||
2. **WebSocket** pushes real-time updates (health, messages, contact changes, raw packets)
|
||||
3. **Components** receive state as props, call handlers to trigger changes
|
||||
|
||||
**Note:** Contacts and channels are loaded via REST on mount (not from WebSocket initial push).
|
||||
The WebSocket only sends health on initial connect, then broadcasts real-time updates.
|
||||
|
||||
### Conversation Header
|
||||
|
||||
For contacts, the header shows path information alongside "Last heard":
|
||||
- `(Last heard: 10:30 AM, direct)` - Direct neighbor (path_len=0)
|
||||
- `(Last heard: 10:30 AM, 2 hops)` - Routed through repeaters (path_len>0)
|
||||
- `(Last heard: 10:30 AM, flood)` - No known path (path_len=-1)
|
||||
- Outgoing sends are optimistic in UI and persisted server-side.
|
||||
- Backend also emits WS `message` for outgoing sends so other clients stay in sync.
|
||||
- ACK/repeat updates arrive as `message_acked` events.
|
||||
|
||||
## WebSocket (`useWebSocket.ts`)
|
||||
|
||||
The `useWebSocket` hook manages real-time connection:
|
||||
- Auto reconnect (3s) with cleanup guard on unmount.
|
||||
- Heartbeat ping every 30s.
|
||||
- Event handlers: `health`, `contacts`, `channels`, `message`, `contact`, `raw_packet`, `message_acked`, `error`, `success`.
|
||||
|
||||
```typescript
|
||||
const wsHandlers = useMemo(() => ({
|
||||
onHealth: (data: HealthStatus) => setHealth(data),
|
||||
onMessage: (msg: Message) => { /* add to list, track unread */ },
|
||||
onMessageAcked: (messageId: number, ackCount: number) => { /* update ack count */ },
|
||||
// ...
|
||||
}), []);
|
||||
## URL Hash Navigation (`utils/urlHash.ts`)
|
||||
|
||||
useWebSocket(wsHandlers);
|
||||
```
|
||||
Supported routes:
|
||||
- `#raw`
|
||||
- `#map`
|
||||
- `#map/focus/{pubkey_or_prefix}`
|
||||
- `#visualizer`
|
||||
- `#channel/{channelKey}`
|
||||
- `#channel/{channelKey}/{label}`
|
||||
- `#contact/{publicKey}`
|
||||
- `#contact/{publicKey}/{label}`
|
||||
|
||||
### Features
|
||||
Legacy name-based hashes are still accepted for compatibility.
|
||||
|
||||
- **Auto-reconnect**: Reconnects after 3 seconds on disconnect
|
||||
- **Heartbeat**: Sends ping every 30 seconds
|
||||
- **Event types**: `health`, `contacts`, `channels`, `message`, `contact`, `raw_packet`, `message_acked`, `error`
|
||||
- **Error handling**: `onError` handler displays toast notifications for backend errors
|
||||
## Conversation State Keys (`utils/conversationState.ts`)
|
||||
|
||||
### URL Detection
|
||||
`getStateKey(type, id)` produces:
|
||||
- channels: `channel-{channelKey}`
|
||||
- contacts: `contact-{publicKey}`
|
||||
|
||||
```typescript
|
||||
const isDev = window.location.port === '5173';
|
||||
const wsUrl = isDev
|
||||
? 'ws://localhost:8000/api/ws'
|
||||
: `${protocol}//${window.location.host}/api/ws`;
|
||||
```
|
||||
Use full contact public key here (not 12-char prefix).
|
||||
|
||||
## API Client (`api.ts`)
|
||||
`conversationState.ts` keeps an in-memory cache and localStorage helpers used for migration/compatibility.
|
||||
Canonical persistence for unread and sort metadata is server-side (`app_settings` + read-state endpoints).
|
||||
|
||||
Typed REST client with consistent error handling:
|
||||
## Utilities
|
||||
|
||||
```typescript
|
||||
import { api } from './api';
|
||||
### `utils/pubkey.ts`
|
||||
|
||||
// Health
|
||||
await api.getHealth();
|
||||
Current public export:
|
||||
- `getContactDisplayName(name, pubkey)`
|
||||
|
||||
// Radio
|
||||
await api.getRadioConfig();
|
||||
await api.updateRadioConfig({ name: 'MyRadio' });
|
||||
await api.sendAdvertisement();
|
||||
It falls back to a 12-char prefix when `name` is missing.
|
||||
|
||||
// Contacts/Channels
|
||||
await api.getContacts();
|
||||
await api.createContact(publicKey, name, tryHistorical); // Create contact, optionally decrypt historical DMs
|
||||
await api.getChannels();
|
||||
await api.createChannel('#test');
|
||||
### `utils/pathUtils.ts`
|
||||
|
||||
// Messages
|
||||
await api.getMessages({ type: 'CHAN', conversation_key: channelKey, limit: 200 });
|
||||
await api.sendChannelMessage(channelKey, 'Hello');
|
||||
await api.sendDirectMessage(publicKey, 'Hello');
|
||||
Distance/validation helpers used by path + map UI.
|
||||
|
||||
// Historical decryption
|
||||
await api.decryptHistoricalPackets({ key_type: 'channel', channel_name: '#test' });
|
||||
### `utils/favorites.ts`
|
||||
|
||||
// Radio reconnection
|
||||
await api.reconnectRadio(); // Returns { status, message, connected }
|
||||
LocalStorage migration helpers for favorites; canonical favorites are server-side.
|
||||
|
||||
// Repeater telemetry
|
||||
await api.requestTelemetry(publicKey, password); // Returns TelemetryResponse
|
||||
## Types and Contracts (`types.ts`)
|
||||
|
||||
// Repeater CLI commands (after login)
|
||||
await api.sendRepeaterCommand(publicKey, 'ver'); // Returns CommandResponse
|
||||
```
|
||||
`AppSettings` currently includes:
|
||||
- `max_radio_contacts`
|
||||
- `experimental_channel_double_send`
|
||||
- `favorites`
|
||||
- `auto_decrypt_dm_on_advert`
|
||||
- `sidebar_sort_order`
|
||||
- `last_message_times`
|
||||
- `preferences_migrated`
|
||||
- `advert_interval`
|
||||
- `bots`
|
||||
|
||||
### API Proxy (Development)
|
||||
Backend also tracks `last_advert_time` in settings responses.
|
||||
|
||||
Vite proxies `/api/*` to backend (backend routes are already prefixed with `/api`):
|
||||
## Repeater Mode
|
||||
|
||||
```typescript
|
||||
// vite.config.ts
|
||||
server: {
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:8000',
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
For repeater contacts (`type=2`):
|
||||
1. Telemetry/login phase (`POST /api/contacts/{key}/telemetry`)
|
||||
2. Command phase (`POST /api/contacts/{key}/command`)
|
||||
|
||||
## Type Definitions (`types.ts`)
|
||||
CLI responses are rendered as local-only messages (not persisted to DB).
|
||||
|
||||
### Key Interfaces
|
||||
## Styling
|
||||
|
||||
```typescript
|
||||
interface Contact {
|
||||
public_key: string; // 64-char hex public key
|
||||
name: string | null;
|
||||
type: number; // 0=unknown, 1=client, 2=repeater, 3=room
|
||||
on_radio: boolean;
|
||||
last_path_len: number; // -1=flood, 0=direct, >0=hops through repeaters
|
||||
last_path: string | null; // Hex routing path
|
||||
last_seen: number | null; // Unix timestamp
|
||||
// ...
|
||||
}
|
||||
UI styling is mostly utility-class driven (Tailwind-style classes in JSX) plus shared globals in `index.css` and `styles.css`.
|
||||
Do not rely on old class-only layout assumptions.
|
||||
|
||||
interface Channel {
|
||||
key: string; // 32-char hex channel key
|
||||
name: string;
|
||||
is_hashtag: boolean;
|
||||
on_radio: boolean;
|
||||
}
|
||||
## Security Posture (intentional)
|
||||
|
||||
interface Message {
|
||||
id: number;
|
||||
type: 'PRIV' | 'CHAN';
|
||||
conversation_key: string; // public key for PRIV, channel key for CHAN
|
||||
text: string;
|
||||
outgoing: boolean;
|
||||
acked: number; // 0=not acked, 1+=ack count (flood echoes)
|
||||
// ...
|
||||
}
|
||||
|
||||
interface Conversation {
|
||||
type: 'contact' | 'channel' | 'raw' | 'map' | 'visualizer';
|
||||
id: string; // public key for contacts, channel key for channels, 'raw'/'map'/'visualizer' for special views
|
||||
name: string;
|
||||
}
|
||||
|
||||
interface Favorite {
|
||||
type: 'channel' | 'contact';
|
||||
id: string; // Channel key or contact public key
|
||||
}
|
||||
|
||||
interface AppSettings {
|
||||
max_radio_contacts: number;
|
||||
experimental_channel_double_send: boolean;
|
||||
favorites: Favorite[];
|
||||
auto_decrypt_dm_on_advert: boolean;
|
||||
sidebar_sort_order: 'recent' | 'alpha';
|
||||
last_message_times: Record<string, number>;
|
||||
preferences_migrated: boolean;
|
||||
}
|
||||
|
||||
// Repeater telemetry types
|
||||
interface NeighborInfo {
|
||||
pubkey_prefix: string;
|
||||
name: string | null;
|
||||
snr: number;
|
||||
last_heard_seconds: number;
|
||||
}
|
||||
|
||||
interface AclEntry {
|
||||
pubkey_prefix: string;
|
||||
name: string | null;
|
||||
permission: number;
|
||||
permission_name: string;
|
||||
}
|
||||
|
||||
interface TelemetryResponse {
|
||||
battery_volts: number;
|
||||
uptime_seconds: number;
|
||||
// ... status fields
|
||||
neighbors: NeighborInfo[];
|
||||
acl: AclEntry[];
|
||||
}
|
||||
|
||||
interface CommandResponse {
|
||||
command: string;
|
||||
response: string;
|
||||
sender_timestamp: number | null;
|
||||
}
|
||||
```
|
||||
|
||||
## Component Patterns
|
||||
|
||||
### MessageInput with Imperative Handle
|
||||
|
||||
Exposes `appendText` method for click-to-mention:
|
||||
|
||||
```typescript
|
||||
export interface MessageInputHandle {
|
||||
appendText: (text: string) => void;
|
||||
}
|
||||
|
||||
export const MessageInput = forwardRef<MessageInputHandle, MessageInputProps>(
|
||||
function MessageInput({ onSend, disabled, isRepeaterMode }, ref) {
|
||||
useImperativeHandle(ref, () => ({
|
||||
appendText: (text: string) => {
|
||||
setText((prev) => prev + text);
|
||||
inputRef.current?.focus();
|
||||
},
|
||||
}));
|
||||
// ...
|
||||
}
|
||||
);
|
||||
|
||||
// Usage in App.tsx
|
||||
const messageInputRef = useRef<MessageInputHandle>(null);
|
||||
messageInputRef.current?.appendText(`@[${sender}] `);
|
||||
```
|
||||
|
||||
### Repeater Mode
|
||||
|
||||
Repeater contacts (type=2) have a two-phase interaction:
|
||||
|
||||
**Phase 1: Login (password mode)**
|
||||
- Input type changes to `password`
|
||||
- Button shows "Fetch" instead of "Send"
|
||||
- Enter "." for empty password (converted to empty string)
|
||||
- Submitting requests telemetry + logs in
|
||||
|
||||
**Phase 2: CLI commands (after login)**
|
||||
- Input switches back to normal text
|
||||
- Placeholder shows "Enter CLI command..."
|
||||
- Commands sent via `/contacts/{key}/command` endpoint
|
||||
- Responses displayed as local messages (not persisted to database)
|
||||
|
||||
```typescript
|
||||
// State tracking
|
||||
const [repeaterLoggedIn, setRepeaterLoggedIn] = useState(false);
|
||||
|
||||
// Reset on conversation change
|
||||
useEffect(() => {
|
||||
setRepeaterLoggedIn(false);
|
||||
}, [activeConversation?.id]);
|
||||
|
||||
// Mode switches after successful telemetry
|
||||
const isRepeaterMode = activeContactIsRepeater && !repeaterLoggedIn;
|
||||
|
||||
<MessageInput
|
||||
onSend={isRepeaterMode ? handleTelemetryRequest :
|
||||
(repeaterLoggedIn ? handleRepeaterCommand : handleSendMessage)}
|
||||
isRepeaterMode={isRepeaterMode}
|
||||
placeholder={repeaterLoggedIn ? 'Enter CLI command...' : undefined}
|
||||
/>
|
||||
```
|
||||
|
||||
Telemetry response is displayed as three local messages (not persisted):
|
||||
1. **Telemetry** - Battery voltage, uptime, signal quality, packet stats
|
||||
2. **Neighbors** - Sorted by SNR (highest first), with resolved names
|
||||
3. **ACL** - Access control list with permission levels
|
||||
|
||||
### Repeater Message Rendering
|
||||
|
||||
Repeater CLI responses often contain colons (e.g., `clock: 12:30:00`). To prevent
|
||||
incorrect sender parsing, MessageList skips `parseSenderFromText()` for repeater contacts:
|
||||
|
||||
```typescript
|
||||
const isRepeater = contact?.type === CONTACT_TYPE_REPEATER;
|
||||
const { sender, content } = isRepeater
|
||||
? { sender: null, content: msg.text } // Preserve full text
|
||||
: parseSenderFromText(msg.text);
|
||||
```
|
||||
|
||||
### Unread Count Tracking
|
||||
|
||||
Uses refs to avoid stale closures in memoized handlers:
|
||||
|
||||
```typescript
|
||||
const activeConversationRef = useRef<Conversation | null>(null);
|
||||
|
||||
// Keep ref in sync
|
||||
useEffect(() => {
|
||||
activeConversationRef.current = activeConversation;
|
||||
}, [activeConversation]);
|
||||
|
||||
// In WebSocket handler (can safely access current value)
|
||||
const activeConv = activeConversationRef.current;
|
||||
```
|
||||
|
||||
### State Tracking Keys
|
||||
|
||||
State tracking keys (for message times used in sidebar sorting) are generated by `getStateKey()`:
|
||||
|
||||
```typescript
|
||||
import { getStateKey } from './utils/conversationState';
|
||||
|
||||
// Channels: "channel-{channelKey}"
|
||||
getStateKey('channel', channelKey) // e.g., "channel-8B3387E9C5CDEA6AC9E5EDBAA115CD72"
|
||||
|
||||
// Contacts: "contact-{12-char-prefix}"
|
||||
getStateKey('contact', publicKey) // e.g., "contact-abc123def456"
|
||||
```
|
||||
|
||||
**Note:** `getStateKey()` is NOT the same as `Message.conversation_key`. The state key is prefixed
|
||||
for local state tracking, while `conversation_key` is the raw database field.
|
||||
|
||||
### Read State (Server-Side)
|
||||
|
||||
Unread tracking uses server-side `last_read_at` timestamps for cross-device consistency:
|
||||
|
||||
```typescript
|
||||
// Fetch aggregated unread counts from server (replaces bulk message fetch + client-side counting)
|
||||
await api.getUnreads(myName); // Returns { counts, mentions, last_message_times }
|
||||
|
||||
// Mark as read via API (called automatically when viewing conversation)
|
||||
await api.markContactRead(publicKey);
|
||||
await api.markChannelRead(channelKey);
|
||||
await api.markAllRead(); // Bulk mark all as read
|
||||
```
|
||||
|
||||
The `useUnreadCounts` hook fetches counts from `GET /api/read-state/unreads` on mount and
|
||||
when channels/contacts change. Real-time increments are still tracked client-side via WebSocket
|
||||
message events. The server computes unread counts using `last_read_at` vs `received_at`.
|
||||
|
||||
## Utility Functions
|
||||
|
||||
### Message Parser (`utils/messageParser.ts`)
|
||||
|
||||
```typescript
|
||||
// Parse "sender: message" format from channel messages
|
||||
parseSenderFromText(text: string): { sender: string | null; content: string }
|
||||
|
||||
// Format Unix timestamp to time string
|
||||
formatTime(timestamp: number): string
|
||||
```
|
||||
|
||||
### Public Key Utilities (`utils/pubkey.ts`)
|
||||
|
||||
Consistent handling of 64-char full keys and 12-char prefixes:
|
||||
|
||||
```typescript
|
||||
import { getPubkeyPrefix, pubkeysMatch, getContactDisplayName } from './utils/pubkey';
|
||||
|
||||
// Extract 12-char prefix (works with full keys or existing prefixes)
|
||||
getPubkeyPrefix(key) // "abc123def456..."
|
||||
|
||||
// Compare keys by prefix (handles mixed full/prefix comparisons)
|
||||
pubkeysMatch(key1, key2) // true if prefixes match
|
||||
|
||||
// Get display name with fallback to prefix
|
||||
getContactDisplayName(name, publicKey) // name or first 12 chars of key
|
||||
```
|
||||
|
||||
### Conversation State (`utils/conversationState.ts`)
|
||||
|
||||
```typescript
|
||||
import { getStateKey, setLastMessageTime, getLastMessageTimes } from './utils/conversationState';
|
||||
|
||||
// Generate state tracking key (NOT the same as Message.conversation_key)
|
||||
getStateKey('channel', channelKey)
|
||||
getStateKey('contact', publicKey)
|
||||
|
||||
// Track message times for sidebar sorting (stored in localStorage)
|
||||
setLastMessageTime(stateKey, timestamp)
|
||||
getLastMessageTimes() // Returns all tracked message times
|
||||
```
|
||||
|
||||
**Note:** Read state (`last_read_at`) is tracked server-side, not in localStorage.
|
||||
|
||||
### Contact Avatar (`utils/contactAvatar.ts`)
|
||||
|
||||
Generates consistent profile "images" for contacts using hash-based colors:
|
||||
|
||||
```typescript
|
||||
import { getContactAvatar, CONTACT_TYPE_REPEATER } from './utils/contactAvatar';
|
||||
|
||||
// Get avatar info for a contact
|
||||
const avatar = getContactAvatar(name, publicKey, contactType);
|
||||
// Returns: { text: 'JD', background: 'hsl(180, 60%, 40%)', textColor: '#ffffff' }
|
||||
|
||||
// Repeaters (type=2) always show 🛜 with gray background
|
||||
const repeaterAvatar = getContactAvatar('Some Repeater', key, CONTACT_TYPE_REPEATER);
|
||||
// Returns: { text: '🛜', background: '#444444', textColor: '#ffffff' }
|
||||
```
|
||||
|
||||
Avatar text priority:
|
||||
1. First emoji in name
|
||||
2. Initials (first letter + first letter after space)
|
||||
3. Single first letter
|
||||
4. First 2 chars of public key (fallback)
|
||||
|
||||
## CSS Patterns
|
||||
|
||||
The app uses a minimal dark theme in `styles.css`.
|
||||
|
||||
### Key Classes
|
||||
|
||||
```css
|
||||
.app /* Root container */
|
||||
.status-bar /* Top bar with radio info */
|
||||
.sidebar /* Left panel with contacts/channels */
|
||||
.sidebar-item /* Individual contact/channel row */
|
||||
.sidebar-item.unread /* Bold with badge */
|
||||
.message-area /* Main content area */
|
||||
.message-list /* Scrollable message container */
|
||||
.message /* Individual message */
|
||||
.message.outgoing /* Right-aligned, different color */
|
||||
.message .sender /* Clickable sender name */
|
||||
```
|
||||
|
||||
### Unread Badge
|
||||
|
||||
```css
|
||||
.sidebar-item.unread .name {
|
||||
font-weight: 700;
|
||||
color: #fff;
|
||||
}
|
||||
.sidebar-item .unread-badge {
|
||||
background: #4caf50;
|
||||
color: #fff;
|
||||
font-size: 10px;
|
||||
padding: 2px 6px;
|
||||
border-radius: 10px;
|
||||
}
|
||||
```
|
||||
- No authentication UI.
|
||||
- Frontend assumes trusted network usage.
|
||||
- Bot editor intentionally allows arbitrary backend bot code configuration.
|
||||
|
||||
## Testing
|
||||
|
||||
Run tests with:
|
||||
```bash
|
||||
cd frontend
|
||||
npm run test:run # Single run
|
||||
npm run test # Watch mode
|
||||
```
|
||||
|
||||
### Test Files
|
||||
|
||||
- `messageParser.test.ts` - Sender extraction, time formatting, conversation keys
|
||||
- `unreadCounts.test.ts` - Unread tracking logic
|
||||
- `contactAvatar.test.ts` - Avatar text extraction, color generation, repeater handling
|
||||
- `useConversationMessages.test.ts` - Message content key generation, ack update logic
|
||||
- `messageCache.test.ts` - LRU cache: eviction, dedup, ack updates, reconciliation
|
||||
- `websocket.test.ts` - WebSocket message routing
|
||||
- `repeaterMode.test.ts` - Repeater CLI parsing, password "." conversion
|
||||
- `useRepeaterMode.test.ts` - Repeater hook: login flow, CLI commands, state reset
|
||||
- `integration.test.ts` - Cross-component integration scenarios
|
||||
- `urlHash.test.ts` - URL hash parsing and generation
|
||||
- `pathUtils.test.ts` - Path distance calculation utilities
|
||||
- `radioPresets.test.ts` - Radio preset configuration
|
||||
- `api.test.ts` - API client request formatting
|
||||
|
||||
### Test Setup
|
||||
|
||||
Tests use jsdom environment with `@testing-library/react`:
|
||||
|
||||
```typescript
|
||||
// src/test/setup.ts
|
||||
import '@testing-library/jest-dom';
|
||||
```
|
||||
|
||||
## Common Tasks
|
||||
|
||||
### Adding a New Component
|
||||
|
||||
1. Create component in `src/components/`
|
||||
2. Add TypeScript props interface
|
||||
3. Import and use in `App.tsx` or parent component
|
||||
4. Add styles to `styles.css`
|
||||
|
||||
### Adding a New API Endpoint
|
||||
|
||||
1. Add method to `api.ts`
|
||||
2. Add/update types in `types.ts`
|
||||
3. Call from `App.tsx` or component
|
||||
|
||||
### Adding New WebSocket Event
|
||||
|
||||
1. Add handler option to `UseWebSocketOptions` interface in `useWebSocket.ts`
|
||||
2. Add case to `onmessage` switch
|
||||
3. Provide handler in `wsHandlers` object in `App.tsx`
|
||||
|
||||
### Adding State
|
||||
|
||||
1. Add `useState` in `App.tsx`
|
||||
2. Pass down as props to components
|
||||
3. If needed in WebSocket handler, also use a ref to avoid stale closures
|
||||
|
||||
## Development Workflow
|
||||
|
||||
```bash
|
||||
# Start dev server (hot reload)
|
||||
npm run dev
|
||||
|
||||
# Build for production
|
||||
npm run build
|
||||
|
||||
# Preview production build
|
||||
npm run preview
|
||||
|
||||
# Run tests
|
||||
npm run test:run
|
||||
npm run build
|
||||
```
|
||||
|
||||
The dev server runs on port 5173 and proxies API requests to `localhost:8000`.
|
||||
|
||||
### Production Build
|
||||
|
||||
In production, the FastAPI backend serves the compiled frontend from `frontend/dist`:
|
||||
When touching cross-layer contracts, also run backend tests from repo root:
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
# Then run backend: uv run uvicorn app.main:app --host 0.0.0.0 --port 8000
|
||||
PYTHONPATH=. uv run pytest tests/ -v
|
||||
```
|
||||
|
||||
## URL Hash Navigation
|
||||
## Editing Checklist
|
||||
|
||||
Deep linking to conversations via URL hash:
|
||||
|
||||
- `#channel/RoomName` - Opens a channel (leading `#` stripped from name for cleaner URLs)
|
||||
- `#contact/ContactName` - Opens a DM
|
||||
- `#raw` - Opens the raw packet feed
|
||||
- `#map` - Opens the node map
|
||||
|
||||
```typescript
|
||||
// Parse hash on initial load
|
||||
const hashConv = parseHashConversation();
|
||||
|
||||
// Update hash when conversation changes (uses replaceState to avoid history pollution)
|
||||
window.history.replaceState(null, '', newHash);
|
||||
```
|
||||
|
||||
## CrackerPanel
|
||||
|
||||
The `CrackerPanel` component provides WebGPU-accelerated brute-forcing of channel keys for undecrypted GROUP_TEXT packets.
|
||||
|
||||
### Features
|
||||
|
||||
- **Dictionary attack first**: Uses `words.txt` wordlist
|
||||
- **GPU bruteforce**: Falls back to character-by-character search
|
||||
- **Queue management**: Automatically processes new packets as they arrive
|
||||
- **Auto-channel creation**: Cracked channels are automatically added to the channel list
|
||||
- **Configurable max length**: Adjustable while running (default: 6)
|
||||
- **Retry failed**: Option to retry failed packets at increasing lengths
|
||||
- **NoSleep integration**: Prevents device sleep during cracking via `nosleep.js`
|
||||
- **Global collapsible panel**: Toggle from sidebar, runs in background when hidden
|
||||
|
||||
### Key Implementation Patterns
|
||||
|
||||
Uses refs to avoid stale closures in async callbacks:
|
||||
|
||||
```typescript
|
||||
const isRunningRef = useRef(false);
|
||||
const isProcessingRef = useRef(false); // Prevents concurrent GPU operations
|
||||
const queueRef = useRef<Map<number, QueueItem>>(new Map());
|
||||
const retryFailedRef = useRef(false);
|
||||
const maxLengthRef = useRef(6);
|
||||
```
|
||||
|
||||
Progress reporting shows rate in Mkeys/s or Gkeys/s depending on speed.
|
||||
|
||||
## MapView
|
||||
|
||||
The `MapView` component displays contacts with GPS coordinates on an interactive Leaflet map.
|
||||
|
||||
### Features
|
||||
|
||||
- **Location filtering**: Only shows contacts with lat/lon that were heard within the last 7 days
|
||||
- **Freshness coloring**: Markers colored by how recently the contact was heard:
|
||||
- Bright green (`#22c55e`) - less than 1 hour ago
|
||||
- Light green (`#4ade80`) - less than 1 day ago
|
||||
- Yellow-green (`#a3e635`) - less than 3 days ago
|
||||
- Gray (`#9ca3af`) - older (up to 7 days)
|
||||
- **Node/repeater distinction**: Regular nodes have black outlines, repeaters are larger with no outline
|
||||
- **Geolocation**: Tries browser geolocation first, falls back to fitting all markers in view
|
||||
- **Popups**: Click a marker to see contact name, last heard time, and coordinates
|
||||
|
||||
### Data Source
|
||||
|
||||
Contact location data (`lat`, `lon`) is extracted from advertisement packets in the backend (`decoder.py`).
|
||||
The `last_seen` timestamp determines marker freshness.
|
||||
|
||||
## Sidebar Features
|
||||
|
||||
- **Sort toggle**: Default is 'recent' (most recent message first), can toggle to alphabetical
|
||||
- **Mark all as read**: Button appears when there are unread messages, clears all unread counts
|
||||
- **Cracker toggle**: Shows/hides the global cracker panel with running status indicator
|
||||
|
||||
## Toast Notifications
|
||||
|
||||
The app uses Sonner for toast notifications via a custom wrapper at `components/ui/sonner.tsx`:
|
||||
|
||||
```typescript
|
||||
import { toast } from './components/ui/sonner';
|
||||
|
||||
// Success toast (use sparingly - only for significant/destructive actions)
|
||||
toast.success('Channel deleted');
|
||||
|
||||
// Error toast with details
|
||||
toast.error('Failed to send message', {
|
||||
description: err instanceof Error ? err.message : 'Check radio connection',
|
||||
});
|
||||
```
|
||||
|
||||
### Error Handling Pattern
|
||||
|
||||
All async operations that can fail should show error toasts. Keep console.error for debugging:
|
||||
|
||||
```typescript
|
||||
try {
|
||||
await api.someOperation();
|
||||
} catch (err) {
|
||||
console.error('Failed to do X:', err);
|
||||
toast.error('Failed to do X', {
|
||||
description: err instanceof Error ? err.message : 'Check radio connection',
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
### Where Toasts Are Used
|
||||
|
||||
**Error toasts** (shown when operations fail):
|
||||
- `App.tsx`: Advertisement, channel delete, contact delete
|
||||
- `useConversationMessages.ts`: Message loading (initial and pagination)
|
||||
- `MessageInput.tsx`: Message send, telemetry request
|
||||
- `CrackerPanel.tsx`: Channel save after cracking, WebGPU unavailable
|
||||
- `StatusBar.tsx`: Manual reconnection failure
|
||||
- `useWebSocket.ts`: Backend errors via WebSocket `error` events
|
||||
|
||||
**Success toasts** (used sparingly for significant actions):
|
||||
- Radio connection/disconnection status changes
|
||||
- Manual reconnection success
|
||||
- Advertisement sent, channel/contact deleted (confirmation of intentional actions)
|
||||
|
||||
**Avoid success toasts** for routine operations like sending messages - only show errors.
|
||||
|
||||
The `<Toaster />` component is rendered in `App.tsx` with `position="top-right"`.
|
||||
1. If API/WS payloads change, update `types.ts`, handlers, and tests.
|
||||
2. If URL/hash behavior changes, update `utils/urlHash.ts` tests.
|
||||
3. If read/unread semantics change, update `useUnreadCounts` tests.
|
||||
4. Keep this file concise; prefer source links over speculative detail.
|
||||
|
||||
@@ -78,6 +78,7 @@ export function App() {
|
||||
const [config, setConfig] = useState<RadioConfig | null>(null);
|
||||
const [appSettings, setAppSettings] = useState<AppSettings | null>(null);
|
||||
const [contacts, setContacts] = useState<Contact[]>([]);
|
||||
const [contactsLoaded, setContactsLoaded] = useState(false);
|
||||
const [channels, setChannels] = useState<Channel[]>([]);
|
||||
const [rawPackets, setRawPackets] = useState<RawPacket[]>([]);
|
||||
const [activeConversation, setActiveConversation] = useState<Conversation | null>(null);
|
||||
@@ -178,7 +179,10 @@ export function App() {
|
||||
description: success.details,
|
||||
});
|
||||
},
|
||||
onContacts: (data: Contact[]) => setContacts(data),
|
||||
onContacts: (data: Contact[]) => {
|
||||
setContacts(data);
|
||||
setContactsLoaded(true);
|
||||
},
|
||||
onChannels: (data: Channel[]) => setChannels(data),
|
||||
onMessage: (msg: Message) => {
|
||||
const activeConv = activeConversationRef.current;
|
||||
@@ -339,7 +343,15 @@ export function App() {
|
||||
|
||||
// Fetch contacts and channels via REST (parallel, faster than WS serial push)
|
||||
api.getChannels().then(setChannels).catch(console.error);
|
||||
fetchAllContacts().then(setContacts).catch(console.error);
|
||||
fetchAllContacts()
|
||||
.then((data) => {
|
||||
setContacts(data);
|
||||
setContactsLoaded(true);
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error(err);
|
||||
setContactsLoaded(true);
|
||||
});
|
||||
}, [fetchConfig, fetchAppSettings, fetchUndecryptedCount, fetchAllContacts]);
|
||||
|
||||
// One-time migration of localStorage preferences to server
|
||||
@@ -467,10 +479,12 @@ export function App() {
|
||||
// Phase 2: Resolve contact hash (only if phase 1 didn't set a conversation)
|
||||
useEffect(() => {
|
||||
if (hasSetDefaultConversation.current || activeConversation) return;
|
||||
if (contacts.length === 0) return;
|
||||
|
||||
const hashConv = parseHashConversation();
|
||||
if (hashConv?.type === 'contact') {
|
||||
// Wait until the initial contacts load finishes so we don't fall back early.
|
||||
if (!contactsLoaded) return;
|
||||
|
||||
const contact = resolveContactFromHashToken(hashConv.name, contacts);
|
||||
if (contact) {
|
||||
setActiveConversation({
|
||||
@@ -481,21 +495,21 @@ export function App() {
|
||||
hasSetDefaultConversation.current = true;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Contact hash didn't match — fall back to Public if channels loaded
|
||||
if (channels.length > 0) {
|
||||
const publicChannel = channels.find((c) => c.name === 'Public');
|
||||
if (publicChannel) {
|
||||
setActiveConversation({
|
||||
type: 'channel',
|
||||
id: publicChannel.key,
|
||||
name: publicChannel.name,
|
||||
});
|
||||
hasSetDefaultConversation.current = true;
|
||||
// Contact hash didn't match — fall back to Public if channels loaded.
|
||||
if (channels.length > 0) {
|
||||
const publicChannel = channels.find((c) => c.name === 'Public');
|
||||
if (publicChannel) {
|
||||
setActiveConversation({
|
||||
type: 'channel',
|
||||
id: publicChannel.key,
|
||||
name: publicChannel.name,
|
||||
});
|
||||
hasSetDefaultConversation.current = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [contacts, channels, activeConversation]);
|
||||
}, [contacts, channels, activeConversation, contactsLoaded]);
|
||||
|
||||
// Keep ref in sync and update URL hash
|
||||
useEffect(() => {
|
||||
|
||||
@@ -1584,7 +1584,7 @@ export function PacketVisualizer({
|
||||
title="Split ambiguous repeaters into separate nodes based on traffic patterns (prev→next). Helps identify colliding prefixes representing different physical nodes."
|
||||
className={!showAmbiguousPaths ? 'text-muted-foreground' : ''}
|
||||
>
|
||||
Hueristically group repeaters by traffic pattern
|
||||
Heuristically group repeaters by traffic pattern
|
||||
</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
|
||||
189
frontend/src/test/appStartupHash.test.tsx
Normal file
189
frontend/src/test/appStartupHash.test.tsx
Normal file
@@ -0,0 +1,189 @@
|
||||
import React from 'react';
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
api: {
|
||||
getRadioConfig: vi.fn(),
|
||||
getSettings: vi.fn(),
|
||||
getUndecryptedPacketCount: vi.fn(),
|
||||
getChannels: vi.fn(),
|
||||
getContacts: vi.fn(),
|
||||
migratePreferences: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../api', () => ({
|
||||
api: mocks.api,
|
||||
}));
|
||||
|
||||
vi.mock('../useWebSocket', () => ({
|
||||
useWebSocket: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../hooks', () => ({
|
||||
useConversationMessages: () => ({
|
||||
messages: [],
|
||||
messagesLoading: false,
|
||||
loadingOlder: false,
|
||||
hasOlderMessages: false,
|
||||
setMessages: vi.fn(),
|
||||
fetchMessages: vi.fn(async () => {}),
|
||||
fetchOlderMessages: vi.fn(async () => {}),
|
||||
addMessageIfNew: vi.fn(),
|
||||
updateMessageAck: vi.fn(),
|
||||
}),
|
||||
useUnreadCounts: () => ({
|
||||
unreadCounts: {},
|
||||
mentions: {},
|
||||
lastMessageTimes: {},
|
||||
incrementUnread: vi.fn(),
|
||||
markAllRead: vi.fn(),
|
||||
trackNewMessage: vi.fn(),
|
||||
}),
|
||||
useRepeaterMode: () => ({
|
||||
repeaterLoggedIn: false,
|
||||
activeContactIsRepeater: false,
|
||||
handleTelemetryRequest: vi.fn(),
|
||||
handleRepeaterCommand: vi.fn(),
|
||||
}),
|
||||
getMessageContentKey: () => 'content-key',
|
||||
}));
|
||||
|
||||
vi.mock('../messageCache', () => ({
|
||||
addMessage: vi.fn(),
|
||||
updateAck: vi.fn(),
|
||||
remove: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../components/StatusBar', () => ({
|
||||
StatusBar: () => <div data-testid="status-bar" />,
|
||||
}));
|
||||
|
||||
vi.mock('../components/Sidebar', () => ({
|
||||
Sidebar: ({
|
||||
activeConversation,
|
||||
}: {
|
||||
activeConversation: { type: string; id: string; name: string } | null;
|
||||
}) => (
|
||||
<div data-testid="active-conversation">
|
||||
{activeConversation
|
||||
? `${activeConversation.type}:${activeConversation.id}:${activeConversation.name}`
|
||||
: 'none'}
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock('../components/MessageList', () => ({
|
||||
MessageList: () => <div data-testid="message-list" />,
|
||||
}));
|
||||
|
||||
vi.mock('../components/MessageInput', () => ({
|
||||
MessageInput: React.forwardRef((_props, ref) => {
|
||||
React.useImperativeHandle(ref, () => ({ appendText: vi.fn() }));
|
||||
return <div data-testid="message-input" />;
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('../components/NewMessageModal', () => ({
|
||||
NewMessageModal: () => null,
|
||||
}));
|
||||
|
||||
vi.mock('../components/SettingsModal', () => ({
|
||||
SettingsModal: () => null,
|
||||
SETTINGS_SECTION_ORDER: ['radio', 'identity', 'connectivity', 'database', 'bot'],
|
||||
SETTINGS_SECTION_LABELS: {
|
||||
radio: 'Radio',
|
||||
identity: 'Identity',
|
||||
connectivity: 'Connectivity',
|
||||
database: 'Database',
|
||||
bot: 'Bot',
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../components/RawPacketList', () => ({
|
||||
RawPacketList: () => null,
|
||||
}));
|
||||
|
||||
vi.mock('../components/MapView', () => ({
|
||||
MapView: () => null,
|
||||
}));
|
||||
|
||||
vi.mock('../components/VisualizerView', () => ({
|
||||
VisualizerView: () => null,
|
||||
}));
|
||||
|
||||
vi.mock('../components/CrackerPanel', () => ({
|
||||
CrackerPanel: () => null,
|
||||
}));
|
||||
|
||||
vi.mock('../components/ui/sheet', () => ({
|
||||
Sheet: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
SheetContent: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
SheetHeader: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
SheetTitle: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
}));
|
||||
|
||||
vi.mock('../components/ui/sonner', () => ({
|
||||
Toaster: () => null,
|
||||
toast: {
|
||||
success: vi.fn(),
|
||||
error: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
import { App } from '../App';
|
||||
|
||||
const publicChannel = {
|
||||
key: '8B3387E9C5CDEA6AC9E5EDBAA115CD72',
|
||||
name: 'Public',
|
||||
is_hashtag: false,
|
||||
on_radio: false,
|
||||
last_read_at: null,
|
||||
};
|
||||
|
||||
describe('App startup hash resolution', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
window.location.hash = `#contact/${'a'.repeat(64)}/Alice`;
|
||||
|
||||
mocks.api.getRadioConfig.mockResolvedValue({
|
||||
public_key: 'aa'.repeat(32),
|
||||
name: 'TestNode',
|
||||
lat: 0,
|
||||
lon: 0,
|
||||
tx_power: 17,
|
||||
max_tx_power: 22,
|
||||
radio: { freq: 910.525, bw: 62.5, sf: 7, cr: 5 },
|
||||
});
|
||||
mocks.api.getSettings.mockResolvedValue({
|
||||
max_radio_contacts: 200,
|
||||
experimental_channel_double_send: false,
|
||||
favorites: [],
|
||||
auto_decrypt_dm_on_advert: false,
|
||||
sidebar_sort_order: 'recent',
|
||||
last_message_times: {},
|
||||
preferences_migrated: true,
|
||||
advert_interval: 0,
|
||||
last_advert_time: 0,
|
||||
bots: [],
|
||||
});
|
||||
mocks.api.getUndecryptedPacketCount.mockResolvedValue({ count: 0 });
|
||||
mocks.api.getChannels.mockResolvedValue([publicChannel]);
|
||||
mocks.api.getContacts.mockResolvedValue([]);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
window.location.hash = '';
|
||||
});
|
||||
|
||||
it('falls back to Public when contact hash is unresolvable and contacts are empty', async () => {
|
||||
render(<App />);
|
||||
|
||||
await waitFor(() => {
|
||||
for (const node of screen.getAllByTestId('active-conversation')) {
|
||||
expect(node).toHaveTextContent(`channel:${publicChannel.key}:Public`);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
63
frontend/src/test/useWebSocket.lifecycle.test.ts
Normal file
63
frontend/src/test/useWebSocket.lifecycle.test.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import { act, renderHook } from '@testing-library/react';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { useWebSocket } from '../useWebSocket';
|
||||
|
||||
class MockWebSocket {
|
||||
static CONNECTING = 0;
|
||||
static OPEN = 1;
|
||||
static CLOSING = 2;
|
||||
static CLOSED = 3;
|
||||
static instances: MockWebSocket[] = [];
|
||||
|
||||
url: string;
|
||||
readyState = MockWebSocket.OPEN;
|
||||
onopen: (() => void) | null = null;
|
||||
onclose: (() => void) | null = null;
|
||||
onerror: ((error: unknown) => void) | null = null;
|
||||
onmessage: ((event: { data: string }) => void) | null = null;
|
||||
|
||||
constructor(url: string) {
|
||||
this.url = url;
|
||||
MockWebSocket.instances.push(this);
|
||||
}
|
||||
|
||||
close(): void {
|
||||
this.readyState = MockWebSocket.CLOSED;
|
||||
this.onclose?.();
|
||||
}
|
||||
|
||||
send(): void {}
|
||||
}
|
||||
|
||||
const originalWebSocket = globalThis.WebSocket;
|
||||
|
||||
describe('useWebSocket lifecycle', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
MockWebSocket.instances = [];
|
||||
globalThis.WebSocket = MockWebSocket as unknown as typeof WebSocket;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
globalThis.WebSocket = originalWebSocket;
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('does not reconnect after hook unmount cleanup', () => {
|
||||
const { unmount } = renderHook(() => useWebSocket({}));
|
||||
|
||||
expect(MockWebSocket.instances).toHaveLength(1);
|
||||
|
||||
act(() => {
|
||||
unmount();
|
||||
});
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(3100);
|
||||
});
|
||||
|
||||
// Unmount-triggered socket close should not start a new connection.
|
||||
expect(MockWebSocket.instances).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
@@ -31,6 +31,7 @@ interface UseWebSocketOptions {
|
||||
export function useWebSocket(options: UseWebSocketOptions) {
|
||||
const wsRef = useRef<WebSocket | null>(null);
|
||||
const reconnectTimeoutRef = useRef<number | null>(null);
|
||||
const shouldReconnectRef = useRef(true);
|
||||
const [connected, setConnected] = useState(false);
|
||||
|
||||
// Store options in ref to avoid stale closures in WebSocket handlers.
|
||||
@@ -71,6 +72,10 @@ export function useWebSocket(options: UseWebSocketOptions) {
|
||||
setConnected(false);
|
||||
wsRef.current = null;
|
||||
|
||||
if (!shouldReconnectRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Reconnect after 3 seconds
|
||||
if (reconnectTimeoutRef.current) {
|
||||
clearTimeout(reconnectTimeoutRef.current);
|
||||
@@ -140,6 +145,7 @@ export function useWebSocket(options: UseWebSocketOptions) {
|
||||
}, []); // No dependencies - handlers accessed through ref
|
||||
|
||||
useEffect(() => {
|
||||
shouldReconnectRef.current = true;
|
||||
connect();
|
||||
|
||||
// Ping every 30 seconds to keep connection alive
|
||||
@@ -150,6 +156,7 @@ export function useWebSocket(options: UseWebSocketOptions) {
|
||||
}, 30000);
|
||||
|
||||
return () => {
|
||||
shouldReconnectRef.current = false;
|
||||
clearInterval(pingInterval);
|
||||
if (reconnectTimeoutRef.current) {
|
||||
clearTimeout(reconnectTimeoutRef.current);
|
||||
|
||||
@@ -92,6 +92,112 @@ class TestMessagesEndpoint:
|
||||
|
||||
assert response.status_code == 503
|
||||
|
||||
def test_send_direct_message_emits_websocket_message_event(self):
|
||||
"""POST /messages/direct should emit a WS message event for other clients."""
|
||||
from fastapi.testclient import TestClient
|
||||
from meshcore import EventType
|
||||
|
||||
mock_mc = MagicMock()
|
||||
mock_mc.get_contact_by_key_prefix.return_value = {"public_key": "ab" * 32}
|
||||
mock_mc.commands.add_contact = AsyncMock(
|
||||
return_value=MagicMock(type=EventType.OK, payload={})
|
||||
)
|
||||
mock_mc.commands.send_msg = AsyncMock(
|
||||
return_value=MagicMock(type=EventType.MSG_SENT, payload={})
|
||||
)
|
||||
|
||||
mock_contact = MagicMock()
|
||||
mock_contact.public_key = "ab" * 32
|
||||
mock_contact.to_radio_dict.return_value = {"public_key": "ab" * 32}
|
||||
|
||||
def _capture_task(coro):
|
||||
coro.close()
|
||||
return MagicMock()
|
||||
|
||||
with (
|
||||
patch("app.dependencies.radio_manager") as mock_rm,
|
||||
patch(
|
||||
"app.repository.ContactRepository.get_by_key_or_prefix",
|
||||
new=AsyncMock(return_value=mock_contact),
|
||||
),
|
||||
patch("app.repository.ContactRepository.update_last_contacted", new=AsyncMock()),
|
||||
patch("app.repository.MessageRepository.create", new=AsyncMock(return_value=123)),
|
||||
patch("app.bot.run_bot_for_message", new=AsyncMock()),
|
||||
patch("app.routers.messages.asyncio.create_task", side_effect=_capture_task),
|
||||
patch("app.routers.messages.broadcast_event", create=True) as mock_broadcast,
|
||||
):
|
||||
mock_rm.is_connected = True
|
||||
mock_rm.meshcore = mock_mc
|
||||
|
||||
from app.main import app
|
||||
|
||||
client = TestClient(app)
|
||||
response = client.post(
|
||||
"/api/messages/direct",
|
||||
json={"destination": mock_contact.public_key, "text": "Hello"},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
mock_broadcast.assert_called_once()
|
||||
event_type, payload = mock_broadcast.call_args.args
|
||||
assert event_type == "message"
|
||||
assert payload["id"] == 123
|
||||
assert payload["type"] == "PRIV"
|
||||
|
||||
def test_send_channel_message_emits_websocket_message_event(self):
|
||||
"""POST /messages/channel should emit a WS message event for other clients."""
|
||||
from fastapi.testclient import TestClient
|
||||
from meshcore import EventType
|
||||
|
||||
mock_mc = MagicMock()
|
||||
mock_mc.self_info = {"name": "TestNode"}
|
||||
ok_result = MagicMock(type=EventType.MSG_SENT, payload={})
|
||||
mock_mc.commands.set_channel = AsyncMock(return_value=ok_result)
|
||||
mock_mc.commands.send_chan_msg = AsyncMock(return_value=ok_result)
|
||||
|
||||
mock_channel = MagicMock()
|
||||
mock_channel.name = "Public"
|
||||
mock_channel.key = "AA" * 16
|
||||
|
||||
def _capture_task(coro):
|
||||
coro.close()
|
||||
return MagicMock()
|
||||
|
||||
with (
|
||||
patch("app.dependencies.radio_manager") as mock_rm,
|
||||
patch(
|
||||
"app.repository.ChannelRepository.get_by_key",
|
||||
new=AsyncMock(return_value=mock_channel),
|
||||
),
|
||||
patch(
|
||||
"app.repository.AppSettingsRepository.get",
|
||||
new=AsyncMock(return_value=MagicMock(experimental_channel_double_send=False)),
|
||||
),
|
||||
patch("app.repository.MessageRepository.create", new=AsyncMock(return_value=456)),
|
||||
patch("app.repository.MessageRepository.get_ack_count", new=AsyncMock(return_value=0)),
|
||||
patch("app.decoder.calculate_channel_hash", return_value="abcd"),
|
||||
patch("app.bot.run_bot_for_message", new=AsyncMock()),
|
||||
patch("app.routers.messages.asyncio.create_task", side_effect=_capture_task),
|
||||
patch("app.routers.messages.broadcast_event", create=True) as mock_broadcast,
|
||||
):
|
||||
mock_rm.is_connected = True
|
||||
mock_rm.meshcore = mock_mc
|
||||
|
||||
from app.main import app
|
||||
|
||||
client = TestClient(app)
|
||||
response = client.post(
|
||||
"/api/messages/channel",
|
||||
json={"channel_key": mock_channel.key, "text": "Hello room"},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
mock_broadcast.assert_called_once()
|
||||
event_type, payload = mock_broadcast.call_args.args
|
||||
assert event_type == "message"
|
||||
assert payload["id"] == 456
|
||||
assert payload["type"] == "CHAN"
|
||||
|
||||
def test_send_direct_message_contact_not_found(self):
|
||||
"""Sending to unknown contact returns 404."""
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
@@ -16,6 +16,7 @@ from app.event_handlers import (
|
||||
register_event_handlers,
|
||||
track_pending_ack,
|
||||
)
|
||||
from app.repository import AmbiguousPublicKeyPrefixError
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
@@ -306,6 +307,45 @@ class TestContactMessageCLIFiltering:
|
||||
# SHOULD still be processed (defaults to txt_type=0)
|
||||
mock_repo.create.assert_called_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ambiguous_prefix_stores_dm_under_prefix(self):
|
||||
"""Ambiguous sender prefixes should still be stored under the prefix key."""
|
||||
from app.event_handlers import on_contact_message
|
||||
|
||||
with (
|
||||
patch("app.event_handlers.MessageRepository") as mock_repo,
|
||||
patch("app.event_handlers.ContactRepository") as mock_contact_repo,
|
||||
patch("app.event_handlers.broadcast_event") as mock_broadcast,
|
||||
patch("app.bot.run_bot_for_message", new_callable=AsyncMock),
|
||||
):
|
||||
mock_repo.create = AsyncMock(return_value=77)
|
||||
mock_contact_repo.get_by_key_or_prefix = AsyncMock(
|
||||
side_effect=AmbiguousPublicKeyPrefixError(
|
||||
"abc123",
|
||||
[
|
||||
"abc1230000000000000000000000000000000000000000000000000000000000",
|
||||
"abc123ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff",
|
||||
],
|
||||
)
|
||||
)
|
||||
|
||||
class MockEvent:
|
||||
payload = {
|
||||
"pubkey_prefix": "abc123",
|
||||
"text": "hello from ambiguous prefix",
|
||||
"txt_type": 0,
|
||||
"sender_timestamp": 1700000000,
|
||||
}
|
||||
|
||||
await on_contact_message(MockEvent())
|
||||
|
||||
mock_repo.create.assert_called_once()
|
||||
assert mock_repo.create.await_args.kwargs["conversation_key"] == "abc123"
|
||||
|
||||
mock_broadcast.assert_called_once()
|
||||
_, payload = mock_broadcast.call_args.args
|
||||
assert payload["conversation_key"] == "abc123"
|
||||
|
||||
|
||||
class TestEventHandlerRegistration:
|
||||
"""Test event handler registration and cleanup."""
|
||||
|
||||
@@ -4,6 +4,7 @@ These tests verify that connect() routes to the correct transport method
|
||||
based on settings.connection_type, and that connection_info is set correctly.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
@@ -168,3 +169,58 @@ class TestRadioManagerConnect:
|
||||
|
||||
old_mc.disconnect.assert_awaited_once()
|
||||
assert rm.meshcore is new_mc
|
||||
|
||||
|
||||
class TestConnectionMonitor:
|
||||
"""Tests for the background connection monitor loop."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_monitor_does_not_mark_connected_when_setup_fails(self):
|
||||
"""A reconnect with failing post-connect setup should not broadcast healthy status."""
|
||||
from app.radio import RadioManager
|
||||
|
||||
rm = RadioManager()
|
||||
rm._connection_info = "Serial: /dev/ttyUSB0"
|
||||
rm._last_connected = True
|
||||
rm._meshcore = MagicMock()
|
||||
rm._meshcore.is_connected = False
|
||||
|
||||
reconnect_calls = 0
|
||||
|
||||
async def _reconnect(*args, **kwargs):
|
||||
nonlocal reconnect_calls
|
||||
reconnect_calls += 1
|
||||
if reconnect_calls == 1:
|
||||
rm._meshcore = MagicMock()
|
||||
rm._meshcore.is_connected = True
|
||||
return True
|
||||
return False
|
||||
|
||||
sleep_calls = 0
|
||||
|
||||
async def _sleep(_seconds: float):
|
||||
nonlocal sleep_calls
|
||||
sleep_calls += 1
|
||||
if sleep_calls >= 3:
|
||||
raise asyncio.CancelledError()
|
||||
|
||||
rm.reconnect = AsyncMock(side_effect=_reconnect)
|
||||
rm.post_connect_setup = AsyncMock(side_effect=RuntimeError("setup failed"))
|
||||
|
||||
with (
|
||||
patch("app.radio.asyncio.sleep", side_effect=_sleep),
|
||||
patch("app.websocket.broadcast_health") as mock_broadcast_health,
|
||||
):
|
||||
await rm.start_connection_monitor()
|
||||
try:
|
||||
await rm._reconnect_task
|
||||
finally:
|
||||
await rm.stop_connection_monitor()
|
||||
|
||||
# Should report connection lost, but not report healthy until setup succeeds.
|
||||
mock_broadcast_health.assert_any_call(False, "Serial: /dev/ttyUSB0")
|
||||
healthy_calls = [
|
||||
call for call in mock_broadcast_health.call_args_list if call.args[0] is True
|
||||
]
|
||||
assert healthy_calls == []
|
||||
assert rm._last_connected is False
|
||||
|
||||
Reference in New Issue
Block a user