mirror of
https://github.com/jkingsman/Remote-Terminal-for-MeshCore.git
synced 2026-03-28 17:43:05 +01:00
Compare commits
50 Commits
bot-safety
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7151cf3846 | ||
|
|
6e5256acce | ||
|
|
7d27567ae9 | ||
|
|
5f0d042252 | ||
|
|
6f68dfc609 | ||
|
|
a32ddda79d | ||
|
|
ac6a5774af | ||
|
|
b12e612596 | ||
|
|
d1499ad75f | ||
|
|
79d5e69ee0 | ||
|
|
498770bd88 | ||
|
|
1405df6039 | ||
|
|
ac5e71d6f2 | ||
|
|
650a24a68c | ||
|
|
53f122e503 | ||
|
|
bf0533807a | ||
|
|
094058bad7 | ||
|
|
efeb047116 | ||
|
|
88c99e0983 | ||
|
|
983a37f68f | ||
|
|
bea3495b79 | ||
|
|
54c24c50d3 | ||
|
|
b7972f50a8 | ||
|
|
bab1693c82 | ||
|
|
f93844a01b | ||
|
|
26b740fe3c | ||
|
|
b0f5930e01 | ||
|
|
5b05fdefa1 | ||
|
|
b63153b3a1 | ||
|
|
3c5a832bef | ||
|
|
fd8bc4b56a | ||
|
|
2d943dedc5 | ||
|
|
137f41970d | ||
|
|
c833f1036b | ||
|
|
e15e6d83f7 | ||
|
|
4ead2ffcde | ||
|
|
f9ca35b3ae | ||
|
|
7c4a244e05 | ||
|
|
6eab75ec7e | ||
|
|
caf4bf4eff | ||
|
|
74e1f49db8 | ||
|
|
95c874e643 | ||
|
|
3b28ebfa49 | ||
|
|
d36c63f6b1 | ||
|
|
e8a4f5c349 | ||
|
|
b022aea71f | ||
|
|
5225a1c766 | ||
|
|
41400c0528 | ||
|
|
07928d930c | ||
|
|
26742d0c88 |
@@ -296,9 +296,9 @@ cd frontend
|
||||
npm run test:run
|
||||
```
|
||||
|
||||
### Before Completing Changes
|
||||
### Before Completing Major Changes
|
||||
|
||||
**Always run `./scripts/all_quality.sh` before finishing any changes that have modified code or tests.** It is the standard repo gate: autofix first, then type checks, tests, and the standard frontend build. This is not necessary for docs-only changes.
|
||||
**Run `./scripts/all_quality.sh` before finishing major changes that have modified code or tests.** It is the standard repo gate: autofix first, then type checks, tests, and the standard frontend build. This is not necessary for docs-only changes. For minor changes (like wording, color, spacing, etc.), wait until prompted to run the quality gate.
|
||||
|
||||
## API Summary
|
||||
|
||||
|
||||
13
CHANGELOG.md
13
CHANGELOG.md
@@ -1,3 +1,16 @@
|
||||
## [3.6.1] - 2026-03-26
|
||||
|
||||
Feature: MeshCore Map integration
|
||||
Feature: Add warning screen about bots
|
||||
Feature: Favicon reflects unread message state
|
||||
Feature: Show hop map in larger modal
|
||||
Feature: Add prebuilt frontend install script
|
||||
Feature: Add clean service installer script
|
||||
Feature: Swipe in to show menu
|
||||
Bugfix: Invalid backend API path serves error, not fallback index
|
||||
Bugfix: Fix some spacing/page height issues
|
||||
Misc: Misc. bugfixes and performance and test improvements
|
||||
|
||||
## [3.6.0] - 2026-03-22
|
||||
|
||||
Feature: Add incoming-packet analytics
|
||||
|
||||
33
LICENSES.md
33
LICENSES.md
@@ -1592,6 +1592,39 @@ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLI
|
||||
|
||||
</details>
|
||||
|
||||
### react-swipeable (7.0.2) — MIT
|
||||
|
||||
<details>
|
||||
<summary>Full license text</summary>
|
||||
|
||||
```
|
||||
The MIT License (MIT)
|
||||
|
||||
Copyright (C) 2014-2022 Josh Perez
|
||||
Copyright (C) 2014-2022 Brian Emil Hartz
|
||||
Copyright (C) 2022 Formidable Labs, Inc.
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in
|
||||
all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
THE SOFTWARE.
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
### sonner (2.0.7) — MIT
|
||||
|
||||
<details>
|
||||
|
||||
@@ -7,7 +7,7 @@ Backend server + browser interface for MeshCore mesh radio networks. Connect you
|
||||
* Run multiple Python bots that can analyze messages and respond to DMs and channels
|
||||
* Monitor unlimited contacts and channels (radio limits don't apply -- packets are decrypted server-side)
|
||||
* Access your radio remotely over your network or VPN
|
||||
* Search for hashtag room names for channels you don't have keys for yet
|
||||
* Search for hashtag channel names for channels you don't have keys for yet
|
||||
* Forward packets to MQTT, LetsMesh, MeshRank, SQS, Apprise, etc.
|
||||
* Use the more recent 1.14 firmwares which support multibyte pathing
|
||||
* Visualize the mesh as a map or node set, view repeater stats, and more!
|
||||
@@ -41,8 +41,6 @@ If you plan to contribute, read [CONTRIBUTING.md](CONTRIBUTING.md).
|
||||
- [UV](https://astral.sh/uv) package manager: `curl -LsSf https://astral.sh/uv/install.sh | sh`
|
||||
- MeshCore radio connected via USB serial, TCP, or BLE
|
||||
|
||||
If you are on a low-resource system and do not want to build the frontend locally, download the release zip named `remoteterm-prebuilt-frontend-vX.X.X-<short hash>.zip`. That bundle includes `frontend/prebuilt`, so you can run the app without doing a frontend build from source.
|
||||
|
||||
<details>
|
||||
<summary>Finding your serial port</summary>
|
||||
|
||||
@@ -97,6 +95,8 @@ Access the app at http://localhost:8000.
|
||||
|
||||
Source checkouts expect a normal frontend build in `frontend/dist`.
|
||||
|
||||
On Linux, if you want this installed as a persistent `systemd` service that starts on boot and restarts automatically on failure, run `bash scripts/install_service.sh` from the repo root.
|
||||
|
||||
## Path 1.5: Use The Prebuilt Release Zip
|
||||
|
||||
Release zips can be found as an asset within the [releases listed here](https://github.com/jkingsman/Remote-Terminal-for-MeshCore/releases). This can be beneficial on resource constrained systems that cannot cope with the RAM-hungry frontend build process.
|
||||
@@ -111,6 +111,8 @@ uv run uvicorn app.main:app --host 0.0.0.0 --port 8000
|
||||
|
||||
The release bundle includes `frontend/prebuilt`, so it does not require a local frontend build.
|
||||
|
||||
Alternatively, if you have already cloned the repo, you can fetch just the prebuilt frontend into your working tree without downloading the full release zip via `python3 scripts/fetch_prebuilt_frontend.py`.
|
||||
|
||||
## Path 2: Docker
|
||||
|
||||
> **Warning:** Docker has had reports intermittent issues with serial event subscriptions. The native method above is more reliable.
|
||||
|
||||
@@ -21,7 +21,7 @@ If the audit finds a mismatch, you'll see an error in the application UI and you
|
||||
|
||||
## HTTPS
|
||||
|
||||
WebGPU room-finding requires a secure context when you are not on `localhost`.
|
||||
WebGPU channel-finding requires a secure context when you are not on `localhost`.
|
||||
|
||||
Generate a local cert and start the backend with TLS:
|
||||
|
||||
@@ -46,59 +46,37 @@ Accept the browser warning, or use [mkcert](https://github.com/FiloSottile/mkcer
|
||||
|
||||
## Systemd Service
|
||||
|
||||
Assumes you are running from `/opt/remoteterm`; adjust paths if you deploy elsewhere.
|
||||
Two paths are available depending on your comfort level with Linux system administration.
|
||||
|
||||
### Simple install (recommended for most users)
|
||||
|
||||
On Linux systems, this is the recommended installation method if you want RemoteTerm set up as a persistent systemd service that starts automatically on boot and restarts automatically if it crashes. Run the installer script from the repo root. It runs as your current user, installs from wherever you cloned the repo, and prints a quick-reference cheatsheet when done — no separate service account or path juggling required.
|
||||
|
||||
```bash
|
||||
# Create service user
|
||||
sudo useradd -r -m -s /bin/false remoteterm
|
||||
|
||||
# Install to /opt/remoteterm
|
||||
sudo mkdir -p /opt/remoteterm
|
||||
sudo cp -r . /opt/remoteterm/
|
||||
sudo chown -R remoteterm:remoteterm /opt/remoteterm
|
||||
|
||||
# Install dependencies
|
||||
cd /opt/remoteterm
|
||||
sudo -u remoteterm uv venv
|
||||
sudo -u remoteterm uv sync
|
||||
|
||||
# If deploying from a source checkout, build the frontend first
|
||||
sudo -u remoteterm bash -lc 'cd /opt/remoteterm/frontend && npm install && npm run build'
|
||||
|
||||
# If deploying from the release zip artifact, frontend/prebuilt is already present
|
||||
bash scripts/install_service.sh
|
||||
```
|
||||
|
||||
Create `/etc/systemd/system/remoteterm.service` with:
|
||||
The script interactively asks which transport to use (serial auto-detect, serial with explicit port, TCP, or BLE), whether to build the frontend locally or download a prebuilt copy, whether to enable the bot system, and whether to set up HTTP Basic Auth. It handles dependency installation (`uv sync`), validates `node`/`npm` for local builds, adds your user to the `dialout` group if needed, writes the systemd unit file, and enables the service. After installation, normal operations work without any `sudo -u` gymnastics:
|
||||
|
||||
```ini
|
||||
[Unit]
|
||||
Description=RemoteTerm for MeshCore
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=remoteterm
|
||||
Group=remoteterm
|
||||
WorkingDirectory=/opt/remoteterm
|
||||
ExecStart=/opt/remoteterm/.venv/bin/uvicorn app.main:app --host 0.0.0.0 --port 8000
|
||||
Restart=always
|
||||
RestartSec=5
|
||||
Environment=MESHCORE_DATABASE_PATH=/opt/remoteterm/data/meshcore.db
|
||||
# Uncomment and set if auto-detection doesn't work:
|
||||
# Environment=MESHCORE_SERIAL_PORT=/dev/ttyUSB0
|
||||
SupplementaryGroups=dialout
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
```
|
||||
|
||||
Then install and start it:
|
||||
You can also rerun the script later to change transport, bot, or auth settings. If the service is already running, the installer stops it, rewrites the unit file, reloads systemd, and starts it again with the new configuration.
|
||||
|
||||
```bash
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl enable --now remoteterm
|
||||
sudo systemctl status remoteterm
|
||||
# Update to latest and restart
|
||||
cd /path/to/repo
|
||||
git pull
|
||||
uv sync
|
||||
cd frontend && npm install && npm run build && cd ..
|
||||
sudo systemctl restart remoteterm
|
||||
|
||||
# Refresh prebuilt frontend only (skips local build)
|
||||
python3 scripts/fetch_prebuilt_frontend.py
|
||||
sudo systemctl restart remoteterm
|
||||
|
||||
# View live logs
|
||||
sudo journalctl -u remoteterm -f
|
||||
|
||||
# Service control
|
||||
sudo systemctl start|stop|restart|status remoteterm
|
||||
```
|
||||
|
||||
## Debug Logging And Bug Reports
|
||||
|
||||
@@ -101,7 +101,7 @@ app/
|
||||
- Packet `path_len` values are hop counts, not byte counts.
|
||||
- Hop width comes from the packet or radio `path_hash_mode`: `0` = 1-byte, `1` = 2-byte, `2` = 3-byte.
|
||||
- Channel slot count comes from firmware-reported `DEVICE_INFO.max_channels`; do not hardcode `40` when scanning/offloading channel slots.
|
||||
- Channel sends use a session-local LRU slot cache after startup channel offload clears the radio. Repeated sends to the same room reuse the loaded slot; new rooms fill free slots up to the discovered channel capacity, then evict the least recently used cached room.
|
||||
- Channel sends use a session-local LRU slot cache after startup channel offload clears the radio. Repeated sends to the same channel reuse the loaded slot; new channels fill free slots up to the discovered channel capacity, then evict the least recently used cached channel.
|
||||
- TCP radios do not reuse cached slot contents. For TCP, channel sends still force `set_channel(...)` before every send because this backend does not have exclusive device access.
|
||||
- `MESHCORE_FORCE_CHANNEL_SLOT_RECONFIGURE=true` disables slot reuse on all transports and forces the old always-`set_channel(...)` behavior before every channel send.
|
||||
- Contacts persist canonical direct-route fields (`direct_path`, `direct_path_len`, `direct_path_hash_mode`) so contact sync and outbound DM routing reuse the exact stored hop width instead of inferring from path bytes.
|
||||
|
||||
@@ -89,6 +89,19 @@ Amazon SQS delivery. Config blob:
|
||||
- Publishes a JSON envelope of the form `{"event_type":"message"|"raw_packet","data":...}`
|
||||
- Supports both decoded messages and raw packets via normal scope selection
|
||||
|
||||
### map_upload (map_upload.py)
|
||||
Uploads heard repeater and room-server advertisements to map.meshcore.dev. Config blob:
|
||||
- `api_url` (optional, default `""`) — upload endpoint; empty falls back to the public map.meshcore.dev API
|
||||
- `dry_run` (bool, default `true`) — when true, logs the payload at INFO level without sending
|
||||
- `geofence_enabled` (bool, default `false`) — when true, only uploads nodes within `geofence_radius_km` of the radio's own configured lat/lon
|
||||
- `geofence_radius_km` (float, default `0`) — filter radius in kilometres
|
||||
|
||||
Geofence notes:
|
||||
- The reference center is always the radio's own `adv_lat`/`adv_lon` from `radio_runtime.meshcore.self_info`, read **live at upload time** — no lat/lon is stored in the fanout config itself.
|
||||
- If the radio's lat/lon is `(0, 0)` or the radio is not connected, the geofence check is silently skipped so uploads continue normally until coordinates are configured.
|
||||
- Requires the radio to have `ENABLE_PRIVATE_KEY_EXPORT=1` firmware to sign uploads.
|
||||
- Scope is always `{"messages": "none", "raw_packets": "all"}` — only raw RF packets are processed.
|
||||
|
||||
## Adding a New Integration Type
|
||||
|
||||
### Step-by-step checklist
|
||||
@@ -291,6 +304,7 @@ Migrations:
|
||||
- `app/fanout/webhook.py` — Webhook fanout module
|
||||
- `app/fanout/apprise_mod.py` — Apprise fanout module
|
||||
- `app/fanout/sqs.py` — Amazon SQS fanout module
|
||||
- `app/fanout/map_upload.py` — Map Upload fanout module
|
||||
- `app/repository/fanout.py` — Database CRUD
|
||||
- `app/routers/fanout.py` — REST API
|
||||
- `app/websocket.py` — `broadcast_event()` dispatches to fanout
|
||||
|
||||
@@ -20,9 +20,9 @@ from datetime import datetime
|
||||
from typing import Any, Protocol
|
||||
|
||||
import aiomqtt
|
||||
import nacl.bindings
|
||||
|
||||
from app.fanout.mqtt_base import BaseMqttPublisher
|
||||
from app.keystore import ed25519_sign_expanded
|
||||
from app.path_utils import parse_packet_envelope, split_path_hex
|
||||
from app.version_info import get_app_build_info
|
||||
|
||||
@@ -40,9 +40,6 @@ _TOKEN_RENEWAL_THRESHOLD = _TOKEN_LIFETIME - 3600 # 23 hours
|
||||
_STATS_REFRESH_INTERVAL = 300 # 5 minutes
|
||||
_STATS_MIN_CACHE_SECS = 60 # Don't re-fetch stats within 60s
|
||||
|
||||
# Ed25519 group order
|
||||
_L = 2**252 + 27742317777372353535851937790883648493
|
||||
|
||||
# Route type mapping: bottom 2 bits of first byte
|
||||
_ROUTE_MAP = {0: "F", 1: "F", 2: "D", 3: "T"}
|
||||
|
||||
@@ -69,28 +66,6 @@ def _base64url_encode(data: bytes) -> str:
|
||||
return base64.urlsafe_b64encode(data).rstrip(b"=").decode("ascii")
|
||||
|
||||
|
||||
def _ed25519_sign_expanded(
|
||||
message: bytes, scalar: bytes, prefix: bytes, public_key: bytes
|
||||
) -> bytes:
|
||||
"""Sign a message using MeshCore's expanded Ed25519 key format.
|
||||
|
||||
MeshCore stores 64-byte "orlp" format keys: scalar(32) || prefix(32).
|
||||
Standard Ed25519 libraries expect seed format and would re-SHA-512 the key.
|
||||
This performs the signing manually using the already-expanded key material.
|
||||
|
||||
Port of meshcore-packet-capture's ed25519_sign_with_expanded_key().
|
||||
"""
|
||||
# r = SHA-512(prefix || message) mod L
|
||||
r = int.from_bytes(hashlib.sha512(prefix + message).digest(), "little") % _L
|
||||
# R = r * B (base point multiplication)
|
||||
R = nacl.bindings.crypto_scalarmult_ed25519_base_noclamp(r.to_bytes(32, "little"))
|
||||
# k = SHA-512(R || public_key || message) mod L
|
||||
k = int.from_bytes(hashlib.sha512(R + public_key + message).digest(), "little") % _L
|
||||
# s = (r + k * scalar) mod L
|
||||
s = (r + k * int.from_bytes(scalar, "little")) % _L
|
||||
return R + s.to_bytes(32, "little")
|
||||
|
||||
|
||||
def _generate_jwt_token(
|
||||
private_key: bytes,
|
||||
public_key: bytes,
|
||||
@@ -127,7 +102,7 @@ def _generate_jwt_token(
|
||||
|
||||
scalar = private_key[:32]
|
||||
prefix = private_key[32:]
|
||||
signature = _ed25519_sign_expanded(signing_input, scalar, prefix, public_key)
|
||||
signature = ed25519_sign_expanded(signing_input, scalar, prefix, public_key)
|
||||
|
||||
return f"{header_b64}.{payload_b64}.{signature.hex()}"
|
||||
|
||||
|
||||
@@ -21,6 +21,7 @@ def _register_module_types() -> None:
|
||||
return
|
||||
from app.fanout.apprise_mod import AppriseModule
|
||||
from app.fanout.bot import BotModule
|
||||
from app.fanout.map_upload import MapUploadModule
|
||||
from app.fanout.mqtt_community import MqttCommunityModule
|
||||
from app.fanout.mqtt_private import MqttPrivateModule
|
||||
from app.fanout.sqs import SqsModule
|
||||
@@ -32,6 +33,7 @@ def _register_module_types() -> None:
|
||||
_MODULE_TYPES["webhook"] = WebhookModule
|
||||
_MODULE_TYPES["apprise"] = AppriseModule
|
||||
_MODULE_TYPES["sqs"] = SqsModule
|
||||
_MODULE_TYPES["map_upload"] = MapUploadModule
|
||||
|
||||
|
||||
def _matches_filter(filter_value: Any, key: str) -> bool:
|
||||
|
||||
320
app/fanout/map_upload.py
Normal file
320
app/fanout/map_upload.py
Normal file
@@ -0,0 +1,320 @@
|
||||
"""Fanout module for uploading heard advert packets to map.meshcore.dev.
|
||||
|
||||
Mirrors the logic of the standalone map.meshcore.dev-uploader project:
|
||||
- Listens on raw RF packets via on_raw
|
||||
- Filters for ADVERT packets, only processes repeaters (role 2) and rooms (role 3)
|
||||
- Skips nodes with no valid location (lat/lon None)
|
||||
- Applies per-pubkey rate-limiting (1-hour window, matching the uploader)
|
||||
- Signs the upload request with the radio's own Ed25519 private key
|
||||
- POSTs to the map API (or logs in dry-run mode)
|
||||
|
||||
Dry-run mode (default: True) logs the full would-be payload at INFO level
|
||||
without making any HTTP requests. Disable it only after verifying the log
|
||||
output looks correct — in particular the radio params (freq/bw/sf/cr) and
|
||||
the raw hex link.
|
||||
|
||||
Config keys
|
||||
-----------
|
||||
api_url : str, default ""
|
||||
Upload endpoint. Empty string falls back to the public map.meshcore.dev API.
|
||||
dry_run : bool, default True
|
||||
When True, log the payload at INFO level instead of sending it.
|
||||
geofence_enabled : bool, default False
|
||||
When True, only upload nodes whose location falls within geofence_radius_km of
|
||||
the radio's own configured latitude/longitude (read live from the radio at upload
|
||||
time — no lat/lon is stored in this config). When the radio's lat/lon is not set
|
||||
(0, 0) or unavailable, the geofence check is silently skipped so uploads continue
|
||||
normally until coordinates are configured.
|
||||
geofence_radius_km : float, default 0.0
|
||||
Radius of the geofence in kilometres. Nodes further than this distance
|
||||
from the radio's own position are skipped.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import math
|
||||
|
||||
import httpx
|
||||
|
||||
from app.decoder import parse_advertisement, parse_packet
|
||||
from app.fanout.base import FanoutModule
|
||||
from app.keystore import ed25519_sign_expanded, get_private_key, get_public_key
|
||||
from app.services.radio_runtime import radio_runtime
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_DEFAULT_API_URL = "https://map.meshcore.dev/api/v1/uploader/node"
|
||||
|
||||
# Re-upload guard: skip re-uploading a pubkey seen within this window (AU parity)
|
||||
_REUPLOAD_SECONDS = 3600
|
||||
|
||||
# Only upload repeaters (2) and rooms (3). Any other role — including future
|
||||
# roles not yet defined — is rejected. An allowlist is used rather than a
|
||||
# blocklist so that new roles cannot accidentally start populating the map.
|
||||
_ALLOWED_DEVICE_ROLES = {2, 3}
|
||||
|
||||
|
||||
def _get_radio_params() -> dict:
|
||||
"""Read radio frequency parameters from the connected radio's self_info.
|
||||
|
||||
The Python meshcore library returns radio_freq in MHz (e.g. 910.525) and
|
||||
radio_bw in kHz (e.g. 62.5). These are exactly the units the map API
|
||||
expects, matching what the JS reference uploader produces after its own
|
||||
/1000 division on raw integer values. No further scaling is applied here.
|
||||
"""
|
||||
try:
|
||||
mc = radio_runtime.meshcore
|
||||
if not mc:
|
||||
return {"freq": 0, "cr": 0, "sf": 0, "bw": 0}
|
||||
info = mc.self_info
|
||||
if not isinstance(info, dict):
|
||||
return {"freq": 0, "cr": 0, "sf": 0, "bw": 0}
|
||||
freq = info.get("radio_freq", 0) or 0
|
||||
bw = info.get("radio_bw", 0) or 0
|
||||
sf = info.get("radio_sf", 0) or 0
|
||||
cr = info.get("radio_cr", 0) or 0
|
||||
return {
|
||||
"freq": freq,
|
||||
"cr": cr,
|
||||
"sf": sf,
|
||||
"bw": bw,
|
||||
}
|
||||
except Exception as exc:
|
||||
logger.debug("MapUpload: could not read radio params: %s", exc)
|
||||
return {"freq": 0, "cr": 0, "sf": 0, "bw": 0}
|
||||
|
||||
|
||||
_ROLE_NAMES: dict[int, str] = {2: "repeater", 3: "room"}
|
||||
|
||||
|
||||
def _haversine_km(lat1: float, lon1: float, lat2: float, lon2: float) -> float:
|
||||
"""Return the great-circle distance in kilometres between two lat/lon points."""
|
||||
r = 6371.0
|
||||
phi1, phi2 = math.radians(lat1), math.radians(lat2)
|
||||
dphi = math.radians(lat2 - lat1)
|
||||
dlam = math.radians(lon2 - lon1)
|
||||
a = math.sin(dphi / 2) ** 2 + math.cos(phi1) * math.cos(phi2) * math.sin(dlam / 2) ** 2
|
||||
return 2 * r * math.asin(math.sqrt(a))
|
||||
|
||||
|
||||
class MapUploadModule(FanoutModule):
|
||||
"""Uploads heard ADVERT packets to the MeshCore community map."""
|
||||
|
||||
def __init__(self, config_id: str, config: dict, *, name: str = "") -> None:
|
||||
super().__init__(config_id, config, name=name)
|
||||
self._client: httpx.AsyncClient | None = None
|
||||
self._last_error: str | None = None
|
||||
# Per-pubkey rate limiting: pubkey_hex -> last_uploaded_advert_timestamp
|
||||
self._seen: dict[str, int] = {}
|
||||
|
||||
async def start(self) -> None:
|
||||
self._client = httpx.AsyncClient(timeout=httpx.Timeout(15.0))
|
||||
self._last_error = None
|
||||
self._seen.clear()
|
||||
|
||||
async def stop(self) -> None:
|
||||
if self._client:
|
||||
await self._client.aclose()
|
||||
self._client = None
|
||||
self._last_error = None
|
||||
|
||||
async def on_raw(self, data: dict) -> None:
|
||||
if data.get("payload_type") != "ADVERT":
|
||||
return
|
||||
|
||||
raw_hex = data.get("data", "")
|
||||
if not raw_hex:
|
||||
return
|
||||
|
||||
try:
|
||||
raw_bytes = bytes.fromhex(raw_hex)
|
||||
except ValueError:
|
||||
return
|
||||
|
||||
packet_info = parse_packet(raw_bytes)
|
||||
if packet_info is None:
|
||||
return
|
||||
|
||||
advert = parse_advertisement(packet_info.payload, raw_packet=raw_bytes)
|
||||
if advert is None:
|
||||
return
|
||||
|
||||
# TODO: advert Ed25519 signature verification is skipped here.
|
||||
# The radio has already validated the packet before passing it to RT,
|
||||
# so re-verification is redundant in practice. If added, verify that
|
||||
# nacl.bindings.crypto_sign_open(sig + (pubkey_bytes || timestamp_bytes),
|
||||
# advert.public_key_bytes) succeeds before proceeding.
|
||||
|
||||
# Only process repeaters (2) and rooms (3) — any other role is rejected
|
||||
if advert.device_role not in _ALLOWED_DEVICE_ROLES:
|
||||
return
|
||||
|
||||
# Skip nodes with no valid location — the decoder already nulls out
|
||||
# impossible values, so None means either no location flag or bad coords.
|
||||
if advert.lat is None or advert.lon is None:
|
||||
logger.debug(
|
||||
"MapUpload: skipping %s — no valid location",
|
||||
advert.public_key[:12],
|
||||
)
|
||||
return
|
||||
|
||||
pubkey = advert.public_key.lower()
|
||||
|
||||
# Rate-limit: skip if this pubkey's timestamp hasn't advanced enough
|
||||
last_seen = self._seen.get(pubkey)
|
||||
if last_seen is not None:
|
||||
if last_seen >= advert.timestamp:
|
||||
logger.debug(
|
||||
"MapUpload: skipping %s — possible replay (last=%d, advert=%d)",
|
||||
pubkey[:12],
|
||||
last_seen,
|
||||
advert.timestamp,
|
||||
)
|
||||
return
|
||||
if advert.timestamp < last_seen + _REUPLOAD_SECONDS:
|
||||
logger.debug(
|
||||
"MapUpload: skipping %s — within 1-hr rate-limit window (delta=%ds)",
|
||||
pubkey[:12],
|
||||
advert.timestamp - last_seen,
|
||||
)
|
||||
return
|
||||
|
||||
await self._upload(
|
||||
pubkey, advert.timestamp, advert.device_role, raw_hex, advert.lat, advert.lon
|
||||
)
|
||||
|
||||
async def _upload(
|
||||
self,
|
||||
pubkey: str,
|
||||
advert_timestamp: int,
|
||||
device_role: int,
|
||||
raw_hex: str,
|
||||
lat: float,
|
||||
lon: float,
|
||||
) -> None:
|
||||
# Geofence check: if enabled, skip nodes outside the configured radius.
|
||||
# The reference center is the radio's own lat/lon read live from self_info —
|
||||
# no coordinates are stored in the fanout config. If the radio lat/lon is
|
||||
# (0, 0) or unavailable the check is skipped transparently so uploads
|
||||
# continue normally until the operator sets coordinates in radio settings.
|
||||
geofence_dist_km: float | None = None
|
||||
if self.config.get("geofence_enabled"):
|
||||
try:
|
||||
mc = radio_runtime.meshcore
|
||||
sinfo = mc.self_info if mc else None
|
||||
fence_lat = float((sinfo or {}).get("adv_lat", 0) or 0)
|
||||
fence_lon = float((sinfo or {}).get("adv_lon", 0) or 0)
|
||||
except Exception as exc:
|
||||
logger.debug("MapUpload: could not read radio lat/lon for geofence: %s", exc)
|
||||
fence_lat = 0.0
|
||||
fence_lon = 0.0
|
||||
|
||||
if fence_lat == 0.0 and fence_lon == 0.0:
|
||||
logger.debug(
|
||||
"MapUpload: geofence skipped for %s — radio lat/lon not configured",
|
||||
pubkey[:12],
|
||||
)
|
||||
else:
|
||||
fence_radius_km = float(self.config.get("geofence_radius_km", 0) or 0)
|
||||
geofence_dist_km = _haversine_km(fence_lat, fence_lon, lat, lon)
|
||||
if geofence_dist_km > fence_radius_km:
|
||||
logger.debug(
|
||||
"MapUpload: skipping %s — outside geofence (%.2f km > %.2f km)",
|
||||
pubkey[:12],
|
||||
geofence_dist_km,
|
||||
fence_radius_km,
|
||||
)
|
||||
return
|
||||
|
||||
private_key = get_private_key()
|
||||
public_key = get_public_key()
|
||||
|
||||
if private_key is None or public_key is None:
|
||||
logger.warning(
|
||||
"MapUpload: private key not available — cannot sign upload for %s. "
|
||||
"Ensure radio firmware has ENABLE_PRIVATE_KEY_EXPORT=1.",
|
||||
pubkey[:12],
|
||||
)
|
||||
return
|
||||
|
||||
api_url = str(self.config.get("api_url", "") or _DEFAULT_API_URL).strip()
|
||||
dry_run = bool(self.config.get("dry_run", True))
|
||||
role_name = _ROLE_NAMES.get(device_role, f"role={device_role}")
|
||||
|
||||
params = _get_radio_params()
|
||||
upload_data = {
|
||||
"params": params,
|
||||
"links": [f"meshcore://{raw_hex}"],
|
||||
}
|
||||
|
||||
# Sign: SHA-256 the compact JSON, then Ed25519-sign the hash
|
||||
json_str = json.dumps(upload_data, separators=(",", ":"))
|
||||
data_hash = hashlib.sha256(json_str.encode()).digest()
|
||||
scalar = private_key[:32]
|
||||
prefix_bytes = private_key[32:]
|
||||
signature = ed25519_sign_expanded(data_hash, scalar, prefix_bytes, public_key)
|
||||
|
||||
request_payload = {
|
||||
"data": json_str,
|
||||
"signature": signature.hex(),
|
||||
"publicKey": public_key.hex(),
|
||||
}
|
||||
|
||||
if dry_run:
|
||||
geofence_note = (
|
||||
f" | geofence: {geofence_dist_km:.2f} km from observer"
|
||||
if geofence_dist_km is not None
|
||||
else ""
|
||||
)
|
||||
logger.info(
|
||||
"MapUpload [DRY RUN] %s (%s)%s → would POST to %s\n payload: %s",
|
||||
pubkey[:12],
|
||||
role_name,
|
||||
geofence_note,
|
||||
api_url,
|
||||
json.dumps(request_payload, separators=(",", ":")),
|
||||
)
|
||||
# Still update _seen so rate-limiting works during dry-run testing
|
||||
self._seen[pubkey] = advert_timestamp
|
||||
return
|
||||
|
||||
if not self._client:
|
||||
return
|
||||
|
||||
try:
|
||||
resp = await self._client.post(
|
||||
api_url,
|
||||
content=json.dumps(request_payload, separators=(",", ":")),
|
||||
headers={"Content-Type": "application/json"},
|
||||
)
|
||||
resp.raise_for_status()
|
||||
self._seen[pubkey] = advert_timestamp
|
||||
self._last_error = None
|
||||
logger.info(
|
||||
"MapUpload: uploaded %s (%s) → HTTP %d",
|
||||
pubkey[:12],
|
||||
role_name,
|
||||
resp.status_code,
|
||||
)
|
||||
except httpx.HTTPStatusError as exc:
|
||||
self._last_error = f"HTTP {exc.response.status_code}"
|
||||
logger.warning(
|
||||
"MapUpload: server returned %d for %s: %s",
|
||||
exc.response.status_code,
|
||||
pubkey[:12],
|
||||
exc.response.text[:200],
|
||||
)
|
||||
except httpx.RequestError as exc:
|
||||
self._last_error = str(exc)
|
||||
logger.warning("MapUpload: request error for %s: %s", pubkey[:12], exc)
|
||||
|
||||
@property
|
||||
def status(self) -> str:
|
||||
if self._client is None:
|
||||
return "disconnected"
|
||||
if self._last_error:
|
||||
return "error"
|
||||
return "connected"
|
||||
@@ -102,7 +102,7 @@ class BaseMqttPublisher(ABC):
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
"%s publish failed on %s. This is usually transient network noise; "
|
||||
"if it self-resolves and reconnects, it is generally not a concern: %s",
|
||||
"if it self-resolves and reconnects, it is generally not a concern. Persistent errors may indicate a problem with your network connection or MQTT broker. Original error: %s",
|
||||
self._integration_label(),
|
||||
topic,
|
||||
e,
|
||||
@@ -239,7 +239,7 @@ class BaseMqttPublisher(ABC):
|
||||
logger.warning(
|
||||
"%s connection error. This is usually transient network noise; "
|
||||
"if it self-resolves, it is generally not a concern: %s "
|
||||
"(reconnecting in %ds)",
|
||||
"(reconnecting in %ds). If this error persists, check your network connection and MQTT broker status.",
|
||||
self._integration_label(),
|
||||
e,
|
||||
backoff,
|
||||
|
||||
@@ -1,14 +1,18 @@
|
||||
"""
|
||||
Ephemeral keystore for storing sensitive keys in memory.
|
||||
Ephemeral keystore for storing sensitive keys in memory, plus the Ed25519
|
||||
signing primitive used by fanout modules that need to sign requests with the
|
||||
radio's own key.
|
||||
|
||||
The private key is stored in memory only and is never persisted to disk.
|
||||
It's exported from the radio on startup and reconnect, then used for
|
||||
server-side decryption of direct messages.
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import logging
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import nacl.bindings
|
||||
from meshcore import EventType
|
||||
|
||||
from app.decoder import derive_public_key
|
||||
@@ -25,11 +29,30 @@ NO_EVENT_RECEIVED_GUIDANCE = (
|
||||
"issue commands to the radio."
|
||||
)
|
||||
|
||||
# Ed25519 group order (L) — used in the expanded signing primitive below
|
||||
_L = 2**252 + 27742317777372353535851937790883648493
|
||||
|
||||
# In-memory storage for the private key and derived public key
|
||||
_private_key: bytes | None = None
|
||||
_public_key: bytes | None = None
|
||||
|
||||
|
||||
def ed25519_sign_expanded(message: bytes, scalar: bytes, prefix: bytes, public_key: bytes) -> bytes:
|
||||
"""Sign a message using MeshCore's expanded Ed25519 key format.
|
||||
|
||||
MeshCore stores 64-byte keys as scalar(32) || prefix(32). Standard
|
||||
Ed25519 libraries expect seed format and would re-SHA-512 the key, so we
|
||||
perform the signing manually using the already-expanded key material.
|
||||
|
||||
Port of meshcore-packet-capture's ed25519_sign_with_expanded_key().
|
||||
"""
|
||||
r = int.from_bytes(hashlib.sha512(prefix + message).digest(), "little") % _L
|
||||
R = nacl.bindings.crypto_scalarmult_ed25519_base_noclamp(r.to_bytes(32, "little"))
|
||||
k = int.from_bytes(hashlib.sha512(R + public_key + message).digest(), "little") % _L
|
||||
s = (r + k * int.from_bytes(scalar, "little")) % _L
|
||||
return R + s.to_bytes(32, "little")
|
||||
|
||||
|
||||
def clear_keys() -> None:
|
||||
"""Clear any stored private/public key material from memory."""
|
||||
global _private_key, _public_key
|
||||
|
||||
@@ -17,6 +17,7 @@ from app.frontend_static import (
|
||||
)
|
||||
from app.radio import RadioDisconnectedError
|
||||
from app.radio_sync import (
|
||||
stop_background_contact_reconciliation,
|
||||
stop_message_polling,
|
||||
stop_periodic_advert,
|
||||
stop_periodic_sync,
|
||||
@@ -95,6 +96,7 @@ async def lifespan(app: FastAPI):
|
||||
pass
|
||||
await fanout_manager.stop_all()
|
||||
await radio_manager.stop_connection_monitor()
|
||||
await stop_background_contact_reconciliation()
|
||||
await stop_message_polling()
|
||||
await stop_periodic_advert()
|
||||
await stop_periodic_sync()
|
||||
|
||||
@@ -266,7 +266,7 @@ class ContactNameHistory(BaseModel):
|
||||
|
||||
|
||||
class ContactActiveRoom(BaseModel):
|
||||
"""A channel/room where a contact has been active."""
|
||||
"""A channel where a contact has been active."""
|
||||
|
||||
channel_key: str
|
||||
channel_name: str
|
||||
|
||||
@@ -548,11 +548,14 @@ class RadioManager:
|
||||
|
||||
async def disconnect(self) -> None:
|
||||
"""Disconnect from the radio."""
|
||||
from app.radio_sync import stop_background_contact_reconciliation
|
||||
|
||||
clear_keys()
|
||||
self._reset_reconnect_error_broadcasts()
|
||||
if self._meshcore is None:
|
||||
return
|
||||
|
||||
await stop_background_contact_reconciliation()
|
||||
await self._acquire_operation_lock("disconnect", blocking=True)
|
||||
try:
|
||||
mc = self._meshcore
|
||||
|
||||
@@ -166,6 +166,9 @@ async def pause_polling():
|
||||
# Background task handle
|
||||
_sync_task: asyncio.Task | None = None
|
||||
|
||||
# Startup/background contact reconciliation task handle
|
||||
_contact_reconcile_task: asyncio.Task | None = None
|
||||
|
||||
# Periodic maintenance check interval in seconds (5 minutes)
|
||||
SYNC_INTERVAL = 300
|
||||
|
||||
@@ -266,30 +269,7 @@ async def sync_and_offload_contacts(mc: MeshCore) -> dict:
|
||||
remove_result = await mc.commands.remove_contact(contact_data)
|
||||
if remove_result.type == EventType.OK:
|
||||
removed += 1
|
||||
|
||||
# LIBRARY INTERNAL FIXUP: The MeshCore library's
|
||||
# commands.remove_contact() sends the remove command over
|
||||
# the wire but does NOT update the library's in-memory
|
||||
# contact cache (mc._contacts). This is a gap in the
|
||||
# library — there's no public API to clear a single
|
||||
# contact from the cache, and the library only refreshes
|
||||
# it on a full get_contacts() call.
|
||||
#
|
||||
# Why this matters: sync_recent_contacts_to_radio() uses
|
||||
# mc.get_contact_by_key_prefix() to check whether a
|
||||
# contact is already loaded on the radio. That method
|
||||
# searches mc._contacts. If we don't evict the removed
|
||||
# contact from the cache here, get_contact_by_key_prefix()
|
||||
# will still find it and skip the add_contact() call —
|
||||
# meaning contacts never get loaded back onto the radio
|
||||
# after offload. The result: no DM ACKs, degraded routing
|
||||
# for potentially minutes until the next periodic sync
|
||||
# refreshes the cache from the (now-empty) radio.
|
||||
#
|
||||
# We access mc._contacts directly because the library
|
||||
# exposes it as a read-only property (mc.contacts) with
|
||||
# no removal API. The dict is keyed by public_key string.
|
||||
mc._contacts.pop(public_key, None)
|
||||
_evict_removed_contact_from_library_cache(mc, public_key)
|
||||
else:
|
||||
logger.warning(
|
||||
"Failed to remove contact %s: %s", public_key[:12], remove_result.payload
|
||||
@@ -461,28 +441,28 @@ async def ensure_default_channels() -> None:
|
||||
|
||||
|
||||
async def sync_and_offload_all(mc: MeshCore) -> dict:
|
||||
"""Sync and offload both contacts and channels, then ensure defaults exist."""
|
||||
"""Run fast startup sync, then background contact reconcile."""
|
||||
logger.info("Starting full radio sync and offload")
|
||||
|
||||
# Contact on_radio is legacy/stale metadata. Clear it during the offload/reload
|
||||
# cycle so old rows stop claiming radio residency we do not actively track.
|
||||
await ContactRepository.clear_on_radio_except([])
|
||||
|
||||
contacts_result = await sync_and_offload_contacts(mc)
|
||||
contacts_result = await sync_contacts_from_radio(mc)
|
||||
channels_result = await sync_and_offload_channels(mc)
|
||||
|
||||
# Ensure default channels exist
|
||||
await ensure_default_channels()
|
||||
|
||||
# Reload favorites plus a working-set fill back onto the radio immediately.
|
||||
# Pass mc directly since the caller already holds the radio operation lock
|
||||
# (asyncio.Lock is not reentrant).
|
||||
reload_result = await sync_recent_contacts_to_radio(force=True, mc=mc)
|
||||
start_background_contact_reconciliation(
|
||||
initial_radio_contacts=contacts_result.get("radio_contacts", {}),
|
||||
expected_mc=mc,
|
||||
)
|
||||
|
||||
return {
|
||||
"contacts": contacts_result,
|
||||
"channels": channels_result,
|
||||
"reloaded": reload_result,
|
||||
"contact_reconcile_started": True,
|
||||
}
|
||||
|
||||
|
||||
@@ -1036,6 +1016,270 @@ async def stop_periodic_sync():
|
||||
# Throttling for contact sync to radio
|
||||
_last_contact_sync: float = 0.0
|
||||
CONTACT_SYNC_THROTTLE_SECONDS = 30 # Don't sync more than once per 30 seconds
|
||||
CONTACT_RECONCILE_BATCH_SIZE = 2
|
||||
CONTACT_RECONCILE_YIELD_SECONDS = 0.05
|
||||
|
||||
|
||||
def _evict_removed_contact_from_library_cache(mc: MeshCore, public_key: str) -> None:
|
||||
"""Keep the library's contact cache consistent after a successful removal."""
|
||||
# LIBRARY INTERNAL FIXUP: The MeshCore library's remove_contact() sends the
|
||||
# remove command over the wire but does NOT update the library's in-memory
|
||||
# contact cache (mc._contacts). This is a gap in the library — there's no
|
||||
# public API to clear a single contact from the cache, and the library only
|
||||
# refreshes it on a full get_contacts() call.
|
||||
#
|
||||
# Why this matters: contact sync and targeted ensure/load paths use
|
||||
# mc.get_contact_by_key_prefix() to check whether a contact is already
|
||||
# loaded on the radio. That method searches mc._contacts. If we don't evict
|
||||
# the removed contact from the cache here, later syncs will still find it
|
||||
# and skip add_contact() calls, leaving the radio without the contact even
|
||||
# though the app thinks it is resident.
|
||||
mc._contacts.pop(public_key, None)
|
||||
|
||||
|
||||
def _normalize_radio_contacts_payload(contacts: dict | None) -> dict[str, dict]:
|
||||
"""Return radio contacts keyed by normalized lowercase full public key."""
|
||||
normalized: dict[str, dict] = {}
|
||||
for public_key, contact_data in (contacts or {}).items():
|
||||
normalized[str(public_key).lower()] = contact_data
|
||||
return normalized
|
||||
|
||||
|
||||
async def sync_contacts_from_radio(mc: MeshCore) -> dict:
|
||||
"""Pull contacts from the radio and persist them to the database without removing them."""
|
||||
synced = 0
|
||||
|
||||
try:
|
||||
result = await mc.commands.get_contacts()
|
||||
|
||||
if result is None or result.type == EventType.ERROR:
|
||||
logger.error(
|
||||
"Failed to get contacts from radio: %s. "
|
||||
"If you see this repeatedly, the radio may be visible on the "
|
||||
"serial/TCP/BLE port but not responding to commands. Check for "
|
||||
"another process with the serial port open (other RemoteTerm "
|
||||
"instances, serial monitors, etc.), verify the firmware is "
|
||||
"up-to-date and in client mode (not repeater), or try a "
|
||||
"power cycle.",
|
||||
result,
|
||||
)
|
||||
return {"synced": 0, "radio_contacts": {}, "error": str(result)}
|
||||
|
||||
contacts = _normalize_radio_contacts_payload(result.payload)
|
||||
logger.info("Found %d contacts on radio", len(contacts))
|
||||
|
||||
for public_key, contact_data in contacts.items():
|
||||
await ContactRepository.upsert(
|
||||
ContactUpsert.from_radio_dict(public_key, contact_data, on_radio=False)
|
||||
)
|
||||
asyncio.create_task(
|
||||
_reconcile_contact_messages_background(
|
||||
public_key,
|
||||
contact_data.get("adv_name"),
|
||||
)
|
||||
)
|
||||
synced += 1
|
||||
|
||||
logger.info("Synced %d contacts from radio snapshot", synced)
|
||||
return {"synced": synced, "radio_contacts": contacts}
|
||||
except Exception as e:
|
||||
logger.error("Error during contact snapshot sync: %s", e)
|
||||
return {"synced": synced, "radio_contacts": {}, "error": str(e)}
|
||||
|
||||
|
||||
async def _reconcile_radio_contacts_in_background(
|
||||
*,
|
||||
initial_radio_contacts: dict[str, dict],
|
||||
expected_mc: MeshCore,
|
||||
) -> None:
|
||||
"""Converge radio contacts toward the desired favorites+recents working set."""
|
||||
radio_contacts = dict(initial_radio_contacts)
|
||||
removed = 0
|
||||
loaded = 0
|
||||
failed = 0
|
||||
|
||||
try:
|
||||
while True:
|
||||
if not radio_manager.is_connected or radio_manager.meshcore is not expected_mc:
|
||||
logger.info("Stopping background contact reconcile: radio transport changed")
|
||||
break
|
||||
|
||||
selected_contacts = await get_contacts_selected_for_radio_sync()
|
||||
desired_contacts = {
|
||||
contact.public_key.lower(): contact
|
||||
for contact in selected_contacts
|
||||
if len(contact.public_key) >= 64
|
||||
}
|
||||
removable_keys = [key for key in radio_contacts if key not in desired_contacts]
|
||||
missing_contacts = [
|
||||
contact for key, contact in desired_contacts.items() if key not in radio_contacts
|
||||
]
|
||||
|
||||
if not removable_keys and not missing_contacts:
|
||||
logger.info(
|
||||
"Background contact reconcile complete: %d contacts on radio working set",
|
||||
len(radio_contacts),
|
||||
)
|
||||
break
|
||||
|
||||
progressed = False
|
||||
try:
|
||||
async with radio_manager.radio_operation(
|
||||
"background_contact_reconcile",
|
||||
blocking=False,
|
||||
) as mc:
|
||||
if mc is not expected_mc:
|
||||
logger.info(
|
||||
"Stopping background contact reconcile: radio transport changed"
|
||||
)
|
||||
break
|
||||
|
||||
budget = CONTACT_RECONCILE_BATCH_SIZE
|
||||
selected_contacts = await get_contacts_selected_for_radio_sync()
|
||||
desired_contacts = {
|
||||
contact.public_key.lower(): contact
|
||||
for contact in selected_contacts
|
||||
if len(contact.public_key) >= 64
|
||||
}
|
||||
|
||||
for public_key in list(radio_contacts):
|
||||
if budget <= 0:
|
||||
break
|
||||
if public_key in desired_contacts:
|
||||
continue
|
||||
|
||||
remove_payload = (
|
||||
mc.get_contact_by_key_prefix(public_key[:12])
|
||||
or radio_contacts.get(public_key)
|
||||
or {"public_key": public_key}
|
||||
)
|
||||
try:
|
||||
remove_result = await mc.commands.remove_contact(remove_payload)
|
||||
except Exception as exc:
|
||||
failed += 1
|
||||
budget -= 1
|
||||
logger.warning(
|
||||
"Error removing contact %s during background reconcile: %s",
|
||||
public_key[:12],
|
||||
exc,
|
||||
)
|
||||
continue
|
||||
|
||||
budget -= 1
|
||||
if remove_result.type == EventType.OK:
|
||||
radio_contacts.pop(public_key, None)
|
||||
_evict_removed_contact_from_library_cache(mc, public_key)
|
||||
removed += 1
|
||||
progressed = True
|
||||
else:
|
||||
failed += 1
|
||||
logger.warning(
|
||||
"Failed to remove contact %s during background reconcile: %s",
|
||||
public_key[:12],
|
||||
remove_result.payload,
|
||||
)
|
||||
|
||||
if budget > 0:
|
||||
for public_key, contact in desired_contacts.items():
|
||||
if budget <= 0:
|
||||
break
|
||||
if public_key in radio_contacts:
|
||||
continue
|
||||
|
||||
if mc.get_contact_by_key_prefix(public_key[:12]):
|
||||
radio_contacts[public_key] = {"public_key": public_key}
|
||||
continue
|
||||
|
||||
try:
|
||||
add_payload = contact.to_radio_dict()
|
||||
add_result = await mc.commands.add_contact(add_payload)
|
||||
except Exception as exc:
|
||||
failed += 1
|
||||
budget -= 1
|
||||
logger.warning(
|
||||
"Error adding contact %s during background reconcile: %s",
|
||||
public_key[:12],
|
||||
exc,
|
||||
exc_info=True,
|
||||
)
|
||||
continue
|
||||
|
||||
budget -= 1
|
||||
if add_result.type == EventType.OK:
|
||||
radio_contacts[public_key] = add_payload
|
||||
loaded += 1
|
||||
progressed = True
|
||||
else:
|
||||
failed += 1
|
||||
reason = add_result.payload
|
||||
hint = ""
|
||||
if reason is None:
|
||||
hint = (
|
||||
" (no response from radio — if this repeats, check for "
|
||||
"serial port contention from another process or try a "
|
||||
"power cycle)"
|
||||
)
|
||||
logger.warning(
|
||||
"Failed to add contact %s during background reconcile: %s%s",
|
||||
public_key[:12],
|
||||
reason,
|
||||
hint,
|
||||
)
|
||||
except RadioOperationBusyError:
|
||||
logger.debug("Background contact reconcile yielding: radio busy")
|
||||
|
||||
await asyncio.sleep(CONTACT_RECONCILE_YIELD_SECONDS)
|
||||
if not progressed:
|
||||
continue
|
||||
except asyncio.CancelledError:
|
||||
logger.info("Background contact reconcile task cancelled")
|
||||
raise
|
||||
except Exception as exc:
|
||||
logger.error("Background contact reconcile failed: %s", exc, exc_info=True)
|
||||
finally:
|
||||
if removed > 0 or loaded > 0 or failed > 0:
|
||||
logger.info(
|
||||
"Background contact reconcile summary: removed %d, loaded %d, failed %d",
|
||||
removed,
|
||||
loaded,
|
||||
failed,
|
||||
)
|
||||
|
||||
|
||||
def start_background_contact_reconciliation(
|
||||
*,
|
||||
initial_radio_contacts: dict[str, dict],
|
||||
expected_mc: MeshCore,
|
||||
) -> None:
|
||||
"""Start or replace the background contact reconcile task for the current radio."""
|
||||
global _contact_reconcile_task
|
||||
|
||||
if _contact_reconcile_task is not None and not _contact_reconcile_task.done():
|
||||
_contact_reconcile_task.cancel()
|
||||
|
||||
_contact_reconcile_task = asyncio.create_task(
|
||||
_reconcile_radio_contacts_in_background(
|
||||
initial_radio_contacts=initial_radio_contacts,
|
||||
expected_mc=expected_mc,
|
||||
)
|
||||
)
|
||||
logger.info(
|
||||
"Started background contact reconcile for %d radio contact(s)",
|
||||
len(initial_radio_contacts),
|
||||
)
|
||||
|
||||
|
||||
async def stop_background_contact_reconciliation() -> None:
|
||||
"""Stop the background contact reconcile task."""
|
||||
global _contact_reconcile_task
|
||||
|
||||
if _contact_reconcile_task and not _contact_reconcile_task.done():
|
||||
_contact_reconcile_task.cancel()
|
||||
try:
|
||||
await _contact_reconcile_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
_contact_reconcile_task = None
|
||||
|
||||
|
||||
async def get_contacts_selected_for_radio_sync() -> list[Contact]:
|
||||
|
||||
@@ -723,6 +723,11 @@ class MessageRepository:
|
||||
state_key = f"{prefix}-{row['conversation_key']}"
|
||||
last_message_times[state_key] = row["last_message_time"]
|
||||
|
||||
# Only include last_read_ats for conversations that actually have messages.
|
||||
# Without this filter, every contact heard via advertisement (even without
|
||||
# any DMs) bloats the payload — 391KB down to ~46KB on a typical database.
|
||||
last_read_ats = {k: v for k, v in last_read_ats.items() if k in last_message_times}
|
||||
|
||||
return {
|
||||
"counts": counts,
|
||||
"mentions": mention_flags,
|
||||
|
||||
@@ -71,7 +71,7 @@ async def create_channel(request: CreateChannelRequest) -> Channel:
|
||||
requested_name = request.name
|
||||
is_hashtag = requested_name.startswith("#")
|
||||
|
||||
# Reserve the canonical Public room so it cannot drift to another key,
|
||||
# Reserve the canonical Public channel so it cannot drift to another key,
|
||||
# and the well-known Public key cannot be renamed to something else.
|
||||
if is_public_channel_name(requested_name):
|
||||
if request.key:
|
||||
|
||||
@@ -16,7 +16,7 @@ from app.repository.fanout import FanoutConfigRepository
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(prefix="/fanout", tags=["fanout"])
|
||||
|
||||
_VALID_TYPES = {"mqtt_private", "mqtt_community", "bot", "webhook", "apprise", "sqs"}
|
||||
_VALID_TYPES = {"mqtt_private", "mqtt_community", "bot", "webhook", "apprise", "sqs", "map_upload"}
|
||||
|
||||
_IATA_RE = re.compile(r"^[A-Z]{3}$")
|
||||
_DEFAULT_COMMUNITY_MQTT_TOPIC_TEMPLATE = "meshcore/{IATA}/{PUBLIC_KEY}/packets"
|
||||
@@ -94,6 +94,8 @@ def _validate_and_normalize_config(config_type: str, config: dict) -> dict:
|
||||
_validate_apprise_config(normalized)
|
||||
elif config_type == "sqs":
|
||||
_validate_sqs_config(normalized)
|
||||
elif config_type == "map_upload":
|
||||
_validate_map_upload_config(normalized)
|
||||
|
||||
return normalized
|
||||
|
||||
@@ -295,10 +297,33 @@ def _validate_sqs_config(config: dict) -> None:
|
||||
)
|
||||
|
||||
|
||||
def _validate_map_upload_config(config: dict) -> None:
|
||||
"""Validate and normalize map_upload config blob."""
|
||||
api_url = str(config.get("api_url", "")).strip()
|
||||
if api_url and not api_url.startswith(("http://", "https://")):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="api_url must start with http:// or https://",
|
||||
)
|
||||
# Persist the cleaned value (empty string means use the module default)
|
||||
config["api_url"] = api_url
|
||||
config["dry_run"] = bool(config.get("dry_run", True))
|
||||
config["geofence_enabled"] = bool(config.get("geofence_enabled", False))
|
||||
try:
|
||||
radius = float(config.get("geofence_radius_km", 0) or 0)
|
||||
except (TypeError, ValueError):
|
||||
raise HTTPException(status_code=400, detail="geofence_radius_km must be a number") from None
|
||||
if radius < 0:
|
||||
raise HTTPException(status_code=400, detail="geofence_radius_km must be >= 0")
|
||||
config["geofence_radius_km"] = radius
|
||||
|
||||
|
||||
def _enforce_scope(config_type: str, scope: dict) -> dict:
|
||||
"""Enforce type-specific scope constraints. Returns normalized scope."""
|
||||
if config_type == "mqtt_community":
|
||||
return {"messages": "none", "raw_packets": "all"}
|
||||
if config_type == "map_upload":
|
||||
return {"messages": "none", "raw_packets": "all"}
|
||||
if config_type == "bot":
|
||||
return {"messages": "all", "raw_packets": "none"}
|
||||
if config_type in ("webhook", "apprise"):
|
||||
|
||||
@@ -62,7 +62,7 @@ def _login_rejected_message(label: str) -> str:
|
||||
def _login_send_failed_message(label: str) -> str:
|
||||
return (
|
||||
f"The login request could not be sent to the {label}. "
|
||||
f"The control panel is still available, but authenticated actions may fail until a login succeeds."
|
||||
f"You're free to attempt interaction; try logging in again if authenticated actions fail."
|
||||
)
|
||||
|
||||
|
||||
@@ -70,7 +70,7 @@ def _login_timeout_message(label: str) -> str:
|
||||
return (
|
||||
f"No login confirmation was heard from the {label}. "
|
||||
"That can mean the password was wrong or the reply was missed in transit. "
|
||||
"The control panel is still available; try logging in again if authenticated actions fail."
|
||||
"You're free to attempt interaction; try logging in again if authenticated actions fail."
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -9,8 +9,8 @@
|
||||
<meta name="theme-color" content="#111419" />
|
||||
<meta name="description" content="Web interface for MeshCore mesh radio networks. Send and receive messages, manage contacts and channels, and configure your radio." />
|
||||
<title>RemoteTerm for MeshCore</title>
|
||||
<link rel="icon" type="image/png" href="/favicon-96x96.png" sizes="96x96" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<link rel="icon" type="image/png" href="/favicon-96x96.png" sizes="96x96" />
|
||||
<link rel="shortcut icon" href="/favicon.ico" />
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
|
||||
<link rel="manifest" href="/site.webmanifest" />
|
||||
|
||||
14
frontend/package-lock.json
generated
14
frontend/package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "remoteterm-meshcore-frontend",
|
||||
"version": "2.7.9",
|
||||
"version": "3.6.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "remoteterm-meshcore-frontend",
|
||||
"version": "2.7.9",
|
||||
"version": "3.6.1",
|
||||
"dependencies": {
|
||||
"@codemirror/lang-python": "^6.2.1",
|
||||
"@codemirror/theme-one-dark": "^6.1.3",
|
||||
@@ -29,6 +29,7 @@
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-leaflet": "^4.2.1",
|
||||
"react-swipeable": "^7.0.2",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.4.0",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
@@ -5695,6 +5696,15 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/react-swipeable": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/react-swipeable/-/react-swipeable-7.0.2.tgz",
|
||||
"integrity": "sha512-v1Qx1l+aC2fdxKa9aKJiaU/ZxmJ5o98RMoFwUqAAzVWUcxgfHFXDDruCKXhw6zIYXm6V64JiHgP9f6mlME5l8w==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.3 || ^17 || ^18 || ^19.0.0 || ^19.0.0-rc"
|
||||
}
|
||||
},
|
||||
"node_modules/read-cache": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "remoteterm-meshcore-frontend",
|
||||
"private": true,
|
||||
"version": "3.6.0",
|
||||
"version": "3.6.1",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
@@ -37,6 +37,7 @@
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-leaflet": "^4.2.1",
|
||||
"react-swipeable": "^7.0.2",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.4.0",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useCallback, useRef, useState } from 'react';
|
||||
import { useEffect, useCallback, useRef, useState, useMemo } from 'react';
|
||||
import { api } from './api';
|
||||
import { takePrefetchOrFetch } from './prefetch';
|
||||
import { useWebSocket } from './useWebSocket';
|
||||
@@ -14,6 +14,8 @@ import {
|
||||
useConversationNavigation,
|
||||
useRealtimeAppState,
|
||||
useBrowserNotifications,
|
||||
useFaviconBadge,
|
||||
useUnreadTitle,
|
||||
useRawPacketStatsSession,
|
||||
} from './hooks';
|
||||
import { AppShell } from './components/AppShell';
|
||||
@@ -22,6 +24,7 @@ import { DistanceUnitProvider } from './contexts/DistanceUnitContext';
|
||||
import { messageContainsMention } from './utils/messageParser';
|
||||
import { getStateKey } from './utils/conversationState';
|
||||
import type { Conversation, Message, RawPacket } from './types';
|
||||
import { CONTACT_TYPE_ROOM } from './types';
|
||||
|
||||
interface ChannelUnreadMarker {
|
||||
channelId: string;
|
||||
@@ -249,6 +252,21 @@ export function App() {
|
||||
} = useConversationMessages(activeConversation, targetMessageId);
|
||||
removeConversationMessagesRef.current = removeConversationMessages;
|
||||
|
||||
// Room servers replay stored history as a burst of DMs, all arriving with similar received_at
|
||||
// but spanning a wide range of sender_timestamps. Sort by sender_timestamp for room contacts
|
||||
// so the display reflects the original send order rather than our radio's receipt order.
|
||||
const activeContactIsRoom =
|
||||
activeConversation?.type === 'contact' &&
|
||||
contacts.find((c) => c.public_key === activeConversation.id)?.type === CONTACT_TYPE_ROOM;
|
||||
const sortedMessages = useMemo(() => {
|
||||
if (!activeContactIsRoom || messages.length === 0) return messages;
|
||||
return [...messages].sort((a, b) => {
|
||||
const aTs = a.sender_timestamp ?? a.received_at;
|
||||
const bTs = b.sender_timestamp ?? b.received_at;
|
||||
return aTs !== bTs ? aTs - bTs : a.id - b.id;
|
||||
});
|
||||
}, [activeContactIsRoom, messages]);
|
||||
|
||||
const {
|
||||
unreadCounts,
|
||||
mentions,
|
||||
@@ -259,6 +277,8 @@ export function App() {
|
||||
markAllRead,
|
||||
refreshUnreads,
|
||||
} = useUnreadCounts(channels, contacts, activeConversation);
|
||||
useFaviconBadge(unreadCounts, mentions, favorites);
|
||||
useUnreadTitle(unreadCounts, favorites);
|
||||
|
||||
useEffect(() => {
|
||||
if (activeConversation?.type !== 'channel') {
|
||||
@@ -423,7 +443,7 @@ export function App() {
|
||||
config,
|
||||
health,
|
||||
favorites,
|
||||
messages,
|
||||
messages: sortedMessages,
|
||||
messagesLoading,
|
||||
loadingOlder,
|
||||
hasOlderMessages,
|
||||
@@ -502,9 +522,7 @@ export function App() {
|
||||
onChannelCreate: handleCreateCrackedChannel,
|
||||
};
|
||||
const newMessageModalProps = {
|
||||
contacts,
|
||||
undecryptedCount,
|
||||
onSelectConversation: handleSelectConversationWithTargetReset,
|
||||
onCreateContact: handleCreateContact,
|
||||
onCreateChannel: handleCreateChannel,
|
||||
onCreateHashtagChannel: handleCreateHashtagChannel,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { lazy, Suspense, useRef, type ComponentProps } from 'react';
|
||||
import { useSwipeable } from 'react-swipeable';
|
||||
|
||||
import { StatusBar } from './StatusBar';
|
||||
import { Sidebar } from './Sidebar';
|
||||
@@ -89,6 +90,24 @@ export function AppShell({
|
||||
contactInfoPaneProps,
|
||||
channelInfoPaneProps,
|
||||
}: AppShellProps) {
|
||||
const swipeHandlers = useSwipeable({
|
||||
onSwipedRight: ({ initial }) => {
|
||||
if (initial[0] < 30 && !sidebarOpen && window.innerWidth < 768) {
|
||||
onSidebarOpenChange(true);
|
||||
}
|
||||
},
|
||||
trackTouch: true,
|
||||
trackMouse: false,
|
||||
preventScrollOnSwipe: true,
|
||||
});
|
||||
|
||||
const closeSwipeHandlers = useSwipeable({
|
||||
onSwipedLeft: () => onSidebarOpenChange(false),
|
||||
trackTouch: true,
|
||||
trackMouse: false,
|
||||
preventScrollOnSwipe: false,
|
||||
});
|
||||
|
||||
const searchMounted = useRef(false);
|
||||
if (conversationPaneProps.activeConversation?.type === 'search') {
|
||||
searchMounted.current = true;
|
||||
@@ -153,7 +172,7 @@ export function AppShell({
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
<div className="flex flex-col h-full" {...swipeHandlers}>
|
||||
<a
|
||||
href="#main-content"
|
||||
className="sr-only focus:not-sr-only focus:absolute focus:z-50 focus:p-2 focus:bg-primary focus:text-primary-foreground"
|
||||
@@ -196,7 +215,9 @@ export function AppShell({
|
||||
<SheetTitle>Navigation</SheetTitle>
|
||||
<SheetDescription>Sidebar navigation</SheetDescription>
|
||||
</SheetHeader>
|
||||
<div className="flex-1 overflow-hidden">{activeSidebarContent}</div>
|
||||
<div className="flex-1 overflow-hidden" {...closeSwipeHandlers}>
|
||||
{activeSidebarContent}
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
|
||||
@@ -284,10 +305,6 @@ export function AppShell({
|
||||
{...newMessageModalProps}
|
||||
open={showNewMessage}
|
||||
onClose={onCloseNewMessage}
|
||||
onSelectConversation={(conv) => {
|
||||
newMessageModalProps.onSelectConversation(conv);
|
||||
onCloseNewMessage();
|
||||
}}
|
||||
/>
|
||||
|
||||
<SecurityWarningModal health={statusProps.health} />
|
||||
|
||||
@@ -45,8 +45,8 @@ export function ChannelFloodScopeOverrideModal({
|
||||
<DialogHeader>
|
||||
<DialogTitle>Regional Override</DialogTitle>
|
||||
<DialogDescription>
|
||||
Room-level regional routing temporarily changes the radio flood scope before send and
|
||||
restores it after. This can noticeably slow room sends.
|
||||
Channel-level regional routing temporarily changes the radio flood scope before send and
|
||||
restores it after. This can noticeably slow channel sends.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
|
||||
@@ -201,7 +201,9 @@ export function ChatHeader({
|
||||
e.stopPropagation();
|
||||
navigator.clipboard.writeText(conversation.id);
|
||||
toast.success(
|
||||
conversation.type === 'channel' ? 'Room key copied!' : 'Contact key copied!'
|
||||
conversation.type === 'channel'
|
||||
? 'Channel key copied!'
|
||||
: 'Contact key copied!'
|
||||
);
|
||||
}}
|
||||
title="Click to copy"
|
||||
|
||||
@@ -242,8 +242,8 @@ export function ContactInfoPane({
|
||||
|
||||
<ActivityChartsSection analytics={analytics} />
|
||||
|
||||
<MostActiveRoomsSection
|
||||
rooms={analytics?.most_active_rooms ?? []}
|
||||
<MostActiveChannelsSection
|
||||
channels={analytics?.most_active_rooms ?? []}
|
||||
onNavigateToChannel={onNavigateToChannel}
|
||||
/>
|
||||
</div>
|
||||
@@ -515,8 +515,8 @@ export function ContactInfoPane({
|
||||
|
||||
<ActivityChartsSection analytics={analytics} />
|
||||
|
||||
<MostActiveRoomsSection
|
||||
rooms={analytics?.most_active_rooms ?? []}
|
||||
<MostActiveChannelsSection
|
||||
channels={analytics?.most_active_rooms ?? []}
|
||||
onNavigateToChannel={onNavigateToChannel}
|
||||
/>
|
||||
</div>
|
||||
@@ -588,23 +588,23 @@ function MessageStatsSection({
|
||||
);
|
||||
}
|
||||
|
||||
function MostActiveRoomsSection({
|
||||
rooms,
|
||||
function MostActiveChannelsSection({
|
||||
channels,
|
||||
onNavigateToChannel,
|
||||
}: {
|
||||
rooms: ContactActiveRoom[];
|
||||
channels: ContactActiveRoom[];
|
||||
onNavigateToChannel?: (channelKey: string) => void;
|
||||
}) {
|
||||
if (rooms.length === 0) {
|
||||
if (channels.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="px-5 py-3 border-b border-border">
|
||||
<SectionLabel>Most Active Rooms</SectionLabel>
|
||||
<SectionLabel>Most Active Channels</SectionLabel>
|
||||
<div className="space-y-1">
|
||||
{rooms.map((room) => (
|
||||
<div key={room.channel_key} className="flex justify-between items-center text-sm">
|
||||
{channels.map((channel) => (
|
||||
<div key={channel.channel_key} className="flex justify-between items-center text-sm">
|
||||
<span
|
||||
className={
|
||||
onNavigateToChannel
|
||||
@@ -614,15 +614,15 @@ function MostActiveRoomsSection({
|
||||
role={onNavigateToChannel ? 'button' : undefined}
|
||||
tabIndex={onNavigateToChannel ? 0 : undefined}
|
||||
onKeyDown={onNavigateToChannel ? handleKeyboardActivate : undefined}
|
||||
onClick={() => onNavigateToChannel?.(room.channel_key)}
|
||||
onClick={() => onNavigateToChannel?.(channel.channel_key)}
|
||||
>
|
||||
{room.channel_name.startsWith('#') || isPublicChannelKey(room.channel_key)
|
||||
? room.channel_name
|
||||
: `#${room.channel_name}`}
|
||||
{channel.channel_name.startsWith('#') || isPublicChannelKey(channel.channel_key)
|
||||
? channel.channel_name
|
||||
: `#${channel.channel_name}`}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground flex-shrink-0 ml-2">
|
||||
{room.message_count.toLocaleString()} msg
|
||||
{room.message_count !== 1 ? 's' : ''}
|
||||
{channel.message_count.toLocaleString()} msg
|
||||
{channel.message_count !== 1 ? 's' : ''}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
|
||||
@@ -7,8 +7,8 @@ import { toast } from './ui/sonner';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { extractPacketPayloadHex } from '../utils/pathUtils';
|
||||
|
||||
interface CrackedRoom {
|
||||
roomName: string;
|
||||
interface CrackedChannel {
|
||||
channelName: string;
|
||||
key: string;
|
||||
packetId: number;
|
||||
message: string;
|
||||
@@ -45,7 +45,7 @@ export function CrackerPanel({
|
||||
const [twoWordMode, setTwoWordMode] = useState(false);
|
||||
const [progress, setProgress] = useState<ProgressReport | null>(null);
|
||||
const [queue, setQueue] = useState<Map<number, QueueItem>>(new Map());
|
||||
const [crackedRooms, setCrackedRooms] = useState<CrackedRoom[]>([]);
|
||||
const [crackedChannels, setCrackedChannels] = useState<CrackedChannel[]>([]);
|
||||
const [wordlistLoaded, setWordlistLoaded] = useState(false);
|
||||
const [gpuAvailable, setGpuAvailable] = useState<boolean | null>(null);
|
||||
const [undecryptedPacketCount, setUndecryptedPacketCount] = useState<number | null>(null);
|
||||
@@ -325,14 +325,14 @@ export function CrackerPanel({
|
||||
return updated;
|
||||
});
|
||||
|
||||
const newRoom: CrackedRoom = {
|
||||
roomName: result.roomName,
|
||||
const newCracked: CrackedChannel = {
|
||||
channelName: result.roomName,
|
||||
key: result.key,
|
||||
packetId: nextId!,
|
||||
message: result.decryptedMessage || '',
|
||||
crackedAt: Date.now(),
|
||||
};
|
||||
setCrackedRooms((prev) => [...prev, newRoom]);
|
||||
setCrackedChannels((prev) => [...prev, newCracked]);
|
||||
|
||||
// Auto-add channel if not already exists
|
||||
const keyUpper = result.key.toUpperCase();
|
||||
@@ -505,7 +505,7 @@ export function CrackerPanel({
|
||||
? 'GPU Not Available'
|
||||
: !wordlistLoaded
|
||||
? 'Loading dictionary...'
|
||||
: 'Find Rooms'}
|
||||
: 'Find Channels'}
|
||||
</button>
|
||||
|
||||
{/* Status */}
|
||||
@@ -580,20 +580,20 @@ export function CrackerPanel({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Cracked rooms list */}
|
||||
{crackedRooms.length > 0 && (
|
||||
{/* Cracked channels list */}
|
||||
{crackedChannels.length > 0 && (
|
||||
<div>
|
||||
<div className="text-xs text-muted-foreground mb-1">Cracked Rooms:</div>
|
||||
<div className="text-xs text-muted-foreground mb-1">Cracked Channels:</div>
|
||||
<div className="space-y-1">
|
||||
{crackedRooms.map((room, i) => (
|
||||
{crackedChannels.map((channel, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="text-sm bg-success/10 border border-success/20 rounded px-2 py-1"
|
||||
>
|
||||
<span className="text-success font-medium">#{room.roomName}</span>
|
||||
<span className="text-success font-medium">#{channel.channelName}</span>
|
||||
<span className="text-muted-foreground ml-2 text-xs">
|
||||
"{room.message.slice(0, 50)}
|
||||
{room.message.length > 50 ? '...' : ''}"
|
||||
"{channel.message.slice(0, 50)}
|
||||
{channel.message.length > 50 ? '...' : ''}"
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
@@ -604,8 +604,8 @@ export function CrackerPanel({
|
||||
<hr className="border-border" />
|
||||
<p className="text-sm text-muted-foreground leading-relaxed">
|
||||
For unknown-keyed GroupText packets, this will attempt to dictionary attack, then brute
|
||||
force payloads as they arrive, testing room names up to the specified length to discover
|
||||
active rooms on the local mesh (GroupText packets may not be hashtag messages; we have no
|
||||
force payloads as they arrive, testing channel names up to the specified length to discover
|
||||
active channels on the local mesh (GroupText packets may not be hashtag messages; we have no
|
||||
way of knowing but try as if they are).
|
||||
<strong> Retry failed at n+1</strong> will let the cracker return to the failed queue and
|
||||
pick up messages it couldn't crack, attempting them at one longer length.
|
||||
@@ -613,8 +613,8 @@ export function CrackerPanel({
|
||||
concatenated together (e.g. "hello" + "world" = "#helloworld") after the single-word
|
||||
dictionary pass; this can substantially increase search time and also result in
|
||||
false-positives.
|
||||
<strong> Decrypt historical</strong> will run an async job on any room name it finds to see
|
||||
if any historically captured packets will decrypt with that key.
|
||||
<strong> Decrypt historical</strong> will run an async job on any channel name it finds to
|
||||
see if any historically captured packets will decrypt with that key.
|
||||
<strong> Turbo mode</strong> will push your GPU to the max (target dispatch time of 10s) and
|
||||
may allow accelerated cracking and/or system instability.
|
||||
</p>
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import { useState, useRef } from 'react';
|
||||
import { Dice5 } from 'lucide-react';
|
||||
import type { Contact, Conversation } from '../types';
|
||||
import { getContactDisplayName } from '../utils/pubkey';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -17,14 +15,12 @@ import { Checkbox } from './ui/checkbox';
|
||||
import { Button } from './ui/button';
|
||||
import { toast } from './ui/sonner';
|
||||
|
||||
type Tab = 'existing' | 'new-contact' | 'new-room' | 'hashtag';
|
||||
type Tab = 'new-contact' | 'new-channel' | 'hashtag';
|
||||
|
||||
interface NewMessageModalProps {
|
||||
open: boolean;
|
||||
contacts: Contact[];
|
||||
undecryptedCount: number;
|
||||
onClose: () => void;
|
||||
onSelectConversation: (conversation: Conversation) => void;
|
||||
onCreateContact: (name: string, publicKey: string, tryHistorical: boolean) => Promise<void>;
|
||||
onCreateChannel: (name: string, key: string, tryHistorical: boolean) => Promise<void>;
|
||||
onCreateHashtagChannel: (name: string, tryHistorical: boolean) => Promise<void>;
|
||||
@@ -32,18 +28,16 @@ interface NewMessageModalProps {
|
||||
|
||||
export function NewMessageModal({
|
||||
open,
|
||||
contacts,
|
||||
undecryptedCount,
|
||||
onClose,
|
||||
onSelectConversation,
|
||||
onCreateContact,
|
||||
onCreateChannel,
|
||||
onCreateHashtagChannel,
|
||||
}: NewMessageModalProps) {
|
||||
const [tab, setTab] = useState<Tab>('existing');
|
||||
const [tab, setTab] = useState<Tab>('new-contact');
|
||||
const [name, setName] = useState('');
|
||||
const [contactKey, setContactKey] = useState('');
|
||||
const [roomKey, setRoomKey] = useState('');
|
||||
const [channelKey, setChannelKey] = useState('');
|
||||
const [tryHistorical, setTryHistorical] = useState(false);
|
||||
const [permitCapitals, setPermitCapitals] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
@@ -53,7 +47,7 @@ export function NewMessageModal({
|
||||
const resetForm = () => {
|
||||
setName('');
|
||||
setContactKey('');
|
||||
setRoomKey('');
|
||||
setChannelKey('');
|
||||
setTryHistorical(false);
|
||||
setPermitCapitals(false);
|
||||
setError('');
|
||||
@@ -71,12 +65,12 @@ export function NewMessageModal({
|
||||
}
|
||||
// handleCreateContact sets activeConversation with the backend-normalized key
|
||||
await onCreateContact(name.trim(), contactKey.trim(), tryHistorical);
|
||||
} else if (tab === 'new-room') {
|
||||
if (!name.trim() || !roomKey.trim()) {
|
||||
setError('Room name and key are required');
|
||||
} else if (tab === 'new-channel') {
|
||||
if (!name.trim() || !channelKey.trim()) {
|
||||
setError('Channel name and key are required');
|
||||
return;
|
||||
}
|
||||
await onCreateChannel(name.trim(), roomKey.trim(), tryHistorical);
|
||||
await onCreateChannel(name.trim(), channelKey.trim(), tryHistorical);
|
||||
} else if (tab === 'hashtag') {
|
||||
const channelName = name.trim();
|
||||
const validationError = validateHashtagName(channelName);
|
||||
@@ -136,7 +130,7 @@ export function NewMessageModal({
|
||||
}
|
||||
};
|
||||
|
||||
const showHistoricalOption = tab !== 'existing' && undecryptedCount > 0;
|
||||
const showHistoricalOption = undecryptedCount > 0;
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
@@ -152,9 +146,8 @@ export function NewMessageModal({
|
||||
<DialogHeader>
|
||||
<DialogTitle>New Conversation</DialogTitle>
|
||||
<DialogDescription className="sr-only">
|
||||
{tab === 'existing' && 'Select an existing contact to start a conversation'}
|
||||
{tab === 'new-contact' && 'Add a new contact by entering their name and public key'}
|
||||
{tab === 'new-room' && 'Create a private room with a shared encryption key'}
|
||||
{tab === 'new-channel' && 'Create a private channel with a shared encryption key'}
|
||||
{tab === 'hashtag' && 'Join a public hashtag channel'}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
@@ -167,53 +160,12 @@ export function NewMessageModal({
|
||||
}}
|
||||
className="w-full"
|
||||
>
|
||||
<TabsList className="grid w-full grid-cols-4">
|
||||
<TabsTrigger value="existing">Existing</TabsTrigger>
|
||||
<TabsList className="grid w-full grid-cols-3">
|
||||
<TabsTrigger value="new-contact">Contact</TabsTrigger>
|
||||
<TabsTrigger value="new-room">Room</TabsTrigger>
|
||||
<TabsTrigger value="hashtag">Hashtag</TabsTrigger>
|
||||
<TabsTrigger value="new-channel">Private Channel</TabsTrigger>
|
||||
<TabsTrigger value="hashtag">Hashtag Channel</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="existing" className="mt-4">
|
||||
<div className="max-h-[300px] overflow-y-auto rounded-md border">
|
||||
{contacts.filter((contact) => contact.public_key.length === 64).length === 0 ? (
|
||||
<div className="p-4 text-center text-muted-foreground">No contacts available</div>
|
||||
) : (
|
||||
contacts
|
||||
.filter((contact) => contact.public_key.length === 64)
|
||||
.map((contact) => (
|
||||
<div
|
||||
key={contact.public_key}
|
||||
className="cursor-pointer px-4 py-2 hover:bg-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
(e.currentTarget as HTMLElement).click();
|
||||
}
|
||||
}}
|
||||
onClick={() => {
|
||||
onSelectConversation({
|
||||
type: 'contact',
|
||||
id: contact.public_key,
|
||||
name: getContactDisplayName(
|
||||
contact.name,
|
||||
contact.public_key,
|
||||
contact.last_advert
|
||||
),
|
||||
});
|
||||
resetForm();
|
||||
onClose();
|
||||
}}
|
||||
>
|
||||
{getContactDisplayName(contact.name, contact.public_key, contact.last_advert)}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="new-contact" className="mt-4 space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="contact-name">Name</Label>
|
||||
@@ -235,23 +187,23 @@ export function NewMessageModal({
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="new-room" className="mt-4 space-y-4">
|
||||
<TabsContent value="new-channel" className="mt-4 space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="room-name">Room Name</Label>
|
||||
<Label htmlFor="channel-name">Channel Name</Label>
|
||||
<Input
|
||||
id="room-name"
|
||||
id="channel-name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="Room name"
|
||||
placeholder="Channel name"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="room-key">Room Key</Label>
|
||||
<Label htmlFor="channel-key">Channel Key</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
id="room-key"
|
||||
value={roomKey}
|
||||
onChange={(e) => setRoomKey(e.target.value)}
|
||||
id="channel-key"
|
||||
value={channelKey}
|
||||
onChange={(e) => setChannelKey(e.target.value)}
|
||||
placeholder="Pre-shared key (hex)"
|
||||
className="flex-1"
|
||||
/>
|
||||
@@ -265,7 +217,7 @@ export function NewMessageModal({
|
||||
const hex = Array.from(bytes)
|
||||
.map((b) => b.toString(16).padStart(2, '0'))
|
||||
.join('');
|
||||
setRoomKey(hex);
|
||||
setChannelKey(hex);
|
||||
}}
|
||||
title="Generate random key"
|
||||
aria-label="Generate random key"
|
||||
@@ -299,7 +251,7 @@ export function NewMessageModal({
|
||||
onChange={(e) => setPermitCapitals(e.target.checked)}
|
||||
className="w-4 h-4 rounded border-input accent-primary"
|
||||
/>
|
||||
<span className="text-sm">Permit capitals in room key derivation</span>
|
||||
<span className="text-sm">Permit capitals in channel key derivation</span>
|
||||
</label>
|
||||
<p className="text-xs text-muted-foreground pl-7">
|
||||
Not recommended; most companions normalize to lowercase
|
||||
@@ -353,11 +305,9 @@ export function NewMessageModal({
|
||||
{loading ? 'Creating...' : 'Create & Add Another'}
|
||||
</Button>
|
||||
)}
|
||||
{tab !== 'existing' && (
|
||||
<Button onClick={handleCreate} disabled={loading}>
|
||||
{loading ? 'Creating...' : 'Create'}
|
||||
</Button>
|
||||
)}
|
||||
<Button onClick={handleCreate} disabled={loading}>
|
||||
{loading ? 'Creating...' : 'Create'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
@@ -51,7 +51,7 @@ export function PathModal({
|
||||
onAnalyzePacket,
|
||||
}: PathModalProps) {
|
||||
const { distanceUnit } = useDistanceUnit();
|
||||
const [expandedMaps, setExpandedMaps] = useState<Set<number>>(new Set());
|
||||
const [mapModalIndex, setMapModalIndex] = useState<number | null>(null);
|
||||
const hasResendActions = isOutgoingChan && messageId !== undefined && onResend;
|
||||
const hasPaths = paths.length > 0;
|
||||
const showAnalyzePacket = hasPaths && packetId != null && onAnalyzePacket;
|
||||
@@ -68,7 +68,7 @@ export function PathModal({
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(isOpen) => !isOpen && onClose()}>
|
||||
<DialogContent className="max-w-md max-h-[80vh] flex flex-col">
|
||||
<DialogContent className="max-w-md max-h-[80dvh] flex flex-col">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{hasPaths
|
||||
@@ -141,59 +141,68 @@ export function PathModal({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{resolvedPaths.map((pathData, index) => {
|
||||
const mapExpanded = expandedMaps.has(index);
|
||||
const toggleMap = () =>
|
||||
setExpandedMaps((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(index)) next.delete(index);
|
||||
else next.add(index);
|
||||
return next;
|
||||
});
|
||||
|
||||
return (
|
||||
<div key={index}>
|
||||
<div className="flex items-center justify-between mb-2 pb-1 border-b border-border">
|
||||
{!hasSinglePath ? (
|
||||
<div className="text-sm text-foreground/70 font-semibold">
|
||||
Path {index + 1}{' '}
|
||||
<span className="font-normal text-muted-foreground">
|
||||
— received {formatTime(pathData.received_at)}
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<div />
|
||||
)}
|
||||
<button
|
||||
onClick={toggleMap}
|
||||
aria-expanded={mapExpanded}
|
||||
className="text-xs text-primary hover:underline cursor-pointer shrink-0 ml-2"
|
||||
>
|
||||
{mapExpanded ? 'Hide map' : 'Map route'}
|
||||
</button>
|
||||
</div>
|
||||
{mapExpanded && (
|
||||
<div className="mb-2">
|
||||
<Suspense
|
||||
fallback={
|
||||
<div
|
||||
className="rounded border border-border bg-muted/30 animate-pulse"
|
||||
style={{ height: 220 }}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<PathRouteMap resolved={pathData.resolved} senderInfo={senderInfo} />
|
||||
</Suspense>
|
||||
{resolvedPaths.map((pathData, index) => (
|
||||
<div key={index}>
|
||||
<div className="flex items-center justify-between mb-2 pb-1 border-b border-border">
|
||||
{!hasSinglePath ? (
|
||||
<div className="text-sm text-foreground/70 font-semibold">
|
||||
Path {index + 1}{' '}
|
||||
<span className="font-normal text-muted-foreground">
|
||||
— received {formatTime(pathData.received_at)}
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<div />
|
||||
)}
|
||||
<PathVisualization
|
||||
resolved={pathData.resolved}
|
||||
senderInfo={senderInfo}
|
||||
distanceUnit={distanceUnit}
|
||||
/>
|
||||
<button
|
||||
onClick={() => setMapModalIndex(index)}
|
||||
className="text-xs text-primary hover:underline cursor-pointer shrink-0 ml-2"
|
||||
>
|
||||
Map route
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<PathVisualization
|
||||
resolved={pathData.resolved}
|
||||
senderInfo={senderInfo}
|
||||
distanceUnit={distanceUnit}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Map modal — opens when a "Map route" button is clicked */}
|
||||
<Dialog
|
||||
open={mapModalIndex !== null}
|
||||
onOpenChange={(open) => !open && setMapModalIndex(null)}
|
||||
>
|
||||
<DialogContent className="sm:max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{mapModalIndex !== null && !hasSinglePath
|
||||
? `Path ${mapModalIndex + 1} Route Map`
|
||||
: 'Route Map'}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
Map of known node locations along this message route.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
{mapModalIndex !== null && (
|
||||
<Suspense
|
||||
fallback={
|
||||
<div
|
||||
className="rounded border border-border bg-muted/30 animate-pulse"
|
||||
style={{ height: 400 }}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<PathRouteMap
|
||||
resolved={resolvedPaths[mapModalIndex].resolved}
|
||||
senderInfo={senderInfo}
|
||||
height={400}
|
||||
/>
|
||||
</Suspense>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import type { ResolvedPath, SenderInfo } from '../utils/pathUtils';
|
||||
interface PathRouteMapProps {
|
||||
resolved: ResolvedPath;
|
||||
senderInfo: SenderInfo;
|
||||
height?: number;
|
||||
}
|
||||
|
||||
// Colors for hop markers (indexed by hop number - 1)
|
||||
@@ -82,7 +83,7 @@ function RouteMapBounds({ points }: { points: [number, number][] }) {
|
||||
return null;
|
||||
}
|
||||
|
||||
export function PathRouteMap({ resolved, senderInfo }: PathRouteMapProps) {
|
||||
export function PathRouteMap({ resolved, senderInfo, height = 220 }: PathRouteMapProps) {
|
||||
const points = collectPoints(resolved);
|
||||
const hasAnyGps = points.length > 0;
|
||||
|
||||
@@ -117,7 +118,7 @@ export function PathRouteMap({ resolved, senderInfo }: PathRouteMapProps) {
|
||||
className="rounded border border-border overflow-hidden"
|
||||
role="img"
|
||||
aria-label="Map showing message route between nodes"
|
||||
style={{ height: 220 }}
|
||||
style={{ height }}
|
||||
>
|
||||
<MapContainer
|
||||
center={center}
|
||||
@@ -138,6 +139,8 @@ export function PathRouteMap({ resolved, senderInfo }: PathRouteMapProps) {
|
||||
icon={makeIcon('S', SENDER_COLOR)}
|
||||
>
|
||||
<Tooltip direction="top" offset={[0, -14]}>
|
||||
<span className="font-mono">{resolved.sender.prefix}</span>
|
||||
{' · '}
|
||||
{senderInfo.name || 'Sender'}
|
||||
</Tooltip>
|
||||
</Marker>
|
||||
@@ -154,6 +157,8 @@ export function PathRouteMap({ resolved, senderInfo }: PathRouteMapProps) {
|
||||
icon={makeIcon(String(hopIdx + 1), getHopColor(hopIdx))}
|
||||
>
|
||||
<Tooltip direction="top" offset={[0, -14]}>
|
||||
<span className="font-mono">{hop.prefix}</span>
|
||||
{' · '}
|
||||
{m.name || m.public_key.slice(0, 12)}
|
||||
</Tooltip>
|
||||
</Marker>
|
||||
@@ -167,6 +172,8 @@ export function PathRouteMap({ resolved, senderInfo }: PathRouteMapProps) {
|
||||
icon={makeIcon('R', RECEIVER_COLOR)}
|
||||
>
|
||||
<Tooltip direction="top" offset={[0, -14]}>
|
||||
<span className="font-mono">{resolved.receiver.prefix}</span>
|
||||
{' · '}
|
||||
{resolved.receiver.name || 'Receiver'}
|
||||
</Tooltip>
|
||||
</Marker>
|
||||
|
||||
@@ -161,7 +161,7 @@ function buildGroupTextResolutionCandidates(channels: Channel[]): GroupTextResol
|
||||
}));
|
||||
}
|
||||
|
||||
function resolveGroupTextRoomName(
|
||||
function resolveGroupTextChannelName(
|
||||
payload: {
|
||||
channelHash?: string;
|
||||
cipherMac?: string;
|
||||
@@ -211,15 +211,15 @@ function getPacketContext(
|
||||
groupTextCandidates: GroupTextResolutionCandidate[]
|
||||
) {
|
||||
const fallbackSender = packet.decrypted_info?.sender ?? null;
|
||||
const fallbackRoom = packet.decrypted_info?.channel_name ?? null;
|
||||
const fallbackChannel = packet.decrypted_info?.channel_name ?? null;
|
||||
|
||||
if (!inspection.decoded?.payload.decoded) {
|
||||
if (!fallbackSender && !fallbackRoom) {
|
||||
if (!fallbackSender && !fallbackChannel) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
title: fallbackRoom ? 'Room' : 'Context',
|
||||
primary: fallbackRoom ?? 'Sender metadata available',
|
||||
title: fallbackChannel ? 'Channel' : 'Context',
|
||||
primary: fallbackChannel ?? 'Sender metadata available',
|
||||
secondary: fallbackSender ? `Sender: ${fallbackSender}` : null,
|
||||
};
|
||||
}
|
||||
@@ -231,11 +231,12 @@ function getPacketContext(
|
||||
ciphertext?: string;
|
||||
decrypted?: { sender?: string; message?: string };
|
||||
};
|
||||
const roomName = fallbackRoom ?? resolveGroupTextRoomName(payload, groupTextCandidates);
|
||||
const channelName =
|
||||
fallbackChannel ?? resolveGroupTextChannelName(payload, groupTextCandidates);
|
||||
return {
|
||||
title: roomName ? 'Room' : 'Channel',
|
||||
title: 'Channel',
|
||||
primary:
|
||||
roomName ?? (payload.channelHash ? `Channel hash ${payload.channelHash}` : 'GroupText'),
|
||||
channelName ?? (payload.channelHash ? `Channel hash ${payload.channelHash}` : 'GroupText'),
|
||||
secondary: payload.decrypted?.sender
|
||||
? `Sender: ${payload.decrypted.sender}`
|
||||
: fallbackSender
|
||||
@@ -783,7 +784,7 @@ export function RawPacketInspectorDialog({
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="flex h-[92vh] max-w-[min(96vw,82rem)] flex-col gap-0 overflow-hidden p-0">
|
||||
<DialogContent className="flex h-[92dvh] max-w-[min(96vw,82rem)] flex-col gap-0 overflow-hidden p-0">
|
||||
<DialogHeader className="border-b border-border px-5 py-3">
|
||||
<DialogTitle>{title}</DialogTitle>
|
||||
<DialogDescription className="sr-only">{description}</DialogDescription>
|
||||
|
||||
@@ -5,6 +5,7 @@ import { Button } from './ui/button';
|
||||
import { Bell, Route, Star, Trash2 } from 'lucide-react';
|
||||
import { DirectTraceIcon } from './DirectTraceIcon';
|
||||
import { RepeaterLogin } from './RepeaterLogin';
|
||||
import { ServerLoginStatusBanner } from './ServerLoginStatusBanner';
|
||||
import { useRememberedServerPassword } from '../hooks/useRememberedServerPassword';
|
||||
import { useRepeaterDashboard } from '../hooks/useRepeaterDashboard';
|
||||
import { isFavorite } from '../utils/favorites';
|
||||
@@ -69,6 +70,7 @@ export function RepeaterDashboard({
|
||||
loggedIn,
|
||||
loginLoading,
|
||||
loginError,
|
||||
lastLoginAttempt,
|
||||
paneData,
|
||||
paneStates,
|
||||
consoleHistory,
|
||||
@@ -249,6 +251,14 @@ export function RepeaterDashboard({
|
||||
/>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
<ServerLoginStatusBanner
|
||||
attempt={lastLoginAttempt}
|
||||
loading={loginLoading}
|
||||
canRetryPassword={password.trim().length > 0}
|
||||
onRetryPassword={() => handleRepeaterLogin(password)}
|
||||
onRetryBlank={handleRepeaterGuestLogin}
|
||||
blankRetryLabel="Retry Existing-Access Login"
|
||||
/>
|
||||
{/* Top row: Telemetry + Radio Settings | Node Info + Neighbors */}
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 md:items-stretch">
|
||||
<div className="flex flex-col gap-4">
|
||||
|
||||
@@ -16,7 +16,13 @@ import { AclPane } from './repeater/RepeaterAclPane';
|
||||
import { LppTelemetryPane } from './repeater/RepeaterLppTelemetryPane';
|
||||
import { ConsolePane } from './repeater/RepeaterConsolePane';
|
||||
import { RepeaterLogin } from './RepeaterLogin';
|
||||
import { ServerLoginStatusBanner } from './ServerLoginStatusBanner';
|
||||
import { useRememberedServerPassword } from '../hooks/useRememberedServerPassword';
|
||||
import {
|
||||
buildServerLoginAttemptFromError,
|
||||
buildServerLoginAttemptFromResponse,
|
||||
type ServerLoginAttemptState,
|
||||
} from '../utils/serverLoginState';
|
||||
|
||||
interface RoomServerPanelProps {
|
||||
contact: Contact;
|
||||
@@ -61,6 +67,7 @@ export function RoomServerPanel({ contact, onAuthenticatedChange }: RoomServerPa
|
||||
const [loginLoading, setLoginLoading] = useState(false);
|
||||
const [loginError, setLoginError] = useState<string | null>(null);
|
||||
const [authenticated, setAuthenticated] = useState(false);
|
||||
const [lastLoginAttempt, setLastLoginAttempt] = useState<ServerLoginAttemptState | null>(null);
|
||||
const [advancedOpen, setAdvancedOpen] = useState(false);
|
||||
const [paneData, setPaneData] = useState<RoomPaneData>({
|
||||
status: null,
|
||||
@@ -75,6 +82,7 @@ export function RoomServerPanel({ contact, onAuthenticatedChange }: RoomServerPa
|
||||
setLoginLoading(false);
|
||||
setLoginError(null);
|
||||
setAuthenticated(false);
|
||||
setLastLoginAttempt(null);
|
||||
setAdvancedOpen(false);
|
||||
setPaneData({
|
||||
status: null,
|
||||
@@ -129,26 +137,32 @@ export function RoomServerPanel({ contact, onAuthenticatedChange }: RoomServerPa
|
||||
);
|
||||
|
||||
const performLogin = useCallback(
|
||||
async (password: string) => {
|
||||
async (nextPassword: string, method: 'password' | 'blank') => {
|
||||
if (loginLoading) return;
|
||||
|
||||
setLoginLoading(true);
|
||||
setLoginError(null);
|
||||
try {
|
||||
const result = await api.roomLogin(contact.public_key, password);
|
||||
const result = await api.roomLogin(contact.public_key, nextPassword);
|
||||
setLastLoginAttempt(buildServerLoginAttemptFromResponse(method, result, 'room server'));
|
||||
setAuthenticated(true);
|
||||
if (result.authenticated) {
|
||||
toast.success('Room login confirmed');
|
||||
toast.success('Login confirmed by the room server.');
|
||||
} else {
|
||||
toast.warning('Room login not confirmed', {
|
||||
description: result.message ?? 'Room login was not confirmed',
|
||||
toast.warning("Couldn't confirm room login", {
|
||||
description:
|
||||
result.message ??
|
||||
'No confirmation came back from the room server. You can still open tools and try again.',
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Unknown error';
|
||||
setLastLoginAttempt(buildServerLoginAttemptFromError(method, message, 'room server'));
|
||||
setAuthenticated(true);
|
||||
setLoginError(message);
|
||||
toast.error('Room login failed', { description: message });
|
||||
toast.error('Room login request failed', {
|
||||
description: `${message}. You can still open tools and retry the login from here.`,
|
||||
});
|
||||
} finally {
|
||||
setLoginLoading(false);
|
||||
}
|
||||
@@ -157,15 +171,15 @@ export function RoomServerPanel({ contact, onAuthenticatedChange }: RoomServerPa
|
||||
);
|
||||
|
||||
const handleLogin = useCallback(
|
||||
async (password: string) => {
|
||||
await performLogin(password);
|
||||
persistAfterLogin(password);
|
||||
async (nextPassword: string) => {
|
||||
await performLogin(nextPassword, 'password');
|
||||
persistAfterLogin(nextPassword);
|
||||
},
|
||||
[performLogin, persistAfterLogin]
|
||||
);
|
||||
|
||||
const handleLoginAsGuest = useCallback(async () => {
|
||||
await performLogin('');
|
||||
await performLogin('', 'blank');
|
||||
persistAfterLogin('');
|
||||
}, [performLogin, persistAfterLogin]);
|
||||
|
||||
@@ -207,6 +221,8 @@ export function RoomServerPanel({ contact, onAuthenticatedChange }: RoomServerPa
|
||||
);
|
||||
|
||||
const panelTitle = useMemo(() => contact.name || contact.public_key.slice(0, 12), [contact]);
|
||||
const showLoginFailureState =
|
||||
lastLoginAttempt !== null && lastLoginAttempt.outcome !== 'confirmed';
|
||||
|
||||
if (!authenticated) {
|
||||
return (
|
||||
@@ -236,7 +252,7 @@ export function RoomServerPanel({ contact, onAuthenticatedChange }: RoomServerPa
|
||||
onLoginAsGuest={handleLoginAsGuest}
|
||||
description="Log in with the room password or use ACL/guest access to enter this room server"
|
||||
passwordPlaceholder="Room server password..."
|
||||
guestLabel="Login with ACL / Guest"
|
||||
guestLabel="Login with Existing Access / Guest"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -245,15 +261,52 @@ export function RoomServerPanel({ contact, onAuthenticatedChange }: RoomServerPa
|
||||
|
||||
return (
|
||||
<section className="border-b border-border bg-muted/20 px-4 py-3">
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setAdvancedOpen((prev) => !prev)}
|
||||
>
|
||||
{advancedOpen ? 'Hide Tools' : 'Show Tools'}
|
||||
</Button>
|
||||
<div className="space-y-3">
|
||||
{showLoginFailureState ? (
|
||||
<ServerLoginStatusBanner
|
||||
attempt={lastLoginAttempt}
|
||||
loading={loginLoading}
|
||||
canRetryPassword={password.trim().length > 0}
|
||||
onRetryPassword={() => handleLogin(password)}
|
||||
onRetryBlank={handleLoginAsGuest}
|
||||
blankRetryLabel="Retry Existing-Access Login"
|
||||
showRetryActions={false}
|
||||
/>
|
||||
) : null}
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
{showLoginFailureState ? (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => void handleLogin(password)}
|
||||
disabled={loginLoading || password.trim().length === 0}
|
||||
>
|
||||
Retry Password Login
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleLoginAsGuest}
|
||||
disabled={loginLoading}
|
||||
>
|
||||
Retry Existing-Access Login
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div />
|
||||
)}
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setAdvancedOpen((prev) => !prev)}
|
||||
>
|
||||
{advancedOpen ? 'Hide Tools' : 'Show Tools'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<Sheet open={advancedOpen} onOpenChange={setAdvancedOpen}>
|
||||
<SheetContent side="right" className="w-full sm:max-w-4xl p-0 flex flex-col">
|
||||
@@ -269,15 +322,6 @@ export function RoomServerPanel({ contact, onAuthenticatedChange }: RoomServerPa
|
||||
<h2 className="truncate text-base font-semibold">Room Server Tools</h2>
|
||||
<p className="text-sm text-muted-foreground">{panelTitle}</p>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={handleLoginAsGuest}
|
||||
disabled={loginLoading}
|
||||
className="self-start sm:self-auto"
|
||||
>
|
||||
Refresh ACL Login
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto p-4">
|
||||
|
||||
@@ -69,7 +69,7 @@ export function SecurityWarningModal({ health }: SecurityWarningModalProps) {
|
||||
<Dialog open>
|
||||
<DialogContent
|
||||
hideCloseButton
|
||||
className="top-3 w-[calc(100vw-1rem)] max-w-[42rem] translate-y-0 gap-5 overflow-y-auto px-4 py-5 max-h-[calc(100vh-1.5rem)] sm:top-[50%] sm:w-full sm:max-h-[min(90vh,48rem)] sm:translate-y-[-50%] sm:px-6"
|
||||
className="w-[calc(100vw-1rem)] max-w-[42rem] gap-5 overflow-y-auto px-4 py-5 max-h-[calc(100dvh-2rem)] sm:w-full sm:max-h-[min(85dvh,48rem)] sm:px-6"
|
||||
onEscapeKeyDown={(event) => event.preventDefault()}
|
||||
onInteractOutside={(event) => event.preventDefault()}
|
||||
>
|
||||
@@ -88,12 +88,12 @@ export function SecurityWarningModal({ health }: SecurityWarningModalProps) {
|
||||
|
||||
<div className="space-y-3 break-words text-sm leading-6 text-muted-foreground">
|
||||
<DialogDescription>
|
||||
Bots are enabled, and app-wide Basic Auth is not configured.
|
||||
Bots are not disabled, and app-wide Basic Auth is not configured.
|
||||
</DialogDescription>
|
||||
<p>
|
||||
Without one of those protections, or another access-control layer in front of
|
||||
RemoteTerm, anyone on your local network who can reach this app can run Python code on
|
||||
the computer hosting this instance.
|
||||
the computer hosting this instance via the bot system.
|
||||
</p>
|
||||
<p className="font-semibold text-foreground">
|
||||
This is only safe on protected or isolated networks with appropriate access control. If
|
||||
|
||||
76
frontend/src/components/ServerLoginStatusBanner.tsx
Normal file
76
frontend/src/components/ServerLoginStatusBanner.tsx
Normal file
@@ -0,0 +1,76 @@
|
||||
import { Button } from './ui/button';
|
||||
import type { ServerLoginAttemptState } from '../utils/serverLoginState';
|
||||
import { getServerLoginAttemptTone } from '../utils/serverLoginState';
|
||||
import { cn } from '../lib/utils';
|
||||
|
||||
interface ServerLoginStatusBannerProps {
|
||||
attempt: ServerLoginAttemptState | null;
|
||||
loading: boolean;
|
||||
canRetryPassword: boolean;
|
||||
onRetryPassword: () => Promise<void> | void;
|
||||
onRetryBlank: () => Promise<void> | void;
|
||||
passwordRetryLabel?: string;
|
||||
blankRetryLabel?: string;
|
||||
showRetryActions?: boolean;
|
||||
}
|
||||
|
||||
export function ServerLoginStatusBanner({
|
||||
attempt,
|
||||
loading,
|
||||
canRetryPassword,
|
||||
onRetryPassword,
|
||||
onRetryBlank,
|
||||
passwordRetryLabel = 'Retry Password Login',
|
||||
blankRetryLabel = 'Retry Existing-Access Login',
|
||||
showRetryActions = true,
|
||||
}: ServerLoginStatusBannerProps) {
|
||||
if (attempt?.outcome === 'confirmed') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const tone = getServerLoginAttemptTone(attempt);
|
||||
const shouldShowActions = showRetryActions;
|
||||
const toneClassName =
|
||||
tone === 'success'
|
||||
? 'border-success/30 bg-success/10 text-success'
|
||||
: tone === 'warning'
|
||||
? 'border-warning/30 bg-warning/10 text-warning'
|
||||
: tone === 'destructive'
|
||||
? 'border-destructive/30 bg-destructive/10 text-destructive'
|
||||
: 'border-border bg-muted/40 text-foreground';
|
||||
|
||||
return (
|
||||
<div className={cn('rounded-md border px-4 py-3', toneClassName)}>
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div className="min-w-0 space-y-1">
|
||||
<p className="text-sm font-medium">
|
||||
{attempt?.summary ?? 'No server login attempt has been recorded in this view yet.'}
|
||||
</p>
|
||||
{attempt?.details && <p className="text-xs opacity-90">{attempt.details}</p>}
|
||||
</div>
|
||||
{shouldShowActions ? (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => void onRetryPassword()}
|
||||
disabled={loading || !canRetryPassword}
|
||||
>
|
||||
{passwordRetryLabel}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => void onRetryBlank()}
|
||||
disabled={loading}
|
||||
>
|
||||
{blankRetryLabel}
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -147,8 +147,8 @@ export function SettingsModal(props: SettingsModalProps) {
|
||||
: 'mx-auto w-full max-w-[800px] space-y-4 border-t border-input p-4';
|
||||
|
||||
const settingsContainerClass = externalDesktopSidebarMode
|
||||
? 'w-full h-full overflow-y-auto'
|
||||
: 'w-full h-full overflow-y-auto space-y-3';
|
||||
? 'w-full h-full min-w-0 overflow-x-hidden overflow-y-auto [contain:layout_paint]'
|
||||
: 'w-full h-full min-w-0 overflow-x-hidden overflow-y-auto space-y-3 [contain:layout_paint]';
|
||||
|
||||
const sectionButtonClasses =
|
||||
'w-full flex items-center justify-between px-4 py-3 text-left hover:bg-muted/40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset';
|
||||
|
||||
@@ -748,7 +748,7 @@ export function Sidebar({
|
||||
icon: <LockOpen className="h-4 w-4" />,
|
||||
label: (
|
||||
<>
|
||||
{showCracker ? 'Hide' : 'Show'} Room Finder
|
||||
{showCracker ? 'Hide' : 'Show'} Channel Finder
|
||||
<span
|
||||
className={cn(
|
||||
'ml-1 text-[11px]',
|
||||
@@ -844,7 +844,7 @@ export function Sidebar({
|
||||
<div className="relative min-w-0 flex-1">
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="Search rooms/contacts..."
|
||||
placeholder="Search channels/contacts..."
|
||||
aria-label="Search conversations"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
|
||||
@@ -112,7 +112,10 @@ export function RadioSettingsPane({
|
||||
<NotFetched />
|
||||
) : (
|
||||
<div>
|
||||
<KvRow label="Local Advert" value={formatAdvertInterval(advertData.advert_interval)} />
|
||||
<KvRow
|
||||
label="Local Advert"
|
||||
value={formatAdvertInterval(advertData.advert_interval, 'minutes')}
|
||||
/>
|
||||
<KvRow
|
||||
label="Flood Advert"
|
||||
value={formatAdvertInterval(advertData.flood_advert_interval)}
|
||||
|
||||
@@ -76,11 +76,19 @@ export function formatClockDrift(
|
||||
return { text: parts.join(''), isLarge: false };
|
||||
}
|
||||
|
||||
export function formatAdvertInterval(val: string | null): string {
|
||||
export function formatAdvertInterval(
|
||||
val: string | null,
|
||||
unit: 'minutes' | 'hours' = 'hours'
|
||||
): string {
|
||||
if (val == null) return '—';
|
||||
const trimmed = val.trim();
|
||||
if (trimmed === '0') return '<disabled>';
|
||||
return `${trimmed}h`;
|
||||
if (unit === 'hours') return `${trimmed}h`;
|
||||
const mins = parseInt(trimmed, 10);
|
||||
if (isNaN(mins)) return trimmed;
|
||||
if (mins >= 60 && mins % 60 === 0) return `${mins / 60}h`;
|
||||
if (mins >= 60) return `${Math.floor(mins / 60)}h${mins % 60}m`;
|
||||
return `${mins}m`;
|
||||
}
|
||||
|
||||
function formatFetchedRelative(fetchedAt: number): string {
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { useState, useEffect, useCallback, useRef, lazy, Suspense } from 'react';
|
||||
import { useState, useEffect, useCallback, useMemo, useRef, lazy, Suspense } from 'react';
|
||||
import { ChevronDown } from 'lucide-react';
|
||||
import { Input } from '../ui/input';
|
||||
import { Label } from '../ui/label';
|
||||
import { Button } from '../ui/button';
|
||||
import { Separator } from '../ui/separator';
|
||||
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from '../ui/dialog';
|
||||
import { toast } from '../ui/sonner';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { api } from '../../api';
|
||||
@@ -14,22 +16,14 @@ const BotCodeEditor = lazy(() =>
|
||||
|
||||
const TYPE_LABELS: Record<string, string> = {
|
||||
mqtt_private: 'Private MQTT',
|
||||
mqtt_community: 'Community MQTT',
|
||||
bot: 'Bot',
|
||||
mqtt_community: 'Community Sharing',
|
||||
bot: 'Python Bot',
|
||||
webhook: 'Webhook',
|
||||
apprise: 'Apprise',
|
||||
sqs: 'Amazon SQS',
|
||||
map_upload: 'Map Upload',
|
||||
};
|
||||
|
||||
const LIST_TYPE_OPTIONS = [
|
||||
{ value: 'mqtt_private', label: 'Private MQTT' },
|
||||
{ value: 'mqtt_community', label: 'Community MQTT' },
|
||||
{ value: 'bot', label: 'Bot' },
|
||||
{ value: 'webhook', label: 'Webhook' },
|
||||
{ value: 'apprise', label: 'Apprise' },
|
||||
{ value: 'sqs', label: 'Amazon SQS' },
|
||||
];
|
||||
|
||||
const DEFAULT_COMMUNITY_PACKET_TOPIC_TEMPLATE = 'meshcore/{IATA}/{PUBLIC_KEY}/packets';
|
||||
const DEFAULT_COMMUNITY_BROKER_HOST = 'mqtt-us-v1.letsmesh.net';
|
||||
const DEFAULT_COMMUNITY_BROKER_HOST_EU = 'mqtt-eu-v1.letsmesh.net';
|
||||
@@ -42,30 +36,6 @@ const DEFAULT_MESHRANK_TRANSPORT = 'tcp';
|
||||
const DEFAULT_MESHRANK_AUTH_MODE = 'none';
|
||||
const DEFAULT_MESHRANK_IATA = 'XYZ';
|
||||
|
||||
const CREATE_TYPE_OPTIONS = [
|
||||
{ value: 'mqtt_private', label: 'Private MQTT' },
|
||||
{ value: 'mqtt_community_meshrank', label: 'MeshRank' },
|
||||
{ value: 'mqtt_community_letsmesh_us', label: 'LetsMesh (US)' },
|
||||
{ value: 'mqtt_community_letsmesh_eu', label: 'LetsMesh (EU)' },
|
||||
{ value: 'mqtt_community', label: 'Community MQTT/meshcoretomqtt' },
|
||||
{ value: 'bot', label: 'Bot' },
|
||||
{ value: 'webhook', label: 'Webhook' },
|
||||
{ value: 'apprise', label: 'Apprise' },
|
||||
{ value: 'sqs', label: 'Amazon SQS' },
|
||||
] as const;
|
||||
|
||||
type DraftType = (typeof CREATE_TYPE_OPTIONS)[number]['value'];
|
||||
|
||||
type DraftRecipe = {
|
||||
savedType: string;
|
||||
detailLabel: string;
|
||||
defaultName: string;
|
||||
defaults: {
|
||||
config: Record<string, unknown>;
|
||||
scope: Record<string, unknown>;
|
||||
};
|
||||
};
|
||||
|
||||
function createCommunityConfigDefaults(
|
||||
overrides: Partial<Record<string, unknown>> = {}
|
||||
): Record<string, unknown> {
|
||||
@@ -122,11 +92,42 @@ const DEFAULT_BOT_CODE = `def bot(**kwargs) -> str | list[str] | None:
|
||||
return "[BOT] Plong!"
|
||||
return None`;
|
||||
|
||||
const DRAFT_RECIPES: Record<DraftType, DraftRecipe> = {
|
||||
mqtt_private: {
|
||||
type DraftType =
|
||||
| 'mqtt_private'
|
||||
| 'mqtt_community'
|
||||
| 'mqtt_community_meshrank'
|
||||
| 'mqtt_community_letsmesh_us'
|
||||
| 'mqtt_community_letsmesh_eu'
|
||||
| 'webhook'
|
||||
| 'apprise'
|
||||
| 'sqs'
|
||||
| 'bot'
|
||||
| 'map_upload';
|
||||
|
||||
type CreateIntegrationDefinition = {
|
||||
value: DraftType;
|
||||
savedType: string;
|
||||
label: string;
|
||||
section: string;
|
||||
description: string;
|
||||
defaultName: string;
|
||||
nameMode: 'counted' | 'fixed';
|
||||
defaults: {
|
||||
config: Record<string, unknown>;
|
||||
scope: Record<string, unknown>;
|
||||
};
|
||||
};
|
||||
|
||||
const CREATE_INTEGRATION_DEFINITIONS: readonly CreateIntegrationDefinition[] = [
|
||||
{
|
||||
value: 'mqtt_private',
|
||||
savedType: 'mqtt_private',
|
||||
detailLabel: 'Private MQTT',
|
||||
label: 'Private MQTT',
|
||||
section: 'Bulk Forwarding',
|
||||
description:
|
||||
'Customizable-scope forwarding of all or some messages to an MQTT broker of your choosing, in raw and/or decrypted form.',
|
||||
defaultName: 'Private MQTT',
|
||||
nameMode: 'counted',
|
||||
defaults: {
|
||||
config: {
|
||||
broker_host: '',
|
||||
@@ -140,10 +141,29 @@ const DRAFT_RECIPES: Record<DraftType, DraftRecipe> = {
|
||||
scope: { messages: 'all', raw_packets: 'all' },
|
||||
},
|
||||
},
|
||||
mqtt_community_meshrank: {
|
||||
{
|
||||
value: 'mqtt_community',
|
||||
savedType: 'mqtt_community',
|
||||
detailLabel: 'MeshRank',
|
||||
label: 'Community MQTT/meshcoretomqtt',
|
||||
section: 'Community Sharing',
|
||||
description:
|
||||
'MeshcoreToMQTT-compatible raw-packet feed publishing, compatible with community aggregators (in other words, make your companion radio also serve as an observer node). Superset of other Community MQTT presets.',
|
||||
defaultName: 'Community MQTT',
|
||||
nameMode: 'counted',
|
||||
defaults: {
|
||||
config: createCommunityConfigDefaults(),
|
||||
scope: { messages: 'none', raw_packets: 'all' },
|
||||
},
|
||||
},
|
||||
{
|
||||
value: 'mqtt_community_meshrank',
|
||||
savedType: 'mqtt_community',
|
||||
label: 'MeshRank',
|
||||
section: 'Community Sharing',
|
||||
description:
|
||||
'A community MQTT config preconfigured for MeshRank, requiring only the provided topic from your MeshRank configuration. A subset of the primary Community MQTT/meshcoretomqtt configuration; you are free to edit all configuration after creation.',
|
||||
defaultName: 'MeshRank',
|
||||
nameMode: 'fixed',
|
||||
defaults: {
|
||||
config: createCommunityConfigDefaults({
|
||||
broker_host: DEFAULT_MESHRANK_BROKER_HOST,
|
||||
@@ -158,10 +178,15 @@ const DRAFT_RECIPES: Record<DraftType, DraftRecipe> = {
|
||||
scope: { messages: 'none', raw_packets: 'all' },
|
||||
},
|
||||
},
|
||||
mqtt_community_letsmesh_us: {
|
||||
{
|
||||
value: 'mqtt_community_letsmesh_us',
|
||||
savedType: 'mqtt_community',
|
||||
detailLabel: 'LetsMesh (US)',
|
||||
label: 'LetsMesh (US)',
|
||||
section: 'Community Sharing',
|
||||
description:
|
||||
'A community MQTT config preconfigured for the LetsMesh US-ingest endpoint, requiring only your email and IATA region code. Good to use with an additional EU configuration for redundancy. A subset of the primary Community MQTT/meshcoretomqtt configuration; you are free to edit all configuration after creation.',
|
||||
defaultName: 'LetsMesh (US)',
|
||||
nameMode: 'fixed',
|
||||
defaults: {
|
||||
config: createCommunityConfigDefaults({
|
||||
broker_host: DEFAULT_COMMUNITY_BROKER_HOST,
|
||||
@@ -170,10 +195,15 @@ const DRAFT_RECIPES: Record<DraftType, DraftRecipe> = {
|
||||
scope: { messages: 'none', raw_packets: 'all' },
|
||||
},
|
||||
},
|
||||
mqtt_community_letsmesh_eu: {
|
||||
{
|
||||
value: 'mqtt_community_letsmesh_eu',
|
||||
savedType: 'mqtt_community',
|
||||
detailLabel: 'LetsMesh (EU)',
|
||||
label: 'LetsMesh (EU)',
|
||||
section: 'Community Sharing',
|
||||
description:
|
||||
'A community MQTT config preconfigured for the LetsMesh EU-ingest endpoint, requiring only your email and IATA region code. Good to use with an additional US configuration for redundancy. A subset of the primary Community MQTT/meshcoretomqtt configuration; you are free to edit all configuration after creation.',
|
||||
defaultName: 'LetsMesh (EU)',
|
||||
nameMode: 'fixed',
|
||||
defaults: {
|
||||
config: createCommunityConfigDefaults({
|
||||
broker_host: DEFAULT_COMMUNITY_BROKER_HOST_EU,
|
||||
@@ -182,30 +212,15 @@ const DRAFT_RECIPES: Record<DraftType, DraftRecipe> = {
|
||||
scope: { messages: 'none', raw_packets: 'all' },
|
||||
},
|
||||
},
|
||||
mqtt_community: {
|
||||
savedType: 'mqtt_community',
|
||||
detailLabel: 'Community MQTT/meshcoretomqtt',
|
||||
defaultName: 'Community MQTT',
|
||||
defaults: {
|
||||
config: createCommunityConfigDefaults(),
|
||||
scope: { messages: 'none', raw_packets: 'all' },
|
||||
},
|
||||
},
|
||||
bot: {
|
||||
savedType: 'bot',
|
||||
detailLabel: 'Bot',
|
||||
defaultName: 'Bot',
|
||||
defaults: {
|
||||
config: {
|
||||
code: DEFAULT_BOT_CODE,
|
||||
},
|
||||
scope: { messages: 'all', raw_packets: 'none' },
|
||||
},
|
||||
},
|
||||
webhook: {
|
||||
{
|
||||
value: 'webhook',
|
||||
savedType: 'webhook',
|
||||
detailLabel: 'Webhook',
|
||||
label: 'Webhook',
|
||||
section: 'Automation',
|
||||
description:
|
||||
'Generic webhook for decrypted channel/DM messages with customizable verb, method, and optional HMAC signature.',
|
||||
defaultName: 'Webhook',
|
||||
nameMode: 'counted',
|
||||
defaults: {
|
||||
config: {
|
||||
url: '',
|
||||
@@ -217,10 +232,15 @@ const DRAFT_RECIPES: Record<DraftType, DraftRecipe> = {
|
||||
scope: { messages: 'all', raw_packets: 'none' },
|
||||
},
|
||||
},
|
||||
apprise: {
|
||||
{
|
||||
value: 'apprise',
|
||||
savedType: 'apprise',
|
||||
detailLabel: 'Apprise',
|
||||
label: 'Apprise',
|
||||
section: 'Automation',
|
||||
description:
|
||||
'A wide-ranging generic fanout, capable of forwarding decrypted channel/DM messages to Discord, Telegram, email, SMS, and many others.',
|
||||
defaultName: 'Apprise',
|
||||
nameMode: 'counted',
|
||||
defaults: {
|
||||
config: {
|
||||
urls: '',
|
||||
@@ -230,10 +250,14 @@ const DRAFT_RECIPES: Record<DraftType, DraftRecipe> = {
|
||||
scope: { messages: 'all', raw_packets: 'none' },
|
||||
},
|
||||
},
|
||||
sqs: {
|
||||
{
|
||||
value: 'sqs',
|
||||
savedType: 'sqs',
|
||||
detailLabel: 'Amazon SQS',
|
||||
label: 'Amazon SQS',
|
||||
section: 'Bulk Forwarding',
|
||||
description: 'Send full or scope-customized raw or decrypted packets to an SQS',
|
||||
defaultName: 'Amazon SQS',
|
||||
nameMode: 'counted',
|
||||
defaults: {
|
||||
config: {
|
||||
queue_url: '',
|
||||
@@ -246,15 +270,58 @@ const DRAFT_RECIPES: Record<DraftType, DraftRecipe> = {
|
||||
scope: { messages: 'all', raw_packets: 'none' },
|
||||
},
|
||||
},
|
||||
};
|
||||
{
|
||||
value: 'bot',
|
||||
savedType: 'bot',
|
||||
label: 'Python Bot',
|
||||
section: 'Automation',
|
||||
description:
|
||||
'A simple, Python-based interface for basic bots that can respond to DM and channel messages.',
|
||||
defaultName: 'Bot',
|
||||
nameMode: 'counted',
|
||||
defaults: {
|
||||
config: {
|
||||
code: DEFAULT_BOT_CODE,
|
||||
},
|
||||
scope: { messages: 'all', raw_packets: 'none' },
|
||||
},
|
||||
},
|
||||
{
|
||||
value: 'map_upload',
|
||||
savedType: 'map_upload',
|
||||
label: 'Map Upload',
|
||||
section: 'Community Sharing',
|
||||
description:
|
||||
'Upload repeaters and room servers to map.meshcore.dev or a compatible map API endpoint.',
|
||||
defaultName: 'Map Upload',
|
||||
nameMode: 'counted',
|
||||
defaults: {
|
||||
config: {
|
||||
api_url: '',
|
||||
dry_run: true,
|
||||
},
|
||||
scope: { messages: 'none', raw_packets: 'all' },
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const CREATE_INTEGRATION_DEFINITIONS_BY_VALUE = Object.fromEntries(
|
||||
CREATE_INTEGRATION_DEFINITIONS.map((definition) => [definition.value, definition])
|
||||
) as Record<DraftType, CreateIntegrationDefinition>;
|
||||
|
||||
function isDraftType(value: string): value is DraftType {
|
||||
return value in DRAFT_RECIPES;
|
||||
return value in CREATE_INTEGRATION_DEFINITIONS_BY_VALUE;
|
||||
}
|
||||
|
||||
function getCreateIntegrationDefinition(draftType: DraftType) {
|
||||
return CREATE_INTEGRATION_DEFINITIONS_BY_VALUE[draftType];
|
||||
}
|
||||
|
||||
function normalizeDraftName(draftType: DraftType, name: string, configs: FanoutConfig[]) {
|
||||
const recipe = DRAFT_RECIPES[draftType];
|
||||
return name || getDefaultIntegrationName(recipe.savedType, configs);
|
||||
const definition = getCreateIntegrationDefinition(draftType);
|
||||
if (name) return name;
|
||||
if (definition.nameMode === 'fixed') return definition.defaultName;
|
||||
return getDefaultIntegrationName(definition.savedType, configs);
|
||||
}
|
||||
|
||||
function normalizeDraftConfig(draftType: DraftType, config: Record<string, unknown>) {
|
||||
@@ -305,22 +372,160 @@ function normalizeDraftConfig(draftType: DraftType, config: Record<string, unkno
|
||||
}
|
||||
|
||||
function normalizeDraftScope(draftType: DraftType, scope: Record<string, unknown>) {
|
||||
if (draftType.startsWith('mqtt_community_')) {
|
||||
if (getCreateIntegrationDefinition(draftType).savedType === 'mqtt_community') {
|
||||
return { messages: 'none', raw_packets: 'all' };
|
||||
}
|
||||
return scope;
|
||||
}
|
||||
|
||||
function cloneDraftDefaults(draftType: DraftType) {
|
||||
const recipe = DRAFT_RECIPES[draftType];
|
||||
const recipe = getCreateIntegrationDefinition(draftType);
|
||||
return {
|
||||
config: structuredClone(recipe.defaults.config),
|
||||
scope: structuredClone(recipe.defaults.scope),
|
||||
};
|
||||
}
|
||||
|
||||
function CreateIntegrationDialog({
|
||||
open,
|
||||
options,
|
||||
selectedType,
|
||||
onOpenChange,
|
||||
onSelect,
|
||||
onCreate,
|
||||
}: {
|
||||
open: boolean;
|
||||
options: readonly CreateIntegrationDefinition[];
|
||||
selectedType: DraftType | null;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onSelect: (type: DraftType) => void;
|
||||
onCreate: () => void;
|
||||
}) {
|
||||
const selectedOption =
|
||||
options.find((option) => option.value === selectedType) ?? options[0] ?? null;
|
||||
const listRef = useRef<HTMLDivElement | null>(null);
|
||||
const [showScrollHint, setShowScrollHint] = useState(false);
|
||||
|
||||
const updateScrollHint = useCallback(() => {
|
||||
const container = listRef.current;
|
||||
if (!container) {
|
||||
setShowScrollHint(false);
|
||||
return;
|
||||
}
|
||||
setShowScrollHint(container.scrollTop + container.clientHeight < container.scrollHeight - 8);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const frame = window.requestAnimationFrame(updateScrollHint);
|
||||
window.addEventListener('resize', updateScrollHint);
|
||||
return () => {
|
||||
window.cancelAnimationFrame(frame);
|
||||
window.removeEventListener('resize', updateScrollHint);
|
||||
};
|
||||
}, [open, options, updateScrollHint]);
|
||||
|
||||
const sectionedOptions = [...new Set(options.map((o) => o.section))]
|
||||
.map((section) => ({
|
||||
section,
|
||||
options: options.filter((option) => option.section === section),
|
||||
}))
|
||||
.filter((group) => group.options.length > 0);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent
|
||||
aria-describedby={undefined}
|
||||
hideCloseButton
|
||||
className="flex max-h-[calc(100dvh-2rem)] w-[96vw] max-w-[960px] flex-col overflow-hidden p-0 sm:rounded-xl"
|
||||
>
|
||||
<DialogHeader className="border-b border-border px-5 py-4">
|
||||
<DialogTitle>Create Integration</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="grid min-h-0 flex-1 grid-cols-1 overflow-hidden md:grid-cols-[240px_minmax(0,1fr)]">
|
||||
<div className="relative border-b border-border bg-muted/20 md:border-b-0 md:border-r">
|
||||
<div
|
||||
ref={listRef}
|
||||
onScroll={updateScrollHint}
|
||||
className="max-h-56 overflow-y-auto p-2 md:max-h-[420px]"
|
||||
>
|
||||
<div className="space-y-4">
|
||||
{sectionedOptions.map((group) => (
|
||||
<div key={group.section} className="space-y-1.5">
|
||||
<div className="px-2 text-[11px] font-semibold uppercase tracking-[0.12em] text-muted-foreground">
|
||||
{group.section}
|
||||
</div>
|
||||
{group.options.map((option) => {
|
||||
const selected = option.value === selectedOption?.value;
|
||||
return (
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
className={cn(
|
||||
'w-full rounded-md border px-3 py-2 text-left transition-colors',
|
||||
selected
|
||||
? 'border-primary bg-accent text-foreground'
|
||||
: 'border-transparent bg-transparent hover:bg-accent/70'
|
||||
)}
|
||||
aria-pressed={selected}
|
||||
onClick={() => onSelect(option.value)}
|
||||
>
|
||||
<div className="text-sm font-medium">{option.label}</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showScrollHint && (
|
||||
<div className="pointer-events-none absolute inset-x-0 bottom-0 flex justify-center bg-gradient-to-t from-background via-background/85 to-transparent px-4 pb-2 pt-8">
|
||||
<div className="rounded-full border border-border/80 bg-background/95 px-2 py-1 text-muted-foreground shadow-sm">
|
||||
<ChevronDown className="h-4 w-4" aria-hidden="true" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 space-y-4 overflow-y-auto px-5 py-5 md:min-h-[280px] md:max-h-[420px]">
|
||||
{selectedOption ? (
|
||||
<>
|
||||
<div className="space-y-1.5">
|
||||
<div className="text-xs font-semibold uppercase tracking-[0.12em] text-muted-foreground">
|
||||
{selectedOption.section}
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold">{selectedOption.label}</h3>
|
||||
</div>
|
||||
|
||||
<p className="text-sm leading-6 text-muted-foreground">
|
||||
{selectedOption.description}
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
|
||||
No integration types are currently available.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter className="gap-2 border-t border-border px-5 py-4 sm:justify-end">
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
Close
|
||||
</Button>
|
||||
<Button onClick={onCreate} disabled={!selectedOption}>
|
||||
Create
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function getDetailTypeLabel(detailType: string) {
|
||||
if (isDraftType(detailType)) return DRAFT_RECIPES[detailType].detailLabel;
|
||||
if (isDraftType(detailType)) return getCreateIntegrationDefinition(detailType).label;
|
||||
return TYPE_LABELS[detailType] || detailType;
|
||||
}
|
||||
|
||||
@@ -380,7 +585,9 @@ function getDefaultIntegrationName(type: string, configs: FanoutConfig[]) {
|
||||
|
||||
function getStatusLabel(status: string | undefined, type?: string) {
|
||||
if (status === 'connected')
|
||||
return type === 'bot' || type === 'webhook' || type === 'apprise' ? 'Active' : 'Connected';
|
||||
return type === 'bot' || type === 'webhook' || type === 'apprise' || type === 'map_upload'
|
||||
? 'Active'
|
||||
: 'Connected';
|
||||
if (status === 'error') return 'Error';
|
||||
if (status === 'disconnected') return 'Disconnected';
|
||||
return 'Inactive';
|
||||
@@ -873,6 +1080,152 @@ function BotConfigEditor({
|
||||
);
|
||||
}
|
||||
|
||||
function MapUploadConfigEditor({
|
||||
config,
|
||||
onChange,
|
||||
}: {
|
||||
config: Record<string, unknown>;
|
||||
onChange: (config: Record<string, unknown>) => void;
|
||||
}) {
|
||||
const isDryRun = config.dry_run !== false;
|
||||
const [radioLat, setRadioLat] = useState<number | null>(null);
|
||||
const [radioLon, setRadioLon] = useState<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
api
|
||||
.getRadioConfig()
|
||||
.then((rc) => {
|
||||
setRadioLat(rc.lat ?? 0);
|
||||
setRadioLon(rc.lon ?? 0);
|
||||
})
|
||||
.catch(() => {
|
||||
setRadioLat(0);
|
||||
setRadioLon(0);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const radioLatLonConfigured =
|
||||
radioLat !== null && radioLon !== null && !(radioLat === 0 && radioLon === 0);
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Automatically upload heard repeater and room server advertisements to{' '}
|
||||
<a
|
||||
href="https://map.meshcore.dev"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="underline hover:text-foreground"
|
||||
>
|
||||
map.meshcore.dev
|
||||
</a>
|
||||
. Requires the radio's private key to be available (firmware must have{' '}
|
||||
<code>ENABLE_PRIVATE_KEY_EXPORT=1</code>). Only raw RF packets are shared — never
|
||||
decrypted messages.
|
||||
</p>
|
||||
|
||||
<div className="rounded-md border border-warning/50 bg-warning/10 px-3 py-2 text-xs text-warning">
|
||||
<strong>Dry Run is {isDryRun ? 'ON' : 'OFF'}.</strong>{' '}
|
||||
{isDryRun
|
||||
? 'No uploads will be sent. Check the backend logs to verify the payload looks correct before enabling live sends.'
|
||||
: 'Live uploads are enabled. Each advert is rate-limited to once per hour per node.'}
|
||||
</div>
|
||||
|
||||
<label className="flex items-center gap-3 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isDryRun}
|
||||
onChange={(e) => onChange({ ...config, dry_run: e.target.checked })}
|
||||
className="h-4 w-4 rounded border-border"
|
||||
/>
|
||||
<div>
|
||||
<span className="text-sm font-medium">Dry Run (log only, no uploads)</span>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
When enabled, upload payloads are logged at INFO level but not sent. Disable once you
|
||||
have confirmed the logged output looks correct.
|
||||
</p>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="fanout-map-api-url">API URL (optional)</Label>
|
||||
<Input
|
||||
id="fanout-map-api-url"
|
||||
type="url"
|
||||
placeholder="https://map.meshcore.dev/api/v1/uploader/node"
|
||||
value={(config.api_url as string) || ''}
|
||||
onChange={(e) => onChange({ ...config, api_url: e.target.value })}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Leave blank to use the default <code>map.meshcore.dev</code> endpoint.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<label className="flex items-center gap-3 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={!!config.geofence_enabled}
|
||||
onChange={(e) => onChange({ ...config, geofence_enabled: e.target.checked })}
|
||||
className="h-4 w-4 rounded border-border"
|
||||
/>
|
||||
<div>
|
||||
<span className="text-sm font-medium">Enable Geofence</span>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Only upload nodes whose location falls within the configured radius of your radio's
|
||||
own position. Helps exclude nodes with false or spoofed coordinates. Uses the
|
||||
latitude/longitude set in Radio Settings.
|
||||
</p>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
{!!config.geofence_enabled && (
|
||||
<div className="space-y-3 pl-7">
|
||||
{!radioLatLonConfigured && (
|
||||
<div className="rounded-md border border-warning/50 bg-warning/10 px-3 py-2 text-xs text-warning">
|
||||
Your radio does not currently have a latitude/longitude configured. Geofencing will be
|
||||
silently skipped until coordinates are set in{' '}
|
||||
<strong>Settings → Radio → Location</strong>.
|
||||
</div>
|
||||
)}
|
||||
{radioLatLonConfigured && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Using radio position{' '}
|
||||
<code>
|
||||
{radioLat?.toFixed(5)}, {radioLon?.toFixed(5)}
|
||||
</code>{' '}
|
||||
as the geofence center. Update coordinates in Radio Settings to move the center.
|
||||
</p>
|
||||
)}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="fanout-map-geofence-radius">Radius (km)</Label>
|
||||
<Input
|
||||
id="fanout-map-geofence-radius"
|
||||
type="number"
|
||||
min="0"
|
||||
step="any"
|
||||
placeholder="e.g. 100"
|
||||
value={(config.geofence_radius_km as number | undefined) ?? ''}
|
||||
onChange={(e) =>
|
||||
onChange({
|
||||
...config,
|
||||
geofence_radius_km: e.target.value === '' ? 0 : parseFloat(e.target.value),
|
||||
})
|
||||
}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Nodes further than this distance from your radio's position will not be uploaded.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type ScopeMode = 'all' | 'none' | 'only' | 'except';
|
||||
|
||||
function getScopeMode(value: unknown): ScopeMode {
|
||||
@@ -1499,9 +1852,9 @@ export function SettingsFanoutSection({
|
||||
const [editName, setEditName] = useState('');
|
||||
const [inlineEditingId, setInlineEditingId] = useState<string | null>(null);
|
||||
const [inlineEditName, setInlineEditName] = useState('');
|
||||
const [addMenuOpen, setAddMenuOpen] = useState(false);
|
||||
const [createDialogOpen, setCreateDialogOpen] = useState(false);
|
||||
const [selectedCreateType, setSelectedCreateType] = useState<DraftType | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const addMenuRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
const loadConfigs = useCallback(async () => {
|
||||
try {
|
||||
@@ -1516,18 +1869,28 @@ export function SettingsFanoutSection({
|
||||
loadConfigs();
|
||||
}, [loadConfigs]);
|
||||
|
||||
const availableCreateOptions = useMemo(
|
||||
() =>
|
||||
CREATE_INTEGRATION_DEFINITIONS.filter(
|
||||
(definition) => definition.savedType !== 'bot' || !health?.bots_disabled
|
||||
),
|
||||
[health?.bots_disabled]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!addMenuOpen) return;
|
||||
|
||||
const handlePointerDown = (event: MouseEvent) => {
|
||||
if (!addMenuRef.current?.contains(event.target as Node)) {
|
||||
setAddMenuOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('mousedown', handlePointerDown);
|
||||
return () => document.removeEventListener('mousedown', handlePointerDown);
|
||||
}, [addMenuOpen]);
|
||||
if (!createDialogOpen) return;
|
||||
if (availableCreateOptions.length === 0) {
|
||||
setSelectedCreateType(null);
|
||||
return;
|
||||
}
|
||||
if (
|
||||
selectedCreateType &&
|
||||
availableCreateOptions.some((option) => option.value === selectedCreateType)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
setSelectedCreateType(availableCreateOptions[0].value);
|
||||
}, [createDialogOpen, availableCreateOptions, selectedCreateType]);
|
||||
|
||||
const handleToggleEnabled = async (cfg: FanoutConfig) => {
|
||||
try {
|
||||
@@ -1541,7 +1904,7 @@ export function SettingsFanoutSection({
|
||||
};
|
||||
|
||||
const handleEdit = (cfg: FanoutConfig) => {
|
||||
setAddMenuOpen(false);
|
||||
setCreateDialogOpen(false);
|
||||
setInlineEditingId(null);
|
||||
setInlineEditName('');
|
||||
setDraftType(null);
|
||||
@@ -1552,7 +1915,7 @@ export function SettingsFanoutSection({
|
||||
};
|
||||
|
||||
const handleStartInlineEdit = (cfg: FanoutConfig) => {
|
||||
setAddMenuOpen(false);
|
||||
setCreateDialogOpen(false);
|
||||
setInlineEditingId(cfg.id);
|
||||
setInlineEditName(cfg.name);
|
||||
};
|
||||
@@ -1611,7 +1974,7 @@ export function SettingsFanoutSection({
|
||||
setBusy(true);
|
||||
try {
|
||||
if (currentDraftType) {
|
||||
const recipe = DRAFT_RECIPES[currentDraftType];
|
||||
const recipe = getCreateIntegrationDefinition(currentDraftType);
|
||||
await api.createFanoutConfig({
|
||||
type: recipe.savedType,
|
||||
name: normalizeDraftName(currentDraftType, editName.trim(), configs),
|
||||
@@ -1663,18 +2026,16 @@ export function SettingsFanoutSection({
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddCreate = async (type: string) => {
|
||||
if (!isDraftType(type)) return;
|
||||
const handleAddCreate = (type: DraftType) => {
|
||||
const definition = getCreateIntegrationDefinition(type);
|
||||
const defaults = cloneDraftDefaults(type);
|
||||
setAddMenuOpen(false);
|
||||
setCreateDialogOpen(false);
|
||||
setEditingId(null);
|
||||
setDraftType(type);
|
||||
setEditName(
|
||||
type === 'mqtt_community_meshrank' ||
|
||||
type === 'mqtt_community_letsmesh_us' ||
|
||||
type === 'mqtt_community_letsmesh_eu'
|
||||
? DRAFT_RECIPES[type].defaultName
|
||||
: getDefaultIntegrationName(DRAFT_RECIPES[type].savedType, configs)
|
||||
definition.nameMode === 'fixed'
|
||||
? definition.defaultName
|
||||
: getDefaultIntegrationName(definition.savedType, configs)
|
||||
);
|
||||
setEditConfig(defaults.config);
|
||||
setEditScope(defaults.scope);
|
||||
@@ -1683,13 +2044,15 @@ export function SettingsFanoutSection({
|
||||
const editingConfig = editingId ? configs.find((c) => c.id === editingId) : null;
|
||||
const detailType = draftType ?? editingConfig?.type ?? null;
|
||||
const isDraft = draftType !== null;
|
||||
const configGroups = LIST_TYPE_OPTIONS.map((opt) => ({
|
||||
type: opt.value,
|
||||
label: opt.label,
|
||||
configs: configs
|
||||
.filter((cfg) => cfg.type === opt.value)
|
||||
.sort((a, b) => a.name.localeCompare(b.name, undefined, { sensitivity: 'base' })),
|
||||
})).filter((group) => group.configs.length > 0);
|
||||
const configGroups = Object.entries(TYPE_LABELS)
|
||||
.map(([type, label]) => ({
|
||||
type,
|
||||
label,
|
||||
configs: configs
|
||||
.filter((cfg) => cfg.type === type)
|
||||
.sort((a, b) => a.name.localeCompare(b.name, undefined, { sensitivity: 'base' })),
|
||||
}))
|
||||
.filter((group) => group.configs.length > 0);
|
||||
|
||||
// Detail view
|
||||
if (detailType) {
|
||||
@@ -1779,6 +2142,10 @@ export function SettingsFanoutSection({
|
||||
/>
|
||||
)}
|
||||
|
||||
{detailType === 'map_upload' && (
|
||||
<MapUploadConfigEditor config={editConfig} onChange={setEditConfig} />
|
||||
)}
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="flex gap-2">
|
||||
@@ -1823,37 +2190,22 @@ export function SettingsFanoutSection({
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="relative inline-block" ref={addMenuRef}>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={addMenuOpen}
|
||||
onClick={() => setAddMenuOpen((open) => !open)}
|
||||
>
|
||||
Add Integration
|
||||
</Button>
|
||||
{addMenuOpen && (
|
||||
<div
|
||||
role="menu"
|
||||
className="absolute left-0 top-full z-10 mt-2 min-w-72 rounded-md border border-input bg-background p-1 shadow-md"
|
||||
>
|
||||
{CREATE_TYPE_OPTIONS.filter((opt) => opt.value !== 'bot' || !health?.bots_disabled).map(
|
||||
(opt) => (
|
||||
<button
|
||||
key={opt.value}
|
||||
type="button"
|
||||
role="menuitem"
|
||||
className="flex w-full rounded-sm px-3 py-2 text-left text-sm hover:bg-muted"
|
||||
onClick={() => handleAddCreate(opt.value)}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<Button type="button" size="sm" onClick={() => setCreateDialogOpen(true)}>
|
||||
Add Integration
|
||||
</Button>
|
||||
|
||||
<CreateIntegrationDialog
|
||||
open={createDialogOpen}
|
||||
options={availableCreateOptions}
|
||||
selectedType={selectedCreateType}
|
||||
onOpenChange={setCreateDialogOpen}
|
||||
onSelect={setSelectedCreateType}
|
||||
onCreate={() => {
|
||||
if (selectedCreateType) {
|
||||
handleAddCreate(selectedCreateType);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
{configGroups.length > 0 && (
|
||||
<div className="columns-1 gap-4 md:columns-2">
|
||||
|
||||
@@ -10,4 +10,5 @@ export { useRealtimeAppState } from './useRealtimeAppState';
|
||||
export { useConversationActions } from './useConversationActions';
|
||||
export { useConversationNavigation } from './useConversationNavigation';
|
||||
export { useBrowserNotifications } from './useBrowserNotifications';
|
||||
export { useFaviconBadge, useUnreadTitle } from './useFaviconBadge';
|
||||
export { useRawPacketStatsSession } from './useRawPacketStatsSession';
|
||||
|
||||
@@ -9,6 +9,17 @@ const NOTIFICATION_ICON_PATH = '/favicon-256x256.png';
|
||||
type NotificationPermissionState = NotificationPermission | 'unsupported';
|
||||
type ConversationNotificationMap = Record<string, boolean>;
|
||||
|
||||
interface NotificationEnableToastInfo {
|
||||
level: 'success' | 'warning';
|
||||
title: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
interface NotificationEnvironment {
|
||||
protocol: string;
|
||||
isSecureContext: boolean;
|
||||
}
|
||||
|
||||
function getConversationNotificationKey(type: 'channel' | 'contact', id: string): string {
|
||||
return getStateKey(type, id);
|
||||
}
|
||||
@@ -92,6 +103,40 @@ function buildMessageNotificationHash(message: Message): string | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
export function getNotificationEnableToastInfo(
|
||||
environment?: Partial<NotificationEnvironment>
|
||||
): NotificationEnableToastInfo {
|
||||
if (typeof window === 'undefined') {
|
||||
return { level: 'success', title: 'Notifications enabled' };
|
||||
}
|
||||
|
||||
const protocol = environment?.protocol ?? window.location.protocol;
|
||||
const isSecureContext = environment?.isSecureContext ?? window.isSecureContext;
|
||||
|
||||
if (protocol === 'http:') {
|
||||
return {
|
||||
level: 'warning',
|
||||
title: 'Notifications enabled with warning',
|
||||
description:
|
||||
'Desktop notifications are on for this conversation, but you are using HTTP instead of HTTPS. Notifications will likely not work reliably.',
|
||||
};
|
||||
}
|
||||
|
||||
// Best-effort heuristic only. Browsers do not expose certificate trust details
|
||||
// directly to page JS, so an HTTPS page that is not a secure context is the
|
||||
// closest signal we have for an untrusted/self-signed setup.
|
||||
if (protocol === 'https:' && !isSecureContext) {
|
||||
return {
|
||||
level: 'warning',
|
||||
title: 'Notifications enabled with warning',
|
||||
description:
|
||||
'Desktop notifications are on for this conversation, but your HTTPS connection is untrusted, such as a self-signed certificate. Notification delivery may be inconsistent depending on your browser.',
|
||||
};
|
||||
}
|
||||
|
||||
return { level: 'success', title: 'Notifications enabled' };
|
||||
}
|
||||
|
||||
export function useBrowserNotifications() {
|
||||
const [permission, setPermission] = useState<NotificationPermissionState>(getInitialPermission);
|
||||
const [enabledByConversation, setEnabledByConversation] =
|
||||
@@ -110,8 +155,6 @@ export function useBrowserNotifications() {
|
||||
|
||||
const toggleConversationNotifications = useCallback(
|
||||
async (type: 'channel' | 'contact', id: string, label: string) => {
|
||||
const blockedDescription =
|
||||
'Allow notifications in your browser settings, then try again. Some browsers may refuse notifications on non-HTTPS or self-signed HTTPS origins. Check your browser documentation for how to trust an insecure origin and the associated risks before doing so.';
|
||||
const conversationKey = getConversationNotificationKey(type, id);
|
||||
if (enabledByConversation[conversationKey]) {
|
||||
setEnabledByConversation((prev) => {
|
||||
@@ -120,20 +163,23 @@ export function useBrowserNotifications() {
|
||||
writeStoredEnabledMap(next);
|
||||
return next;
|
||||
});
|
||||
toast.success(`${label} notifications disabled`);
|
||||
toast.success('Notifications disabled', {
|
||||
description: `Desktop notifications are off for ${label}.`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (permission === 'unsupported') {
|
||||
toast.error('Browser notifications unavailable', {
|
||||
toast.error('Notifications unavailable', {
|
||||
description: 'This browser does not support desktop notifications.',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (permission === 'denied') {
|
||||
toast.error('Browser notifications blocked', {
|
||||
description: blockedDescription,
|
||||
toast.error('Notifications blocked', {
|
||||
description:
|
||||
'Desktop notifications are blocked by your browser. Allow notifications in browser settings, then try again. Non-HTTPS or untrusted HTTPS origins may also prevent notifications from working reliably.',
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -155,13 +201,24 @@ export function useBrowserNotifications() {
|
||||
icon: NOTIFICATION_ICON_PATH,
|
||||
tag: `meshcore-notification-preview-${conversationKey}`,
|
||||
});
|
||||
toast.success(`${label} notifications enabled`);
|
||||
const toastInfo = getNotificationEnableToastInfo();
|
||||
if (toastInfo.level === 'warning') {
|
||||
toast.warning(toastInfo.title, {
|
||||
description: toastInfo.description,
|
||||
});
|
||||
} else {
|
||||
toast.success(toastInfo.title, {
|
||||
description: `Desktop notifications are on for ${label}.`,
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
toast.error('Browser notifications not enabled', {
|
||||
toast.error('Notifications not enabled', {
|
||||
description:
|
||||
nextPermission === 'denied' ? blockedDescription : 'Permission request was dismissed.',
|
||||
nextPermission === 'denied'
|
||||
? 'Desktop notifications were denied by your browser. Allow notifications in browser settings, then try again.'
|
||||
: 'The browser permission request was dismissed.',
|
||||
});
|
||||
},
|
||||
[enabledByConversation, permission]
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
} from '../utils/lastViewedConversation';
|
||||
import { findPublicChannel } from '../utils/publicChannel';
|
||||
import { getContactDisplayName } from '../utils/pubkey';
|
||||
import { toast } from '../components/ui/sonner';
|
||||
import type { Channel, Contact, Conversation } from '../types';
|
||||
|
||||
interface UseConversationRouterArgs {
|
||||
@@ -137,6 +138,11 @@ export function useConversationRouter({
|
||||
// No hash or unresolvable — default to Public
|
||||
const publicConversation = getPublicChannelConversation();
|
||||
if (publicConversation) {
|
||||
if (hashConv?.type === 'channel') {
|
||||
const token =
|
||||
hashConv.name.length > 16 ? hashConv.name.substring(0, 16) + '…' : hashConv.name;
|
||||
toast.error(`Channel not found: ${token}`);
|
||||
}
|
||||
setActiveConversationState(publicConversation);
|
||||
hasSetDefaultConversation.current = true;
|
||||
}
|
||||
@@ -162,6 +168,9 @@ export function useConversationRouter({
|
||||
}
|
||||
|
||||
// Contact hash didn't match — fall back to Public if channels loaded.
|
||||
const token =
|
||||
hashConv.name.length > 16 ? hashConv.name.substring(0, 16) + '…' : hashConv.name;
|
||||
toast.error(`Contact not found: ${token}`);
|
||||
const publicConversation = getPublicChannelConversation();
|
||||
if (publicConversation) {
|
||||
setActiveConversationState(publicConversation);
|
||||
|
||||
196
frontend/src/hooks/useFaviconBadge.ts
Normal file
196
frontend/src/hooks/useFaviconBadge.ts
Normal file
@@ -0,0 +1,196 @@
|
||||
import { useEffect, useMemo, useRef } from 'react';
|
||||
|
||||
import type { Favorite } from '../types';
|
||||
import { getStateKey } from '../utils/conversationState';
|
||||
|
||||
const APP_TITLE = 'RemoteTerm for MeshCore';
|
||||
const UNREAD_APP_TITLE = 'RemoteTerm';
|
||||
const BASE_FAVICON_PATH = '/favicon.svg';
|
||||
const GREEN_BADGE_FILL = '#16a34a';
|
||||
const RED_BADGE_FILL = '#dc2626';
|
||||
const BADGE_CENTER = 750;
|
||||
const BADGE_OUTER_RADIUS = 220;
|
||||
const BADGE_INNER_RADIUS = 180;
|
||||
|
||||
let baseFaviconSvgPromise: Promise<string> | null = null;
|
||||
|
||||
export type FaviconBadgeState = 'none' | 'green' | 'red';
|
||||
|
||||
function getUnreadDirectMessageCount(unreadCounts: Record<string, number>): number {
|
||||
return Object.entries(unreadCounts).reduce(
|
||||
(sum, [stateKey, count]) => sum + (stateKey.startsWith('contact-') ? count : 0),
|
||||
0
|
||||
);
|
||||
}
|
||||
|
||||
function getUnreadFavoriteChannelCount(
|
||||
unreadCounts: Record<string, number>,
|
||||
favorites: Favorite[]
|
||||
): number {
|
||||
return favorites.reduce(
|
||||
(sum, favorite) =>
|
||||
sum +
|
||||
(favorite.type === 'channel' ? unreadCounts[getStateKey('channel', favorite.id)] || 0 : 0),
|
||||
0
|
||||
);
|
||||
}
|
||||
|
||||
export function getTotalUnreadCount(unreadCounts: Record<string, number>): number {
|
||||
return Object.values(unreadCounts).reduce((sum, count) => sum + count, 0);
|
||||
}
|
||||
|
||||
export function getFavoriteUnreadCount(
|
||||
unreadCounts: Record<string, number>,
|
||||
favorites: Favorite[]
|
||||
): number {
|
||||
return favorites.reduce((sum, favorite) => {
|
||||
const stateKey = getStateKey(favorite.type, favorite.id);
|
||||
return sum + (unreadCounts[stateKey] || 0);
|
||||
}, 0);
|
||||
}
|
||||
|
||||
export function getUnreadTitle(
|
||||
unreadCounts: Record<string, number>,
|
||||
favorites: Favorite[]
|
||||
): string {
|
||||
const unreadCount = getFavoriteUnreadCount(unreadCounts, favorites);
|
||||
if (unreadCount <= 0) {
|
||||
return APP_TITLE;
|
||||
}
|
||||
|
||||
const label = unreadCount > 99 ? '99+' : String(unreadCount);
|
||||
return `(${label}) ${UNREAD_APP_TITLE}`;
|
||||
}
|
||||
|
||||
export function deriveFaviconBadgeState(
|
||||
unreadCounts: Record<string, number>,
|
||||
mentions: Record<string, boolean>,
|
||||
favorites: Favorite[]
|
||||
): FaviconBadgeState {
|
||||
if (Object.values(mentions).some(Boolean) || getUnreadDirectMessageCount(unreadCounts) > 0) {
|
||||
return 'red';
|
||||
}
|
||||
|
||||
if (getUnreadFavoriteChannelCount(unreadCounts, favorites) > 0) {
|
||||
return 'green';
|
||||
}
|
||||
|
||||
return 'none';
|
||||
}
|
||||
|
||||
export function buildBadgedFaviconSvg(baseSvg: string, badgeFill: string): string {
|
||||
const closingTagIndex = baseSvg.lastIndexOf('</svg>');
|
||||
if (closingTagIndex === -1) {
|
||||
return baseSvg;
|
||||
}
|
||||
|
||||
const badge = `
|
||||
<circle cx="${BADGE_CENTER}" cy="${BADGE_CENTER}" r="${BADGE_OUTER_RADIUS}" fill="#ffffff"/>
|
||||
<circle cx="${BADGE_CENTER}" cy="${BADGE_CENTER}" r="${BADGE_INNER_RADIUS}" fill="${badgeFill}"/>
|
||||
`;
|
||||
return `${baseSvg.slice(0, closingTagIndex)}${badge}</svg>`;
|
||||
}
|
||||
|
||||
async function loadBaseFaviconSvg(): Promise<string> {
|
||||
if (!baseFaviconSvgPromise) {
|
||||
baseFaviconSvgPromise = fetch(BASE_FAVICON_PATH, { cache: 'force-cache' })
|
||||
.then(async (response) => {
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to load favicon SVG: ${response.status}`);
|
||||
}
|
||||
return response.text();
|
||||
})
|
||||
.catch((error) => {
|
||||
baseFaviconSvgPromise = null;
|
||||
throw error;
|
||||
});
|
||||
}
|
||||
|
||||
return baseFaviconSvgPromise;
|
||||
}
|
||||
|
||||
function upsertFaviconLinks(rel: 'icon' | 'shortcut icon', href: string): void {
|
||||
const links = Array.from(document.head.querySelectorAll<HTMLLinkElement>(`link[rel="${rel}"]`));
|
||||
const targets = links.length > 0 ? links : [document.createElement('link')];
|
||||
|
||||
for (const link of targets) {
|
||||
if (!link.parentNode) {
|
||||
link.rel = rel;
|
||||
document.head.appendChild(link);
|
||||
}
|
||||
link.type = 'image/svg+xml';
|
||||
link.href = href;
|
||||
}
|
||||
}
|
||||
|
||||
function applyFaviconHref(href: string): void {
|
||||
upsertFaviconLinks('icon', href);
|
||||
upsertFaviconLinks('shortcut icon', href);
|
||||
}
|
||||
|
||||
export function useUnreadTitle(unreadCounts: Record<string, number>, favorites: Favorite[]): void {
|
||||
const title = useMemo(() => getUnreadTitle(unreadCounts, favorites), [favorites, unreadCounts]);
|
||||
|
||||
useEffect(() => {
|
||||
document.title = title;
|
||||
|
||||
return () => {
|
||||
document.title = APP_TITLE;
|
||||
};
|
||||
}, [title]);
|
||||
}
|
||||
|
||||
export function useFaviconBadge(
|
||||
unreadCounts: Record<string, number>,
|
||||
mentions: Record<string, boolean>,
|
||||
favorites: Favorite[]
|
||||
): void {
|
||||
const objectUrlRef = useRef<string | null>(null);
|
||||
const badgeState = useMemo(
|
||||
() => deriveFaviconBadgeState(unreadCounts, mentions, favorites),
|
||||
[favorites, mentions, unreadCounts]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (objectUrlRef.current) {
|
||||
URL.revokeObjectURL(objectUrlRef.current);
|
||||
objectUrlRef.current = null;
|
||||
}
|
||||
|
||||
if (badgeState === 'none') {
|
||||
applyFaviconHref(BASE_FAVICON_PATH);
|
||||
return;
|
||||
}
|
||||
|
||||
const badgeFill = badgeState === 'red' ? RED_BADGE_FILL : GREEN_BADGE_FILL;
|
||||
let cancelled = false;
|
||||
|
||||
void loadBaseFaviconSvg()
|
||||
.then((baseSvg) => {
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
|
||||
const objectUrl = URL.createObjectURL(
|
||||
new Blob([buildBadgedFaviconSvg(baseSvg, badgeFill)], {
|
||||
type: 'image/svg+xml',
|
||||
})
|
||||
);
|
||||
objectUrlRef.current = objectUrl;
|
||||
applyFaviconHref(objectUrl);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) {
|
||||
applyFaviconHref(BASE_FAVICON_PATH);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
if (objectUrlRef.current) {
|
||||
URL.revokeObjectURL(objectUrlRef.current);
|
||||
objectUrlRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [badgeState]);
|
||||
}
|
||||
@@ -2,12 +2,13 @@ import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
|
||||
type ServerLoginKind = 'repeater' | 'room';
|
||||
|
||||
const STORAGE_KEY_PREFIX = 'remoteterm-server-password';
|
||||
|
||||
type StoredPassword = {
|
||||
password: string;
|
||||
};
|
||||
|
||||
const STORAGE_KEY_PREFIX = 'remoteterm-server-password';
|
||||
const inMemoryPasswords = new Map<string, StoredPassword>();
|
||||
|
||||
function getStorageKey(kind: ServerLoginKind, publicKey: string): string {
|
||||
return `${STORAGE_KEY_PREFIX}:${kind}:${publicKey}`;
|
||||
}
|
||||
@@ -33,37 +34,46 @@ export function useRememberedServerPassword(kind: ServerLoginKind, publicKey: st
|
||||
|
||||
useEffect(() => {
|
||||
const stored = loadStoredPassword(kind, publicKey);
|
||||
if (!stored) {
|
||||
setPassword('');
|
||||
if (stored) {
|
||||
setPassword(stored.password);
|
||||
setRememberPassword(true);
|
||||
return;
|
||||
}
|
||||
|
||||
const inMemoryStored = inMemoryPasswords.get(storageKey);
|
||||
if (inMemoryStored) {
|
||||
setPassword(inMemoryStored.password);
|
||||
setRememberPassword(false);
|
||||
return;
|
||||
}
|
||||
setPassword(stored.password);
|
||||
setRememberPassword(true);
|
||||
}, [kind, publicKey]);
|
||||
|
||||
setPassword('');
|
||||
setRememberPassword(false);
|
||||
}, [kind, publicKey, storageKey]);
|
||||
|
||||
const persistAfterLogin = useCallback(
|
||||
(submittedPassword: string) => {
|
||||
const trimmedPassword = submittedPassword.trim();
|
||||
if (!trimmedPassword) {
|
||||
return;
|
||||
}
|
||||
|
||||
inMemoryPasswords.set(storageKey, { password: trimmedPassword });
|
||||
|
||||
if (!rememberPassword) {
|
||||
try {
|
||||
localStorage.removeItem(storageKey);
|
||||
} catch {
|
||||
// localStorage may be unavailable
|
||||
}
|
||||
setPassword('');
|
||||
return;
|
||||
} else {
|
||||
try {
|
||||
localStorage.setItem(storageKey, JSON.stringify({ password: trimmedPassword }));
|
||||
} catch {
|
||||
// localStorage may be unavailable
|
||||
}
|
||||
}
|
||||
|
||||
const trimmedPassword = submittedPassword.trim();
|
||||
if (!trimmedPassword) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
localStorage.setItem(storageKey, JSON.stringify({ password: trimmedPassword }));
|
||||
} catch {
|
||||
// localStorage may be unavailable
|
||||
}
|
||||
setPassword(trimmedPassword);
|
||||
},
|
||||
[rememberPassword, storageKey]
|
||||
|
||||
@@ -15,6 +15,11 @@ import type {
|
||||
RepeaterLppTelemetryResponse,
|
||||
CommandResponse,
|
||||
} from '../types';
|
||||
import {
|
||||
buildServerLoginAttemptFromError,
|
||||
buildServerLoginAttemptFromResponse,
|
||||
type ServerLoginAttemptState,
|
||||
} from '../utils/serverLoginState';
|
||||
|
||||
const MAX_RETRIES = 3;
|
||||
const RETRY_DELAY_MS = 2000;
|
||||
@@ -41,6 +46,7 @@ interface PaneData {
|
||||
interface RepeaterDashboardCacheEntry {
|
||||
loggedIn: boolean;
|
||||
loginError: string | null;
|
||||
lastLoginAttempt: ServerLoginAttemptState | null;
|
||||
paneData: PaneData;
|
||||
paneStates: Record<PaneName, PaneState>;
|
||||
consoleHistory: ConsoleEntry[];
|
||||
@@ -119,6 +125,7 @@ function getCachedState(publicKey: string | null): RepeaterDashboardCacheEntry |
|
||||
return {
|
||||
loggedIn: cached.loggedIn,
|
||||
loginError: cached.loginError,
|
||||
lastLoginAttempt: cached.lastLoginAttempt,
|
||||
paneData: clonePaneData(cached.paneData),
|
||||
paneStates: normalizePaneStates(cached.paneStates),
|
||||
consoleHistory: cloneConsoleHistory(cached.consoleHistory),
|
||||
@@ -130,6 +137,7 @@ function cacheState(publicKey: string, entry: RepeaterDashboardCacheEntry) {
|
||||
repeaterDashboardCache.set(publicKey, {
|
||||
loggedIn: entry.loggedIn,
|
||||
loginError: entry.loginError,
|
||||
lastLoginAttempt: entry.lastLoginAttempt,
|
||||
paneData: clonePaneData(entry.paneData),
|
||||
paneStates: normalizePaneStates(entry.paneStates),
|
||||
consoleHistory: cloneConsoleHistory(entry.consoleHistory),
|
||||
@@ -173,6 +181,7 @@ export interface UseRepeaterDashboardResult {
|
||||
loggedIn: boolean;
|
||||
loginLoading: boolean;
|
||||
loginError: string | null;
|
||||
lastLoginAttempt: ServerLoginAttemptState | null;
|
||||
paneData: PaneData;
|
||||
paneStates: Record<PaneName, PaneState>;
|
||||
consoleHistory: ConsoleEntry[];
|
||||
@@ -203,6 +212,9 @@ export function useRepeaterDashboard(
|
||||
const [loggedIn, setLoggedIn] = useState(cachedState?.loggedIn ?? false);
|
||||
const [loginLoading, setLoginLoading] = useState(false);
|
||||
const [loginError, setLoginError] = useState<string | null>(cachedState?.loginError ?? null);
|
||||
const [lastLoginAttempt, setLastLoginAttempt] = useState<ServerLoginAttemptState | null>(
|
||||
cachedState?.lastLoginAttempt ?? null
|
||||
);
|
||||
|
||||
const [paneData, setPaneData] = useState<PaneData>(
|
||||
cachedState?.paneData ?? createInitialPaneData
|
||||
@@ -243,11 +255,20 @@ export function useRepeaterDashboard(
|
||||
cacheState(conversationId, {
|
||||
loggedIn,
|
||||
loginError,
|
||||
lastLoginAttempt,
|
||||
paneData,
|
||||
paneStates,
|
||||
consoleHistory,
|
||||
});
|
||||
}, [consoleHistory, conversationId, loggedIn, loginError, paneData, paneStates]);
|
||||
}, [
|
||||
consoleHistory,
|
||||
conversationId,
|
||||
loggedIn,
|
||||
loginError,
|
||||
lastLoginAttempt,
|
||||
paneData,
|
||||
paneStates,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
paneDataRef.current = paneData;
|
||||
@@ -267,12 +288,14 @@ export function useRepeaterDashboard(
|
||||
const publicKey = getPublicKey();
|
||||
if (!publicKey) return;
|
||||
const conversationId = publicKey;
|
||||
const method = password.trim().length > 0 ? 'password' : 'blank';
|
||||
|
||||
setLoginLoading(true);
|
||||
setLoginError(null);
|
||||
try {
|
||||
const result = await api.repeaterLogin(publicKey, password);
|
||||
if (activeIdRef.current !== conversationId) return;
|
||||
setLastLoginAttempt(buildServerLoginAttemptFromResponse(method, result, 'repeater'));
|
||||
setLoggedIn(true);
|
||||
if (!result.authenticated) {
|
||||
const msg = result.message ?? 'Repeater login was not confirmed';
|
||||
@@ -282,6 +305,7 @@ export function useRepeaterDashboard(
|
||||
} catch (err) {
|
||||
if (activeIdRef.current !== conversationId) return;
|
||||
const msg = err instanceof Error ? err.message : 'Login failed';
|
||||
setLastLoginAttempt(buildServerLoginAttemptFromError(method, msg, 'repeater'));
|
||||
setLoggedIn(true);
|
||||
setLoginError(msg);
|
||||
toast.error('Login request failed', {
|
||||
@@ -475,6 +499,7 @@ export function useRepeaterDashboard(
|
||||
loggedIn,
|
||||
loginLoading,
|
||||
loginError,
|
||||
lastLoginAttempt,
|
||||
paneData,
|
||||
paneStates,
|
||||
consoleHistory,
|
||||
|
||||
@@ -150,7 +150,7 @@ describe('ContactInfoPane', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('loads name-only channel stats and most active rooms', async () => {
|
||||
it('loads name-only channel stats and most active channels', async () => {
|
||||
getContactAnalytics.mockResolvedValue(
|
||||
createAnalytics(null, {
|
||||
lookup_type: 'name',
|
||||
@@ -188,7 +188,7 @@ describe('ContactInfoPane', () => {
|
||||
expect(screen.getByText('Name First In Use')).toBeInTheDocument();
|
||||
expect(screen.getByText('Messages Per Hour')).toBeInTheDocument();
|
||||
expect(screen.getByText('Messages Per Week')).toBeInTheDocument();
|
||||
expect(screen.getByText('Most Active Rooms')).toBeInTheDocument();
|
||||
expect(screen.getByText('Most Active Channels')).toBeInTheDocument();
|
||||
expect(screen.getByText('#ops')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText(/Name-only analytics include channel messages only/i)
|
||||
|
||||
@@ -12,6 +12,7 @@ vi.mock('../api', () => ({
|
||||
deleteFanoutConfig: vi.fn(),
|
||||
getChannels: vi.fn(),
|
||||
getContacts: vi.fn(),
|
||||
getRadioConfig: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -67,44 +68,110 @@ function renderSectionWithRefresh(
|
||||
);
|
||||
}
|
||||
|
||||
function startsWithAccessibleName(name: string) {
|
||||
const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
return new RegExp(`^${escaped}(?:\\s|$)`);
|
||||
}
|
||||
|
||||
async function openCreateIntegrationDialog() {
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('button', { name: 'Add Integration' })).toBeInTheDocument();
|
||||
});
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Add Integration' }));
|
||||
return screen.findByRole('dialog', { name: 'Create Integration' });
|
||||
}
|
||||
|
||||
function selectCreateIntegration(name: string) {
|
||||
const dialog = screen.getByRole('dialog', { name: 'Create Integration' });
|
||||
fireEvent.click(within(dialog).getByRole('button', { name: startsWithAccessibleName(name) }));
|
||||
}
|
||||
|
||||
function confirmCreateIntegration() {
|
||||
const dialog = screen.getByRole('dialog', { name: 'Create Integration' });
|
||||
fireEvent.click(within(dialog).getByRole('button', { name: 'Create' }));
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.spyOn(window, 'confirm').mockReturnValue(true);
|
||||
mockedApi.getFanoutConfigs.mockResolvedValue([]);
|
||||
mockedApi.getChannels.mockResolvedValue([]);
|
||||
mockedApi.getContacts.mockResolvedValue([]);
|
||||
mockedApi.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 },
|
||||
path_hash_mode: 0,
|
||||
path_hash_mode_supported: false,
|
||||
});
|
||||
});
|
||||
|
||||
describe('SettingsFanoutSection', () => {
|
||||
it('shows add integration menu with all integration types', async () => {
|
||||
it('shows add integration dialog with all integration types', async () => {
|
||||
renderSection();
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('button', { name: 'Add Integration' })).toBeInTheDocument();
|
||||
});
|
||||
const dialog = await openCreateIntegrationDialog();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Add Integration' }));
|
||||
|
||||
expect(screen.getByRole('menuitem', { name: 'Private MQTT' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('menuitem', { name: 'MeshRank' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('menuitem', { name: 'LetsMesh (US)' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('menuitem', { name: 'LetsMesh (EU)' })).toBeInTheDocument();
|
||||
const optionButtons = within(dialog)
|
||||
.getAllByRole('button')
|
||||
.filter((button) => button.hasAttribute('aria-pressed'));
|
||||
expect(optionButtons).toHaveLength(10);
|
||||
expect(within(dialog).getByRole('button', { name: 'Close' })).toBeInTheDocument();
|
||||
expect(within(dialog).getByRole('button', { name: 'Create' })).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole('menuitem', { name: 'Community MQTT/meshcoretomqtt' })
|
||||
within(dialog).getByRole('button', { name: startsWithAccessibleName('Private MQTT') })
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByRole('menuitem', { name: 'Webhook' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('menuitem', { name: 'Apprise' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('menuitem', { name: 'Amazon SQS' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('menuitem', { name: 'Bot' })).toBeInTheDocument();
|
||||
expect(
|
||||
within(dialog).getByRole('button', { name: startsWithAccessibleName('MeshRank') })
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
within(dialog).getByRole('button', { name: startsWithAccessibleName('LetsMesh (US)') })
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
within(dialog).getByRole('button', { name: startsWithAccessibleName('LetsMesh (EU)') })
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
within(dialog).getByRole('button', {
|
||||
name: startsWithAccessibleName('Community MQTT/meshcoretomqtt'),
|
||||
})
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
within(dialog).getByRole('button', { name: startsWithAccessibleName('Webhook') })
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
within(dialog).getByRole('button', { name: startsWithAccessibleName('Apprise') })
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
within(dialog).getByRole('button', { name: startsWithAccessibleName('Amazon SQS') })
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
within(dialog).getByRole('button', { name: startsWithAccessibleName('Python Bot') })
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
within(dialog).getByRole('button', { name: startsWithAccessibleName('Map Upload') })
|
||||
).toBeInTheDocument();
|
||||
expect(within(dialog).getByRole('heading', { level: 3 })).toBeInTheDocument();
|
||||
|
||||
const genericCommunityIndex = optionButtons.findIndex((button) =>
|
||||
button.textContent?.startsWith('Community MQTT/meshcoretomqtt')
|
||||
);
|
||||
const meshRankIndex = optionButtons.findIndex((button) =>
|
||||
button.textContent?.startsWith('MeshRank')
|
||||
);
|
||||
expect(genericCommunityIndex).toBeGreaterThan(-1);
|
||||
expect(meshRankIndex).toBeGreaterThan(-1);
|
||||
expect(genericCommunityIndex).toBeLessThan(meshRankIndex);
|
||||
});
|
||||
|
||||
it('shows bot option in add integration menu when bots are enabled', async () => {
|
||||
it('shows bot option in add integration dialog when bots are enabled', async () => {
|
||||
renderSection();
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('button', { name: 'Add Integration' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Add Integration' }));
|
||||
expect(screen.getByRole('menuitem', { name: 'Bot' })).toBeInTheDocument();
|
||||
const dialog = await openCreateIntegrationDialog();
|
||||
expect(
|
||||
within(dialog).getByRole('button', { name: startsWithAccessibleName('Python Bot') })
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows bots disabled banner when bots_disabled', async () => {
|
||||
@@ -123,14 +190,12 @@ describe('SettingsFanoutSection', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('hides bot option from add integration menu when bots_disabled', async () => {
|
||||
it('hides bot option from add integration dialog when bots_disabled', async () => {
|
||||
renderSection({ health: { ...baseHealth, bots_disabled: true } });
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('button', { name: 'Add Integration' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Add Integration' }));
|
||||
expect(screen.queryByRole('menuitem', { name: 'Bot' })).not.toBeInTheDocument();
|
||||
const dialog = await openCreateIntegrationDialog();
|
||||
expect(
|
||||
within(dialog).queryByRole('button', { name: startsWithAccessibleName('Python Bot') })
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('lists existing configs after load', async () => {
|
||||
@@ -305,12 +370,9 @@ describe('SettingsFanoutSection', () => {
|
||||
|
||||
it('navigates to create view when clicking add button', async () => {
|
||||
renderSection();
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('button', { name: 'Add Integration' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Add Integration' }));
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: 'Webhook' }));
|
||||
await openCreateIntegrationDialog();
|
||||
selectCreateIntegration('Webhook');
|
||||
confirmCreateIntegration();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('← Back to list')).toBeInTheDocument();
|
||||
@@ -324,12 +386,9 @@ describe('SettingsFanoutSection', () => {
|
||||
|
||||
it('new SQS draft shows queue url fields and sensible defaults', async () => {
|
||||
renderSection();
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('button', { name: 'Add Integration' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Add Integration' }));
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: 'Amazon SQS' }));
|
||||
await openCreateIntegrationDialog();
|
||||
selectCreateIntegration('Amazon SQS');
|
||||
confirmCreateIntegration();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('← Back to list')).toBeInTheDocument();
|
||||
@@ -341,12 +400,9 @@ describe('SettingsFanoutSection', () => {
|
||||
|
||||
it('backing out of a new draft does not create an integration', async () => {
|
||||
renderSection();
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('button', { name: 'Add Integration' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Add Integration' }));
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: 'Webhook' }));
|
||||
await openCreateIntegrationDialog();
|
||||
selectCreateIntegration('Webhook');
|
||||
confirmCreateIntegration();
|
||||
await waitFor(() => expect(screen.getByText('← Back to list')).toBeInTheDocument());
|
||||
|
||||
fireEvent.click(screen.getByText('← Back to list'));
|
||||
@@ -420,12 +476,9 @@ describe('SettingsFanoutSection', () => {
|
||||
mockedApi.getFanoutConfigs.mockResolvedValueOnce([]).mockResolvedValueOnce([createdWebhook]);
|
||||
|
||||
renderSection();
|
||||
await waitFor(() =>
|
||||
expect(screen.getByRole('button', { name: 'Add Integration' })).toBeInTheDocument()
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Add Integration' }));
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: 'Webhook' }));
|
||||
await openCreateIntegrationDialog();
|
||||
selectCreateIntegration('Webhook');
|
||||
confirmCreateIntegration();
|
||||
await waitFor(() => expect(screen.getByText('← Back to list')).toBeInTheDocument());
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Save as Disabled' }));
|
||||
@@ -453,8 +506,9 @@ describe('SettingsFanoutSection', () => {
|
||||
renderSection();
|
||||
await waitFor(() => expect(screen.getByText('Test Hook')).toBeInTheDocument());
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Add Integration' }));
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: 'Webhook' }));
|
||||
await openCreateIntegrationDialog();
|
||||
selectCreateIntegration('Webhook');
|
||||
confirmCreateIntegration();
|
||||
await waitFor(() => expect(screen.getByLabelText('Name')).toHaveValue('Webhook #3'));
|
||||
});
|
||||
|
||||
@@ -656,21 +710,21 @@ describe('SettingsFanoutSection', () => {
|
||||
mockedApi.getFanoutConfigs.mockResolvedValue([communityConfig]);
|
||||
renderSection();
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText('Broker: mqtt-us-v1.letsmesh.net:443')).toBeInTheDocument()
|
||||
);
|
||||
expect(screen.getByText('mesh2mqtt/{IATA}/node/{PUBLIC_KEY}')).toBeInTheDocument();
|
||||
const group = await screen.findByRole('group', { name: 'Integration Community Feed' });
|
||||
expect(
|
||||
within(group).getByText(
|
||||
(_, element) => element?.textContent === 'Broker: mqtt-us-v1.letsmesh.net:443'
|
||||
)
|
||||
).toBeInTheDocument();
|
||||
expect(within(group).getByText('mesh2mqtt/{IATA}/node/{PUBLIC_KEY}')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Region: LAX')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('MeshRank preset pre-fills the broker settings and asks for the topic template', async () => {
|
||||
renderSection();
|
||||
await waitFor(() =>
|
||||
expect(screen.getByRole('button', { name: 'Add Integration' })).toBeInTheDocument()
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Add Integration' }));
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: 'MeshRank' }));
|
||||
await openCreateIntegrationDialog();
|
||||
selectCreateIntegration('MeshRank');
|
||||
confirmCreateIntegration();
|
||||
|
||||
await waitFor(() => expect(screen.getByText('← Back to list')).toBeInTheDocument());
|
||||
|
||||
@@ -707,12 +761,9 @@ describe('SettingsFanoutSection', () => {
|
||||
mockedApi.getFanoutConfigs.mockResolvedValueOnce([]).mockResolvedValueOnce([createdConfig]);
|
||||
|
||||
renderSection();
|
||||
await waitFor(() =>
|
||||
expect(screen.getByRole('button', { name: 'Add Integration' })).toBeInTheDocument()
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Add Integration' }));
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: 'MeshRank' }));
|
||||
await openCreateIntegrationDialog();
|
||||
selectCreateIntegration('MeshRank');
|
||||
confirmCreateIntegration();
|
||||
await waitFor(() => expect(screen.getByText('← Back to list')).toBeInTheDocument());
|
||||
|
||||
fireEvent.change(screen.getByLabelText('Packet Topic Template'), {
|
||||
@@ -774,12 +825,9 @@ describe('SettingsFanoutSection', () => {
|
||||
mockedApi.getFanoutConfigs.mockResolvedValueOnce([]).mockResolvedValueOnce([createdConfig]);
|
||||
|
||||
renderSection();
|
||||
await waitFor(() =>
|
||||
expect(screen.getByRole('button', { name: 'Add Integration' })).toBeInTheDocument()
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Add Integration' }));
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: 'LetsMesh (US)' }));
|
||||
await openCreateIntegrationDialog();
|
||||
selectCreateIntegration('LetsMesh (US)');
|
||||
confirmCreateIntegration();
|
||||
await waitFor(() => expect(screen.getByText('← Back to list')).toBeInTheDocument());
|
||||
|
||||
expect(screen.getByLabelText('Name')).toHaveValue('LetsMesh (US)');
|
||||
@@ -842,12 +890,9 @@ describe('SettingsFanoutSection', () => {
|
||||
mockedApi.getFanoutConfigs.mockResolvedValueOnce([]).mockResolvedValueOnce([createdConfig]);
|
||||
|
||||
renderSection();
|
||||
await waitFor(() =>
|
||||
expect(screen.getByRole('button', { name: 'Add Integration' })).toBeInTheDocument()
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Add Integration' }));
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: 'LetsMesh (EU)' }));
|
||||
await openCreateIntegrationDialog();
|
||||
selectCreateIntegration('LetsMesh (EU)');
|
||||
confirmCreateIntegration();
|
||||
await waitFor(() => expect(screen.getByText('← Back to list')).toBeInTheDocument());
|
||||
|
||||
fireEvent.change(screen.getByLabelText('Email'), { target: { value: 'user@example.com' } });
|
||||
@@ -880,16 +925,13 @@ describe('SettingsFanoutSection', () => {
|
||||
|
||||
it('generic Community MQTT entry still opens the full editor', async () => {
|
||||
renderSection();
|
||||
await waitFor(() =>
|
||||
expect(screen.getByRole('button', { name: 'Add Integration' })).toBeInTheDocument()
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Add Integration' }));
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: 'Community MQTT/meshcoretomqtt' }));
|
||||
await openCreateIntegrationDialog();
|
||||
selectCreateIntegration('Community MQTT/meshcoretomqtt');
|
||||
confirmCreateIntegration();
|
||||
|
||||
await waitFor(() => expect(screen.getByText('← Back to list')).toBeInTheDocument());
|
||||
|
||||
expect(screen.getByLabelText('Name')).toHaveValue('Community MQTT #1');
|
||||
expect(screen.getByLabelText('Name')).toHaveValue('Community Sharing #1');
|
||||
expect(screen.getByLabelText('Broker Host')).toBeInTheDocument();
|
||||
expect(screen.getByLabelText('Authentication')).toBeInTheDocument();
|
||||
expect(screen.getByLabelText('Packet Topic Template')).toBeInTheDocument();
|
||||
@@ -909,9 +951,12 @@ describe('SettingsFanoutSection', () => {
|
||||
mockedApi.getFanoutConfigs.mockResolvedValue([privateConfig]);
|
||||
renderSection();
|
||||
|
||||
await waitFor(() => expect(screen.getByText('Broker: broker.local:1883')).toBeInTheDocument());
|
||||
const group = await screen.findByRole('group', { name: 'Integration Private Broker' });
|
||||
expect(
|
||||
screen.getByText('meshcore/dm:<pubkey>, meshcore/gm:<channel>, meshcore/raw/...')
|
||||
within(group).getByText((_, element) => element?.textContent === 'Broker: broker.local:1883')
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
within(group).getByText('meshcore/dm:<pubkey>, meshcore/gm:<channel>, meshcore/raw/...')
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -929,7 +974,8 @@ describe('SettingsFanoutSection', () => {
|
||||
mockedApi.getFanoutConfigs.mockResolvedValue([config]);
|
||||
renderSection();
|
||||
|
||||
await waitFor(() => expect(screen.getByText('https://example.com/hook')).toBeInTheDocument());
|
||||
const group = await screen.findByRole('group', { name: 'Integration Webhook Feed' });
|
||||
expect(within(group).getByText('https://example.com/hook')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('apprise list shows compact target summary', async () => {
|
||||
@@ -950,9 +996,10 @@ describe('SettingsFanoutSection', () => {
|
||||
mockedApi.getFanoutConfigs.mockResolvedValue([config]);
|
||||
renderSection();
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText(/discord:\/\/abc, mailto:\/\/one@example.com/)).toBeInTheDocument()
|
||||
);
|
||||
const group = await screen.findByRole('group', { name: 'Integration Apprise Feed' });
|
||||
expect(
|
||||
within(group).getByText(/discord:\/\/abc, mailto:\/\/one@example.com/)
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('sqs list shows queue url summary', async () => {
|
||||
@@ -972,11 +1019,10 @@ describe('SettingsFanoutSection', () => {
|
||||
mockedApi.getFanoutConfigs.mockResolvedValue([config]);
|
||||
renderSection();
|
||||
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
screen.getByText('https://sqs.us-east-1.amazonaws.com/123456789012/mesh-events')
|
||||
).toBeInTheDocument()
|
||||
);
|
||||
const group = await screen.findByRole('group', { name: 'Integration Queue Feed' });
|
||||
expect(
|
||||
within(group).getByText('https://sqs.us-east-1.amazonaws.com/123456789012/mesh-events')
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('groups integrations by type and sorts entries alphabetically within each group', async () => {
|
||||
|
||||
@@ -10,7 +10,6 @@ import userEvent from '@testing-library/user-event';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
import { NewMessageModal } from '../components/NewMessageModal';
|
||||
import type { Contact } from '../types';
|
||||
import { toast } from '../components/ui/sonner';
|
||||
|
||||
// Mock sonner (toast)
|
||||
@@ -18,24 +17,6 @@ vi.mock('../components/ui/sonner', () => ({
|
||||
toast: { success: vi.fn(), error: vi.fn() },
|
||||
}));
|
||||
|
||||
const mockContact: Contact = {
|
||||
public_key: 'aa'.repeat(32),
|
||||
name: 'Alice',
|
||||
type: 1,
|
||||
flags: 0,
|
||||
direct_path: null,
|
||||
direct_path_len: -1,
|
||||
direct_path_hash_mode: 0,
|
||||
last_advert: null,
|
||||
lat: null,
|
||||
lon: null,
|
||||
last_seen: null,
|
||||
on_radio: false,
|
||||
last_contacted: null,
|
||||
last_read_at: null,
|
||||
first_seen: null,
|
||||
};
|
||||
|
||||
const mockToast = toast as unknown as {
|
||||
success: ReturnType<typeof vi.fn>;
|
||||
error: ReturnType<typeof vi.fn>;
|
||||
@@ -43,7 +24,6 @@ const mockToast = toast as unknown as {
|
||||
|
||||
describe('NewMessageModal form reset', () => {
|
||||
const onClose = vi.fn();
|
||||
const onSelectConversation = vi.fn();
|
||||
const onCreateContact = vi.fn().mockResolvedValue(undefined);
|
||||
const onCreateChannel = vi.fn().mockResolvedValue(undefined);
|
||||
const onCreateHashtagChannel = vi.fn().mockResolvedValue(undefined);
|
||||
@@ -56,10 +36,8 @@ describe('NewMessageModal form reset', () => {
|
||||
return render(
|
||||
<NewMessageModal
|
||||
open={open}
|
||||
contacts={[mockContact]}
|
||||
undecryptedCount={5}
|
||||
onClose={onClose}
|
||||
onSelectConversation={onSelectConversation}
|
||||
onCreateContact={onCreateContact}
|
||||
onCreateChannel={onCreateChannel}
|
||||
onCreateHashtagChannel={onCreateHashtagChannel}
|
||||
@@ -75,7 +53,7 @@ describe('NewMessageModal form reset', () => {
|
||||
it('clears name after successful Create', async () => {
|
||||
const user = userEvent.setup();
|
||||
const { unmount } = renderModal();
|
||||
await switchToTab(user, 'Hashtag');
|
||||
await switchToTab(user, 'Hashtag Channel');
|
||||
|
||||
const input = screen.getByPlaceholderText('channel-name') as HTMLInputElement;
|
||||
await user.type(input, 'testchan');
|
||||
@@ -91,14 +69,14 @@ describe('NewMessageModal form reset', () => {
|
||||
|
||||
// Re-render to simulate reopening — state should be reset
|
||||
renderModal();
|
||||
await switchToTab(user, 'Hashtag');
|
||||
await switchToTab(user, 'Hashtag Channel');
|
||||
expect((screen.getByPlaceholderText('channel-name') as HTMLInputElement).value).toBe('');
|
||||
});
|
||||
|
||||
it('clears name when Cancel is clicked', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderModal();
|
||||
await switchToTab(user, 'Hashtag');
|
||||
await switchToTab(user, 'Hashtag Channel');
|
||||
|
||||
const input = screen.getByPlaceholderText('channel-name') as HTMLInputElement;
|
||||
await user.type(input, 'mychannel');
|
||||
@@ -127,13 +105,13 @@ describe('NewMessageModal form reset', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('new-room tab', () => {
|
||||
describe('new-channel tab', () => {
|
||||
it('clears name and key after successful Create', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderModal();
|
||||
await switchToTab(user, 'Room');
|
||||
await switchToTab(user, 'Private Channel');
|
||||
|
||||
await user.type(screen.getByPlaceholderText('Room name'), 'MyRoom');
|
||||
await user.type(screen.getByPlaceholderText('Channel name'), 'MyRoom');
|
||||
await user.type(screen.getByPlaceholderText('Pre-shared key (hex)'), 'cc'.repeat(16));
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Create' }));
|
||||
@@ -148,9 +126,9 @@ describe('NewMessageModal form reset', () => {
|
||||
const user = userEvent.setup();
|
||||
onCreateChannel.mockRejectedValueOnce(new Error('Bad key'));
|
||||
renderModal();
|
||||
await switchToTab(user, 'Room');
|
||||
await switchToTab(user, 'Private Channel');
|
||||
|
||||
await user.type(screen.getByPlaceholderText('Room name'), 'MyRoom');
|
||||
await user.type(screen.getByPlaceholderText('Channel name'), 'MyRoom');
|
||||
await user.type(screen.getByPlaceholderText('Pre-shared key (hex)'), 'cc'.repeat(16));
|
||||
await user.click(screen.getByRole('button', { name: 'Create' }));
|
||||
|
||||
@@ -164,7 +142,7 @@ describe('NewMessageModal form reset', () => {
|
||||
});
|
||||
|
||||
describe('tab switching resets form', () => {
|
||||
it('clears contact fields when switching to room tab', async () => {
|
||||
it('clears contact fields when switching to channel tab', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderModal();
|
||||
await switchToTab(user, 'Contact');
|
||||
@@ -172,24 +150,24 @@ describe('NewMessageModal form reset', () => {
|
||||
await user.type(screen.getByPlaceholderText('Contact name'), 'Bob');
|
||||
await user.type(screen.getByPlaceholderText('64-character hex public key'), 'deadbeef');
|
||||
|
||||
// Switch to Room tab — fields should reset
|
||||
await switchToTab(user, 'Room');
|
||||
// Switch to Private Channel tab — fields should reset
|
||||
await switchToTab(user, 'Private Channel');
|
||||
|
||||
expect((screen.getByPlaceholderText('Room name') as HTMLInputElement).value).toBe('');
|
||||
expect((screen.getByPlaceholderText('Channel name') as HTMLInputElement).value).toBe('');
|
||||
expect((screen.getByPlaceholderText('Pre-shared key (hex)') as HTMLInputElement).value).toBe(
|
||||
''
|
||||
);
|
||||
});
|
||||
|
||||
it('clears room fields when switching to hashtag tab', async () => {
|
||||
it('clears channel fields when switching to hashtag tab', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderModal();
|
||||
await switchToTab(user, 'Room');
|
||||
await switchToTab(user, 'Private Channel');
|
||||
|
||||
await user.type(screen.getByPlaceholderText('Room name'), 'SecretRoom');
|
||||
await user.type(screen.getByPlaceholderText('Channel name'), 'SecretRoom');
|
||||
await user.type(screen.getByPlaceholderText('Pre-shared key (hex)'), 'ff'.repeat(16));
|
||||
|
||||
await switchToTab(user, 'Hashtag');
|
||||
await switchToTab(user, 'Hashtag Channel');
|
||||
|
||||
expect((screen.getByPlaceholderText('channel-name') as HTMLInputElement).value).toBe('');
|
||||
});
|
||||
@@ -199,7 +177,7 @@ describe('NewMessageModal form reset', () => {
|
||||
it('resets tryHistorical when switching tabs', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderModal();
|
||||
await switchToTab(user, 'Hashtag');
|
||||
await switchToTab(user, 'Hashtag Channel');
|
||||
|
||||
// Check the "Try decrypting" checkbox
|
||||
const checkbox = screen.getByRole('checkbox', { name: /Try decrypting/ });
|
||||
@@ -210,7 +188,7 @@ describe('NewMessageModal form reset', () => {
|
||||
|
||||
// Switch tab and come back
|
||||
await switchToTab(user, 'Contact');
|
||||
await switchToTab(user, 'Hashtag');
|
||||
await switchToTab(user, 'Hashtag Channel');
|
||||
|
||||
// The streaming message should be gone (tryHistorical was reset)
|
||||
expect(screen.queryByText(/Messages will stream in/)).toBeNull();
|
||||
|
||||
@@ -361,7 +361,7 @@ describe('RawPacketFeedView', () => {
|
||||
expect(screen.queryByText('Identity not resolvable')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('opens a packet detail modal from the raw feed and decrypts room messages when a key is loaded', () => {
|
||||
it('opens a packet detail modal from the raw feed and decrypts channel messages when a key is loaded', () => {
|
||||
renderView({
|
||||
packets: [
|
||||
{
|
||||
@@ -392,7 +392,7 @@ describe('RawPacketFeedView', () => {
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not guess a room name when multiple loaded channels collide on the group hash', () => {
|
||||
it('does not guess a channel name when multiple loaded channels collide on the group hash', () => {
|
||||
renderView({
|
||||
packets: [
|
||||
{
|
||||
|
||||
@@ -11,6 +11,7 @@ const mockHook: {
|
||||
loggedIn: false,
|
||||
loginLoading: false,
|
||||
loginError: null,
|
||||
lastLoginAttempt: null,
|
||||
paneData: {
|
||||
status: null,
|
||||
nodeInfo: null,
|
||||
|
||||
@@ -56,22 +56,84 @@ describe('RoomServerPanel', () => {
|
||||
status: 'timeout',
|
||||
authenticated: false,
|
||||
message:
|
||||
'No login confirmation was heard from the room server. The control panel is still available; try logging in again if authenticated actions fail.',
|
||||
"No login confirmation was heard from the room server. You're free to try sending messages; try logging in again if authenticated actions fail.",
|
||||
});
|
||||
const onAuthenticatedChange = vi.fn();
|
||||
|
||||
render(<RoomServerPanel contact={roomContact} onAuthenticatedChange={onAuthenticatedChange} />);
|
||||
|
||||
fireEvent.click(screen.getByText('Login with ACL / Guest'));
|
||||
fireEvent.click(screen.getByText('Login with Existing Access / Guest'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Show Tools')).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.getByText('Show Tools')).toBeInTheDocument();
|
||||
expect(mockToast.warning).toHaveBeenCalledWith('Room login not confirmed', {
|
||||
expect(screen.getByText('Retry Existing-Access Login')).toBeInTheDocument();
|
||||
expect(mockToast.warning).toHaveBeenCalledWith("Couldn't confirm room login", {
|
||||
description:
|
||||
'No login confirmation was heard from the room server. The control panel is still available; try logging in again if authenticated actions fail.',
|
||||
"No login confirmation was heard from the room server. You're free to try sending messages; try logging in again if authenticated actions fail.",
|
||||
});
|
||||
expect(onAuthenticatedChange).toHaveBeenLastCalledWith(true);
|
||||
});
|
||||
|
||||
it('retains the last password for one-click retry after unlocking the panel', async () => {
|
||||
mockApi.roomLogin
|
||||
.mockResolvedValueOnce({
|
||||
status: 'timeout',
|
||||
authenticated: false,
|
||||
message: 'No reply heard',
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
status: 'ok',
|
||||
authenticated: true,
|
||||
message: null,
|
||||
});
|
||||
|
||||
render(<RoomServerPanel contact={roomContact} />);
|
||||
|
||||
fireEvent.change(screen.getByLabelText('Repeater password'), {
|
||||
target: { value: 'secret-room-password' },
|
||||
});
|
||||
fireEvent.click(screen.getByText('Login with Password'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Retry Password Login')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByText('Retry Password Login'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockApi.roomLogin).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
roomContact.public_key,
|
||||
'secret-room-password'
|
||||
);
|
||||
expect(mockApi.roomLogin).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
roomContact.public_key,
|
||||
'secret-room-password'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('shows only a success toast after a confirmed login', async () => {
|
||||
mockApi.roomLogin.mockResolvedValueOnce({
|
||||
status: 'ok',
|
||||
authenticated: true,
|
||||
message: null,
|
||||
});
|
||||
|
||||
render(<RoomServerPanel contact={roomContact} />);
|
||||
|
||||
fireEvent.click(screen.getByText('Login with Existing Access / Guest'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Show Tools')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(screen.queryByText('Login confirmed by the room server.')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Retry Password Login')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Retry Existing-Access Login')).not.toBeInTheDocument();
|
||||
expect(mockToast.success).toHaveBeenCalledWith('Login confirmed by the room server.');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
import { act, renderHook } from '@testing-library/react';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { useBrowserNotifications } from '../hooks/useBrowserNotifications';
|
||||
import {
|
||||
getNotificationEnableToastInfo,
|
||||
useBrowserNotifications,
|
||||
} from '../hooks/useBrowserNotifications';
|
||||
import type { Message } from '../types';
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
toast: {
|
||||
success: vi.fn(),
|
||||
warning: vi.fn(),
|
||||
error: vi.fn(),
|
||||
},
|
||||
}));
|
||||
@@ -57,6 +61,10 @@ describe('useBrowserNotifications', () => {
|
||||
configurable: true,
|
||||
value: NotificationMock,
|
||||
});
|
||||
Object.defineProperty(window, 'isSecureContext', {
|
||||
configurable: true,
|
||||
value: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('stores notification opt-in per conversation', async () => {
|
||||
@@ -84,6 +92,10 @@ describe('useBrowserNotifications', () => {
|
||||
icon: '/favicon-256x256.png',
|
||||
tag: `meshcore-notification-preview-channel-${incomingChannelMessage.conversation_key}`,
|
||||
});
|
||||
expect(mocks.toast.warning).toHaveBeenCalledWith('Notifications enabled with warning', {
|
||||
description:
|
||||
'Desktop notifications are on for this conversation, but you are using HTTP instead of HTTPS. Notifications will likely not work reliably.',
|
||||
});
|
||||
});
|
||||
|
||||
it('only sends desktop notifications for opted-in conversations', async () => {
|
||||
@@ -164,9 +176,65 @@ describe('useBrowserNotifications', () => {
|
||||
);
|
||||
});
|
||||
|
||||
expect(mocks.toast.error).toHaveBeenCalledWith('Browser notifications blocked', {
|
||||
expect(mocks.toast.error).toHaveBeenCalledWith('Notifications blocked', {
|
||||
description:
|
||||
'Allow notifications in your browser settings, then try again. Some browsers may refuse notifications on non-HTTPS or self-signed HTTPS origins. Check your browser documentation for how to trust an insecure origin and the associated risks before doing so.',
|
||||
'Desktop notifications are blocked by your browser. Allow notifications in browser settings, then try again. Non-HTTPS or untrusted HTTPS origins may also prevent notifications from working reliably.',
|
||||
});
|
||||
});
|
||||
|
||||
it('shows a warning toast when notifications are enabled on HTTP', async () => {
|
||||
const { result } = renderHook(() => useBrowserNotifications());
|
||||
|
||||
await act(async () => {
|
||||
await result.current.toggleConversationNotifications(
|
||||
'channel',
|
||||
incomingChannelMessage.conversation_key,
|
||||
'#flightless'
|
||||
);
|
||||
});
|
||||
|
||||
expect(mocks.toast.warning).toHaveBeenCalledWith('Notifications enabled with warning', {
|
||||
description:
|
||||
'Desktop notifications are on for this conversation, but you are using HTTP instead of HTTPS. Notifications will likely not work reliably.',
|
||||
});
|
||||
expect(mocks.toast.success).not.toHaveBeenCalledWith('Notifications enabled');
|
||||
});
|
||||
|
||||
it('best-effort detects insecure HTTPS for the enable-warning copy', () => {
|
||||
expect(
|
||||
getNotificationEnableToastInfo({
|
||||
protocol: 'https:',
|
||||
isSecureContext: false,
|
||||
})
|
||||
).toEqual({
|
||||
level: 'warning',
|
||||
title: 'Notifications enabled with warning',
|
||||
description:
|
||||
'Desktop notifications are on for this conversation, but your HTTPS connection is untrusted, such as a self-signed certificate. Notification delivery may be inconsistent depending on your browser.',
|
||||
});
|
||||
});
|
||||
|
||||
it('shows a descriptive success toast when notifications are disabled', async () => {
|
||||
const { result } = renderHook(() => useBrowserNotifications());
|
||||
|
||||
await act(async () => {
|
||||
await result.current.toggleConversationNotifications(
|
||||
'channel',
|
||||
incomingChannelMessage.conversation_key,
|
||||
'#flightless'
|
||||
);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await result.current.toggleConversationNotifications(
|
||||
'channel',
|
||||
incomingChannelMessage.conversation_key,
|
||||
'#flightless'
|
||||
);
|
||||
});
|
||||
|
||||
expect(mocks.toast.success).toHaveBeenCalledWith('Notifications disabled', {
|
||||
description: 'Desktop notifications are off for #flightless.',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
255
frontend/src/test/useFaviconBadge.test.ts
Normal file
255
frontend/src/test/useFaviconBadge.test.ts
Normal file
@@ -0,0 +1,255 @@
|
||||
import { renderHook, waitFor } from '@testing-library/react';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import {
|
||||
buildBadgedFaviconSvg,
|
||||
deriveFaviconBadgeState,
|
||||
getFavoriteUnreadCount,
|
||||
getUnreadTitle,
|
||||
getTotalUnreadCount,
|
||||
useFaviconBadge,
|
||||
useUnreadTitle,
|
||||
} from '../hooks/useFaviconBadge';
|
||||
import type { Favorite } from '../types';
|
||||
import { getStateKey } from '../utils/conversationState';
|
||||
|
||||
function getIconHref(rel: 'icon' | 'shortcut icon'): string | null {
|
||||
return (
|
||||
document.head.querySelector<HTMLLinkElement>(`link[rel="${rel}"]`)?.getAttribute('href') ?? null
|
||||
);
|
||||
}
|
||||
|
||||
describe('useFaviconBadge', () => {
|
||||
const baseSvg =
|
||||
'<svg xmlns="http://www.w3.org/2000/svg"><rect width="1000" height="1000"/></svg>';
|
||||
const originalCreateObjectURL = URL.createObjectURL;
|
||||
const originalRevokeObjectURL = URL.revokeObjectURL;
|
||||
let objectUrlCounter = 0;
|
||||
let fetchMock: ReturnType<typeof vi.fn>;
|
||||
let createObjectURLMock: ReturnType<typeof vi.fn>;
|
||||
let revokeObjectURLMock: ReturnType<typeof vi.fn>;
|
||||
|
||||
beforeEach(() => {
|
||||
document.head.innerHTML = `
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<link rel="shortcut icon" href="/favicon.ico" />
|
||||
`;
|
||||
document.title = 'RemoteTerm for MeshCore';
|
||||
objectUrlCounter = 0;
|
||||
fetchMock = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
text: async () => baseSvg,
|
||||
});
|
||||
createObjectURLMock = vi.fn(() => `blob:generated-${++objectUrlCounter}`);
|
||||
revokeObjectURLMock = vi.fn();
|
||||
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
Object.defineProperty(URL, 'createObjectURL', {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
value: createObjectURLMock,
|
||||
});
|
||||
Object.defineProperty(URL, 'revokeObjectURL', {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
value: revokeObjectURLMock,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
Object.defineProperty(URL, 'createObjectURL', {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
value: originalCreateObjectURL,
|
||||
});
|
||||
Object.defineProperty(URL, 'revokeObjectURL', {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
value: originalRevokeObjectURL,
|
||||
});
|
||||
});
|
||||
|
||||
it('derives badge priority from unread counts, mentions, and favorites', () => {
|
||||
const favorites: Favorite[] = [{ type: 'channel', id: 'fav-chan' }];
|
||||
|
||||
expect(deriveFaviconBadgeState({}, {}, favorites)).toBe('none');
|
||||
expect(
|
||||
deriveFaviconBadgeState(
|
||||
{
|
||||
[getStateKey('channel', 'fav-chan')]: 3,
|
||||
},
|
||||
{},
|
||||
favorites
|
||||
)
|
||||
).toBe('green');
|
||||
expect(
|
||||
deriveFaviconBadgeState(
|
||||
{
|
||||
[getStateKey('contact', 'abc')]: 12,
|
||||
},
|
||||
{},
|
||||
favorites
|
||||
)
|
||||
).toBe('red');
|
||||
expect(
|
||||
deriveFaviconBadgeState(
|
||||
{
|
||||
[getStateKey('channel', 'fav-chan')]: 1,
|
||||
},
|
||||
{
|
||||
[getStateKey('channel', 'fav-chan')]: true,
|
||||
},
|
||||
favorites
|
||||
)
|
||||
).toBe('red');
|
||||
});
|
||||
|
||||
it('builds a dot-only badge into the base svg markup', () => {
|
||||
const svg = buildBadgedFaviconSvg(baseSvg, '#16a34a');
|
||||
|
||||
expect(svg).toContain('<circle cx="750" cy="750" r="220" fill="#ffffff"/>');
|
||||
expect(svg).toContain('<circle cx="750" cy="750" r="180" fill="#16a34a"/>');
|
||||
expect(svg).not.toContain('<text');
|
||||
});
|
||||
|
||||
it('derives the unread count and page title', () => {
|
||||
expect(getTotalUnreadCount({})).toBe(0);
|
||||
expect(getTotalUnreadCount({ a: 2, b: 5 })).toBe(7);
|
||||
expect(getFavoriteUnreadCount({}, [])).toBe(0);
|
||||
expect(
|
||||
getFavoriteUnreadCount(
|
||||
{
|
||||
[getStateKey('channel', 'fav-chan')]: 7,
|
||||
[getStateKey('contact', 'fav-contact')]: 3,
|
||||
[getStateKey('channel', 'other-chan')]: 9,
|
||||
},
|
||||
[
|
||||
{ type: 'channel', id: 'fav-chan' },
|
||||
{ type: 'contact', id: 'fav-contact' },
|
||||
]
|
||||
)
|
||||
).toBe(10);
|
||||
expect(getUnreadTitle({}, [])).toBe('RemoteTerm for MeshCore');
|
||||
expect(
|
||||
getUnreadTitle(
|
||||
{
|
||||
[getStateKey('channel', 'fav-chan')]: 7,
|
||||
[getStateKey('channel', 'other-chan')]: 9,
|
||||
},
|
||||
[{ type: 'channel', id: 'fav-chan' }]
|
||||
)
|
||||
).toBe('(7) RemoteTerm');
|
||||
expect(
|
||||
getUnreadTitle(
|
||||
{
|
||||
[getStateKey('channel', 'fav-chan')]: 120,
|
||||
},
|
||||
[{ type: 'channel', id: 'fav-chan' }]
|
||||
)
|
||||
).toBe('(99+) RemoteTerm');
|
||||
});
|
||||
|
||||
it('switches between the base favicon and generated blob badges', async () => {
|
||||
const favorites: Favorite[] = [{ type: 'channel', id: 'fav-chan' }];
|
||||
const { rerender } = renderHook(
|
||||
({
|
||||
unreadCounts,
|
||||
mentions,
|
||||
currentFavorites,
|
||||
}: {
|
||||
unreadCounts: Record<string, number>;
|
||||
mentions: Record<string, boolean>;
|
||||
currentFavorites: Favorite[];
|
||||
}) => useFaviconBadge(unreadCounts, mentions, currentFavorites),
|
||||
{
|
||||
initialProps: {
|
||||
unreadCounts: {},
|
||||
mentions: {},
|
||||
currentFavorites: favorites,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getIconHref('icon')).toBe('/favicon.svg');
|
||||
expect(getIconHref('shortcut icon')).toBe('/favicon.svg');
|
||||
});
|
||||
|
||||
rerender({
|
||||
unreadCounts: {
|
||||
[getStateKey('channel', 'fav-chan')]: 1,
|
||||
},
|
||||
mentions: {},
|
||||
currentFavorites: favorites,
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getIconHref('icon')).toBe('blob:generated-1');
|
||||
expect(getIconHref('shortcut icon')).toBe('blob:generated-1');
|
||||
});
|
||||
|
||||
rerender({
|
||||
unreadCounts: {
|
||||
[getStateKey('contact', 'dm-key')]: 12,
|
||||
},
|
||||
mentions: {},
|
||||
currentFavorites: favorites,
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getIconHref('icon')).toBe('blob:generated-2');
|
||||
expect(getIconHref('shortcut icon')).toBe('blob:generated-2');
|
||||
});
|
||||
|
||||
rerender({
|
||||
unreadCounts: {},
|
||||
mentions: {},
|
||||
currentFavorites: favorites,
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getIconHref('icon')).toBe('/favicon.svg');
|
||||
expect(getIconHref('shortcut icon')).toBe('/favicon.svg');
|
||||
});
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
expect(createObjectURLMock).toHaveBeenCalledTimes(2);
|
||||
expect(revokeObjectURLMock).toHaveBeenCalledWith('blob:generated-1');
|
||||
expect(revokeObjectURLMock).toHaveBeenCalledWith('blob:generated-2');
|
||||
});
|
||||
|
||||
it('writes unread counts into the page title', () => {
|
||||
const { rerender, unmount } = renderHook(
|
||||
({
|
||||
unreadCounts,
|
||||
favorites,
|
||||
}: {
|
||||
unreadCounts: Record<string, number>;
|
||||
favorites: Favorite[];
|
||||
}) => useUnreadTitle(unreadCounts, favorites),
|
||||
{
|
||||
initialProps: {
|
||||
unreadCounts: {},
|
||||
favorites: [{ type: 'channel', id: 'fav-chan' }],
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
expect(document.title).toBe('RemoteTerm for MeshCore');
|
||||
|
||||
rerender({
|
||||
unreadCounts: {
|
||||
[getStateKey('channel', 'fav-chan')]: 4,
|
||||
[getStateKey('contact', 'dm-key')]: 2,
|
||||
},
|
||||
favorites: [{ type: 'channel', id: 'fav-chan' }],
|
||||
});
|
||||
|
||||
expect(document.title).toBe('(4) RemoteTerm');
|
||||
|
||||
unmount();
|
||||
|
||||
expect(document.title).toBe('RemoteTerm for MeshCore');
|
||||
});
|
||||
});
|
||||
@@ -8,70 +8,24 @@ describe('useRememberedServerPassword', () => {
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
it('loads remembered passwords from localStorage', () => {
|
||||
localStorage.setItem(
|
||||
'remoteterm-server-password:repeater:abc123',
|
||||
JSON.stringify({ password: 'stored-secret' })
|
||||
it('restores the last in-memory password when local remember is disabled', () => {
|
||||
const { result, unmount } = renderHook(() =>
|
||||
useRememberedServerPassword('room', 'aa'.repeat(32))
|
||||
);
|
||||
|
||||
const { result } = renderHook(() => useRememberedServerPassword('repeater', 'abc123'));
|
||||
|
||||
expect(result.current.password).toBe('stored-secret');
|
||||
expect(result.current.rememberPassword).toBe(true);
|
||||
});
|
||||
|
||||
it('stores passwords after login when remember is enabled', () => {
|
||||
const { result } = renderHook(() => useRememberedServerPassword('room', 'room-key'));
|
||||
|
||||
act(() => {
|
||||
result.current.setRememberPassword(true);
|
||||
result.current.setPassword('room-secret');
|
||||
result.current.persistAfterLogin('room-secret');
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.persistAfterLogin(' hello ');
|
||||
});
|
||||
expect(result.current.password).toBe('room-secret');
|
||||
unmount();
|
||||
|
||||
expect(localStorage.getItem('remoteterm-server-password:room:room-key')).toBe(
|
||||
JSON.stringify({ password: 'hello' })
|
||||
);
|
||||
expect(result.current.password).toBe('hello');
|
||||
});
|
||||
|
||||
it('clears stored passwords when login is done with remember disabled', () => {
|
||||
localStorage.setItem(
|
||||
'remoteterm-server-password:repeater:abc123',
|
||||
JSON.stringify({ password: 'stored-secret' })
|
||||
const { result: remounted } = renderHook(() =>
|
||||
useRememberedServerPassword('room', 'aa'.repeat(32))
|
||||
);
|
||||
|
||||
const { result } = renderHook(() => useRememberedServerPassword('repeater', 'abc123'));
|
||||
|
||||
act(() => {
|
||||
result.current.setRememberPassword(false);
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.persistAfterLogin('new-secret');
|
||||
});
|
||||
|
||||
expect(localStorage.getItem('remoteterm-server-password:repeater:abc123')).toBeNull();
|
||||
expect(result.current.password).toBe('');
|
||||
});
|
||||
|
||||
it('preserves remembered passwords on guest login when remember stays enabled', () => {
|
||||
localStorage.setItem(
|
||||
'remoteterm-server-password:room:room-key',
|
||||
JSON.stringify({ password: 'stored-secret' })
|
||||
);
|
||||
|
||||
const { result } = renderHook(() => useRememberedServerPassword('room', 'room-key'));
|
||||
|
||||
act(() => {
|
||||
result.current.persistAfterLogin('');
|
||||
});
|
||||
|
||||
expect(localStorage.getItem('remoteterm-server-password:room:room-key')).toBe(
|
||||
JSON.stringify({ password: 'stored-secret' })
|
||||
);
|
||||
expect(result.current.password).toBe('stored-secret');
|
||||
expect(remounted.current.password).toBe('room-secret');
|
||||
expect(remounted.current.rememberPassword).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -74,6 +74,8 @@ describe('useRepeaterDashboard', () => {
|
||||
|
||||
expect(result.current.loggedIn).toBe(true);
|
||||
expect(result.current.loginError).toBe(null);
|
||||
expect(result.current.lastLoginAttempt?.heardBack).toBe(true);
|
||||
expect(result.current.lastLoginAttempt?.outcome).toBe('confirmed');
|
||||
expect(mockApi.repeaterLogin).toHaveBeenCalledWith(REPEATER_KEY, 'secret');
|
||||
});
|
||||
|
||||
@@ -92,6 +94,8 @@ describe('useRepeaterDashboard', () => {
|
||||
|
||||
expect(result.current.loggedIn).toBe(true);
|
||||
expect(result.current.loginError).toBe('Auth failed');
|
||||
expect(result.current.lastLoginAttempt?.heardBack).toBe(true);
|
||||
expect(result.current.lastLoginAttempt?.outcome).toBe('not_confirmed');
|
||||
expect(mockToast.error).toHaveBeenCalledWith('Login not confirmed', {
|
||||
description: 'Auth failed',
|
||||
});
|
||||
@@ -125,6 +129,8 @@ describe('useRepeaterDashboard', () => {
|
||||
|
||||
expect(result.current.loggedIn).toBe(true);
|
||||
expect(result.current.loginError).toBe('Network error');
|
||||
expect(result.current.lastLoginAttempt?.heardBack).toBe(false);
|
||||
expect(result.current.lastLoginAttempt?.outcome).toBe('request_failed');
|
||||
expect(mockToast.error).toHaveBeenCalledWith('Login request failed', {
|
||||
description:
|
||||
'Network error. The dashboard is still available, but repeater operations may fail until a login succeeds.',
|
||||
|
||||
@@ -67,8 +67,8 @@ export function describeCiphertextStructure(
|
||||
case PayloadType.GroupText:
|
||||
return `Encrypted message content (${byteLength} bytes). Contains encrypted plaintext with this structure:
|
||||
• Timestamp (4 bytes) - send time as unix timestamp
|
||||
• Flags (1 byte) - room-message flags byte
|
||||
• Message (remaining bytes) - UTF-8 room message text`;
|
||||
• Flags (1 byte) - channel-message flags byte
|
||||
• Message (remaining bytes) - UTF-8 channel message text`;
|
||||
case PayloadType.TextMessage:
|
||||
return `Encrypted message data (${byteLength} bytes). Contains encrypted plaintext with this structure:
|
||||
• Timestamp (4 bytes) - send time as unix timestamp
|
||||
|
||||
107
frontend/src/utils/serverLoginState.ts
Normal file
107
frontend/src/utils/serverLoginState.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
import type { RepeaterLoginResponse } from '../types';
|
||||
|
||||
export type ServerLoginMethod = 'password' | 'blank';
|
||||
|
||||
export type ServerLoginAttemptState =
|
||||
| {
|
||||
method: ServerLoginMethod;
|
||||
outcome: 'confirmed';
|
||||
summary: string;
|
||||
details: string | null;
|
||||
heardBack: true;
|
||||
at: number;
|
||||
}
|
||||
| {
|
||||
method: ServerLoginMethod;
|
||||
outcome: 'not_confirmed';
|
||||
summary: string;
|
||||
details: string | null;
|
||||
heardBack: boolean;
|
||||
at: number;
|
||||
}
|
||||
| {
|
||||
method: ServerLoginMethod;
|
||||
outcome: 'request_failed';
|
||||
summary: string;
|
||||
details: string | null;
|
||||
heardBack: false;
|
||||
at: number;
|
||||
};
|
||||
|
||||
export function getServerLoginMethodLabel(
|
||||
method: ServerLoginMethod,
|
||||
blankLabel = 'existing-access'
|
||||
): string {
|
||||
return method === 'password' ? 'password' : blankLabel;
|
||||
}
|
||||
|
||||
export function getServerLoginAttemptTone(
|
||||
attempt: ServerLoginAttemptState | null
|
||||
): 'success' | 'warning' | 'destructive' | 'muted' {
|
||||
if (!attempt) return 'muted';
|
||||
if (attempt.outcome === 'confirmed') return 'success';
|
||||
if (attempt.outcome === 'not_confirmed') return 'warning';
|
||||
return 'destructive';
|
||||
}
|
||||
|
||||
export function buildServerLoginAttemptFromResponse(
|
||||
method: ServerLoginMethod,
|
||||
result: RepeaterLoginResponse,
|
||||
entityLabel: string
|
||||
): ServerLoginAttemptState {
|
||||
const methodLabel = getServerLoginMethodLabel(method);
|
||||
const at = Date.now();
|
||||
const target = `the ${entityLabel}`;
|
||||
|
||||
if (result.authenticated) {
|
||||
return {
|
||||
method,
|
||||
outcome: 'confirmed',
|
||||
summary: `Login confirmed by ${target}.`,
|
||||
details: null,
|
||||
heardBack: true,
|
||||
at,
|
||||
};
|
||||
}
|
||||
|
||||
if (result.status === 'timeout') {
|
||||
return {
|
||||
method,
|
||||
outcome: 'not_confirmed',
|
||||
summary: `We couldn't confirm the login.`,
|
||||
details:
|
||||
result.message ??
|
||||
`No confirmation came back from ${target} after the ${methodLabel} login attempt.`,
|
||||
heardBack: false,
|
||||
at,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
method,
|
||||
outcome: 'not_confirmed',
|
||||
summary: `Login was not confirmed.`,
|
||||
details:
|
||||
result.message ??
|
||||
`${target} responded, but did not confirm the ${methodLabel} login attempt.`,
|
||||
heardBack: true,
|
||||
at,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildServerLoginAttemptFromError(
|
||||
method: ServerLoginMethod,
|
||||
message: string,
|
||||
entityLabel: string
|
||||
): ServerLoginAttemptState {
|
||||
const methodLabel = getServerLoginMethodLabel(method);
|
||||
const target = `the ${entityLabel}`;
|
||||
return {
|
||||
method,
|
||||
outcome: 'request_failed',
|
||||
summary: `We couldn't send the login request.`,
|
||||
details: `${target} never acknowledged the ${methodLabel} login attempt. ${message}`,
|
||||
heardBack: false,
|
||||
at: Date.now(),
|
||||
};
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "remoteterm-meshcore"
|
||||
version = "3.6.0"
|
||||
version = "3.6.1"
|
||||
description = "RemoteTerm - Web interface for MeshCore radio mesh networks"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
|
||||
106
scripts/fetch_prebuilt_frontend.py
Executable file
106
scripts/fetch_prebuilt_frontend.py
Executable file
@@ -0,0 +1,106 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
fetch_prebuilt_frontend.py
|
||||
|
||||
Downloads the latest prebuilt frontend artifact from the GitHub releases page
|
||||
and installs it into frontend/prebuilt/ so the backend can serve it directly.
|
||||
|
||||
No GitHub CLI or authentication required — uses only the public releases API
|
||||
and browser_download_url. Requires only the Python standard library.
|
||||
"""
|
||||
|
||||
import json
|
||||
import shutil
|
||||
import sys
|
||||
import urllib.request
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
|
||||
REPO = "jkingsman/Remote-Terminal-for-MeshCore"
|
||||
API_URL = f"https://api.github.com/repos/{REPO}/releases/latest"
|
||||
PREBUILT_PREFIX = "Remote-Terminal-for-MeshCore/frontend/prebuilt/"
|
||||
|
||||
SCRIPT_DIR = Path(__file__).resolve().parent
|
||||
PREBUILT_DIR = SCRIPT_DIR.parent / "frontend" / "prebuilt"
|
||||
|
||||
|
||||
def fetch_json(url: str) -> dict:
|
||||
req = urllib.request.Request(url, headers={"Accept": "application/vnd.github+json"})
|
||||
with urllib.request.urlopen(req) as resp:
|
||||
return json.loads(resp.read())
|
||||
|
||||
|
||||
def find_prebuilt_asset(release: dict) -> tuple[str, str, str]:
|
||||
"""Return (tag_name, asset_name, download_url) for the prebuilt zip."""
|
||||
tag = release.get("tag_name", "")
|
||||
for asset in release.get("assets", []):
|
||||
name = asset.get("name", "")
|
||||
if name.startswith("remoteterm-prebuilt-frontend-") and name.endswith(".zip"):
|
||||
return tag, name, asset["browser_download_url"]
|
||||
raise SystemExit(
|
||||
f"No prebuilt frontend artifact found in the latest release.\n"
|
||||
f"Check https://github.com/{REPO}/releases for available assets."
|
||||
)
|
||||
|
||||
|
||||
def download(url: str, dest: Path) -> None:
|
||||
with urllib.request.urlopen(url) as resp, open(dest, "wb") as f:
|
||||
shutil.copyfileobj(resp, f)
|
||||
|
||||
|
||||
def extract_prebuilt(zip_path: Path, dest: Path) -> int:
|
||||
with zipfile.ZipFile(zip_path) as zf:
|
||||
members = [m for m in zf.namelist() if m.startswith(PREBUILT_PREFIX)]
|
||||
if not members:
|
||||
raise SystemExit(f"'{PREBUILT_PREFIX}' not found inside zip.")
|
||||
|
||||
if dest.exists():
|
||||
shutil.rmtree(dest)
|
||||
dest.mkdir(parents=True)
|
||||
|
||||
for member in members:
|
||||
rel = member[len(PREBUILT_PREFIX):]
|
||||
if not rel:
|
||||
continue
|
||||
target = dest / rel
|
||||
if member.endswith("/"):
|
||||
target.mkdir(parents=True, exist_ok=True)
|
||||
else:
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
with zf.open(member) as src, open(target, "wb") as dst:
|
||||
shutil.copyfileobj(src, dst)
|
||||
|
||||
return len(members)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
print("Fetching latest release info...")
|
||||
release = fetch_json(API_URL)
|
||||
tag, asset_name, download_url = find_prebuilt_asset(release)
|
||||
print(f" Release : {tag}")
|
||||
print(f" Asset : {asset_name}")
|
||||
print()
|
||||
|
||||
zip_path = PREBUILT_DIR.parent / asset_name
|
||||
try:
|
||||
print(f"Downloading {asset_name}...")
|
||||
download(download_url, zip_path)
|
||||
|
||||
print("Extracting prebuilt frontend...")
|
||||
count = extract_prebuilt(zip_path, PREBUILT_DIR)
|
||||
print(f"Extracted {count} entries.")
|
||||
finally:
|
||||
zip_path.unlink(missing_ok=True)
|
||||
|
||||
print()
|
||||
print(f"Done! Prebuilt frontend ({tag}) installed to frontend/prebuilt/")
|
||||
print("Start the server with:")
|
||||
print(" uv run uvicorn app.main:app --host 0.0.0.0 --port 8000")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
main()
|
||||
except KeyboardInterrupt:
|
||||
print("\nAborted.", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
413
scripts/install_service.sh
Executable file
413
scripts/install_service.sh
Executable file
@@ -0,0 +1,413 @@
|
||||
#!/usr/bin/env bash
|
||||
# install_service.sh
|
||||
#
|
||||
# Sets up RemoteTerm for MeshCore as a persistent systemd service running as
|
||||
# the current user from the current repo directory. No separate service account
|
||||
# is needed. After installation, git pull and rebuilds work without any sudo -u
|
||||
# gymnastics.
|
||||
#
|
||||
# Run from anywhere inside the repo:
|
||||
# bash scripts/install_service.sh
|
||||
|
||||
set -e
|
||||
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
CYAN='\033[0;36m'
|
||||
BOLD='\033[1m'
|
||||
NC='\033[0m'
|
||||
|
||||
SERVICE_NAME="remoteterm"
|
||||
REPO_DIR="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
CURRENT_USER="$(id -un)"
|
||||
SERVICE_FILE="/etc/systemd/system/${SERVICE_NAME}.service"
|
||||
FRONTEND_MODE="build"
|
||||
|
||||
echo -e "${BOLD}=== RemoteTerm for MeshCore — Service Installer ===${NC}"
|
||||
echo
|
||||
|
||||
# ── sanity checks ──────────────────────────────────────────────────────────────
|
||||
|
||||
if [ "$(uname -s)" != "Linux" ]; then
|
||||
echo -e "${RED}Error: this script is for Linux (systemd) only.${NC}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! command -v systemctl &>/dev/null; then
|
||||
echo -e "${RED}Error: systemd not found. This script requires a systemd-based Linux system.${NC}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! command -v uv &>/dev/null; then
|
||||
echo -e "${RED}Error: 'uv' not found. Install it first:${NC}"
|
||||
echo " curl -LsSf https://astral.sh/uv/install.sh | sh"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! command -v python3 &>/dev/null; then
|
||||
echo -e "${RED}Error: python3 is required but was not found.${NC}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
UV_BIN="$(command -v uv)"
|
||||
UVICORN_BIN="$REPO_DIR/.venv/bin/uvicorn"
|
||||
|
||||
echo -e " Installing as user : ${CYAN}${CURRENT_USER}${NC}"
|
||||
echo -e " Repo directory : ${CYAN}${REPO_DIR}${NC}"
|
||||
echo -e " Service name : ${CYAN}${SERVICE_NAME}${NC}"
|
||||
echo -e " uv : ${CYAN}${UV_BIN}${NC}"
|
||||
echo
|
||||
|
||||
version_major() {
|
||||
local version="$1"
|
||||
version="${version#v}"
|
||||
printf '%s' "${version%%.*}"
|
||||
}
|
||||
|
||||
require_minimum_version() {
|
||||
local tool_name="$1"
|
||||
local detected_version="$2"
|
||||
local minimum_major="$3"
|
||||
local major
|
||||
major="$(version_major "$detected_version")"
|
||||
if ! [[ "$major" =~ ^[0-9]+$ ]] || [ "$major" -lt "$minimum_major" ]; then
|
||||
echo -e "${RED}Error: ${tool_name} ${minimum_major}+ is required for a local frontend build, but found ${detected_version}.${NC}"
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
# ── transport selection ────────────────────────────────────────────────────────
|
||||
|
||||
echo -e "${BOLD}─── Transport ───────────────────────────────────────────────────────${NC}"
|
||||
echo "How is your MeshCore radio connected?"
|
||||
echo " 1) Serial — auto-detect port (default)"
|
||||
echo " 2) Serial — specify port manually"
|
||||
echo " 3) TCP (network connection)"
|
||||
echo " 4) BLE (Bluetooth)"
|
||||
echo
|
||||
read -rp "Select transport [1-4] (default: 1): " TRANSPORT_CHOICE
|
||||
TRANSPORT_CHOICE="${TRANSPORT_CHOICE:-1}"
|
||||
echo
|
||||
|
||||
NEED_DIALOUT=false
|
||||
SERIAL_PORT=""
|
||||
TCP_HOST=""
|
||||
TCP_PORT=""
|
||||
BLE_ADDRESS=""
|
||||
BLE_PIN=""
|
||||
|
||||
case "$TRANSPORT_CHOICE" in
|
||||
1)
|
||||
echo -e "${GREEN}Serial auto-detect selected.${NC}"
|
||||
NEED_DIALOUT=true
|
||||
;;
|
||||
2)
|
||||
read -rp "Serial port path (default: /dev/ttyUSB0): " SERIAL_PORT
|
||||
SERIAL_PORT="${SERIAL_PORT:-/dev/ttyUSB0}"
|
||||
echo -e "${GREEN}Serial port: ${SERIAL_PORT}${NC}"
|
||||
NEED_DIALOUT=true
|
||||
;;
|
||||
3)
|
||||
read -rp "TCP host (IP address or hostname): " TCP_HOST
|
||||
while [ -z "$TCP_HOST" ]; do
|
||||
echo -e "${RED}TCP host is required.${NC}"
|
||||
read -rp "TCP host: " TCP_HOST
|
||||
done
|
||||
read -rp "TCP port (default: 4000): " TCP_PORT
|
||||
TCP_PORT="${TCP_PORT:-4000}"
|
||||
echo -e "${GREEN}TCP: ${TCP_HOST}:${TCP_PORT}${NC}"
|
||||
;;
|
||||
4)
|
||||
read -rp "BLE device address (e.g. AA:BB:CC:DD:EE:FF): " BLE_ADDRESS
|
||||
while [ -z "$BLE_ADDRESS" ]; do
|
||||
echo -e "${RED}BLE address is required.${NC}"
|
||||
read -rp "BLE device address: " BLE_ADDRESS
|
||||
done
|
||||
read -rsp "BLE PIN: " BLE_PIN
|
||||
echo
|
||||
while [ -z "$BLE_PIN" ]; do
|
||||
echo -e "${RED}BLE PIN is required.${NC}"
|
||||
read -rsp "BLE PIN: " BLE_PIN
|
||||
echo
|
||||
done
|
||||
echo -e "${GREEN}BLE: ${BLE_ADDRESS}${NC}"
|
||||
;;
|
||||
*)
|
||||
echo -e "${YELLOW}Invalid selection — defaulting to serial auto-detect.${NC}"
|
||||
TRANSPORT_CHOICE=1
|
||||
NEED_DIALOUT=true
|
||||
;;
|
||||
esac
|
||||
echo
|
||||
|
||||
# ── frontend install mode ──────────────────────────────────────────────────────
|
||||
|
||||
echo -e "${BOLD}─── Frontend Assets ─────────────────────────────────────────────────${NC}"
|
||||
echo "How should the frontend be installed?"
|
||||
echo " 1) Build locally with npm (default, latest code, requires node/npm)"
|
||||
echo " 2) Download prebuilt frontend (fastest)"
|
||||
echo
|
||||
read -rp "Select frontend mode [1-2] (default: 1): " FRONTEND_CHOICE
|
||||
FRONTEND_CHOICE="${FRONTEND_CHOICE:-1}"
|
||||
echo
|
||||
|
||||
case "$FRONTEND_CHOICE" in
|
||||
1)
|
||||
FRONTEND_MODE="build"
|
||||
echo -e "${GREEN}Using local frontend build.${NC}"
|
||||
;;
|
||||
2)
|
||||
FRONTEND_MODE="prebuilt"
|
||||
echo -e "${GREEN}Using prebuilt frontend download.${NC}"
|
||||
;;
|
||||
*)
|
||||
FRONTEND_MODE="build"
|
||||
echo -e "${YELLOW}Invalid selection — defaulting to local frontend build.${NC}"
|
||||
;;
|
||||
esac
|
||||
echo
|
||||
|
||||
# ── bots ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
echo -e "${BOLD}─── Bot System ──────────────────────────────────────────────────────${NC}"
|
||||
echo -e "${YELLOW}Warning:${NC} The bot system executes arbitrary Python code on the server."
|
||||
echo "It is not recommended on untrusted networks. You can always enable"
|
||||
echo "it later by editing the service file."
|
||||
echo
|
||||
read -rp "Enable bots? [y/N]: " ENABLE_BOTS
|
||||
ENABLE_BOTS="${ENABLE_BOTS:-N}"
|
||||
echo
|
||||
|
||||
ENABLE_AUTH="N"
|
||||
AUTH_USERNAME=""
|
||||
AUTH_PASSWORD=""
|
||||
|
||||
if [[ "$ENABLE_BOTS" =~ ^[Yy] ]]; then
|
||||
echo -e "${GREEN}Bots enabled.${NC}"
|
||||
echo
|
||||
|
||||
echo -e "${BOLD}─── HTTP Basic Auth ─────────────────────────────────────────────────${NC}"
|
||||
echo "With bots enabled, HTTP Basic Auth is strongly recommended if this"
|
||||
echo "service will be accessible beyond your local machine."
|
||||
echo
|
||||
read -rp "Set up HTTP Basic Auth? [Y/n]: " ENABLE_AUTH
|
||||
ENABLE_AUTH="${ENABLE_AUTH:-Y}"
|
||||
echo
|
||||
|
||||
if [[ "$ENABLE_AUTH" =~ ^[Yy] ]]; then
|
||||
read -rp "Username: " AUTH_USERNAME
|
||||
while [ -z "$AUTH_USERNAME" ]; do
|
||||
echo -e "${RED}Username cannot be empty.${NC}"
|
||||
read -rp "Username: " AUTH_USERNAME
|
||||
done
|
||||
read -rsp "Password: " AUTH_PASSWORD
|
||||
echo
|
||||
while [ -z "$AUTH_PASSWORD" ]; do
|
||||
echo -e "${RED}Password cannot be empty.${NC}"
|
||||
read -rsp "Password: " AUTH_PASSWORD
|
||||
echo
|
||||
done
|
||||
echo -e "${GREEN}Basic Auth configured for user '${AUTH_USERNAME}'.${NC}"
|
||||
echo -e "${YELLOW}Note:${NC} Basic Auth credentials are not safe over plain HTTP."
|
||||
echo "See README_ADVANCED.md for HTTPS setup."
|
||||
fi
|
||||
else
|
||||
echo -e "${GREEN}Bots disabled.${NC}"
|
||||
fi
|
||||
echo
|
||||
|
||||
# ── python dependencies ────────────────────────────────────────────────────────
|
||||
|
||||
echo -e "${YELLOW}Installing Python dependencies (uv sync)...${NC}"
|
||||
cd "$REPO_DIR"
|
||||
uv sync
|
||||
echo -e "${GREEN}Dependencies ready.${NC}"
|
||||
echo
|
||||
|
||||
# ── frontend assets ────────────────────────────────────────────────────────────
|
||||
|
||||
if [ "$FRONTEND_MODE" = "build" ]; then
|
||||
if ! command -v node &>/dev/null; then
|
||||
echo -e "${RED}Error: node is required for a local frontend build but was not found.${NC}"
|
||||
echo -e "${YELLOW}Tip:${NC} Re-run the installer and choose the prebuilt frontend option, or install Node.js 18+ and npm 9+."
|
||||
exit 1
|
||||
fi
|
||||
if ! command -v npm &>/dev/null; then
|
||||
echo -e "${RED}Error: npm is required for a local frontend build but was not found.${NC}"
|
||||
echo -e "${YELLOW}Tip:${NC} Re-run the installer and choose the prebuilt frontend option, or install Node.js 18+ and npm 9+."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
NODE_VERSION="$(node -v)"
|
||||
NPM_VERSION="$(npm -v)"
|
||||
require_minimum_version "Node.js" "$NODE_VERSION" 18
|
||||
require_minimum_version "npm" "$NPM_VERSION" 9
|
||||
|
||||
echo -e "${YELLOW}Building frontend locally with Node ${NODE_VERSION} and npm ${NPM_VERSION}...${NC}"
|
||||
(
|
||||
cd "$REPO_DIR/frontend"
|
||||
npm install
|
||||
npm run build
|
||||
)
|
||||
else
|
||||
echo -e "${YELLOW}Fetching prebuilt frontend...${NC}"
|
||||
python3 "$REPO_DIR/scripts/fetch_prebuilt_frontend.py"
|
||||
fi
|
||||
echo
|
||||
|
||||
# ── data directory ─────────────────────────────────────────────────────────────
|
||||
|
||||
mkdir -p "$REPO_DIR/data"
|
||||
|
||||
# ── serial port access ─────────────────────────────────────────────────────────
|
||||
|
||||
if [ "$NEED_DIALOUT" = true ]; then
|
||||
if ! id -nG "$CURRENT_USER" | grep -qw dialout; then
|
||||
echo -e "${YELLOW}Adding ${CURRENT_USER} to the 'dialout' group for serial port access...${NC}"
|
||||
sudo usermod -aG dialout "$CURRENT_USER"
|
||||
echo -e "${GREEN}Done. You may need to log out and back in for this to take effect for${NC}"
|
||||
echo -e "${GREEN}manual runs; the service itself handles it via SupplementaryGroups.${NC}"
|
||||
echo
|
||||
else
|
||||
echo -e "${GREEN}User ${CURRENT_USER} is already in the 'dialout' group.${NC}"
|
||||
echo
|
||||
fi
|
||||
fi
|
||||
|
||||
# ── systemd service file ───────────────────────────────────────────────────────
|
||||
|
||||
if sudo systemctl is-active --quiet "$SERVICE_NAME"; then
|
||||
echo -e "${YELLOW}${SERVICE_NAME} is currently running; stopping it before applying changes...${NC}"
|
||||
sudo systemctl stop "$SERVICE_NAME"
|
||||
echo
|
||||
fi
|
||||
|
||||
echo -e "${YELLOW}Writing systemd service file to ${SERVICE_FILE}...${NC}"
|
||||
|
||||
generate_service_file() {
|
||||
echo "[Unit]"
|
||||
echo "Description=RemoteTerm for MeshCore"
|
||||
echo "After=network.target"
|
||||
echo ""
|
||||
echo "[Service]"
|
||||
echo "Type=simple"
|
||||
echo "User=${CURRENT_USER}"
|
||||
echo "WorkingDirectory=${REPO_DIR}"
|
||||
echo "ExecStart=${UVICORN_BIN} app.main:app --host 0.0.0.0 --port 8000"
|
||||
echo "Restart=always"
|
||||
echo "RestartSec=5"
|
||||
echo "Environment=MESHCORE_DATABASE_PATH=${REPO_DIR}/data/meshcore.db"
|
||||
|
||||
# Transport
|
||||
case "$TRANSPORT_CHOICE" in
|
||||
2) echo "Environment=MESHCORE_SERIAL_PORT=${SERIAL_PORT}" ;;
|
||||
3)
|
||||
echo "Environment=MESHCORE_TCP_HOST=${TCP_HOST}"
|
||||
echo "Environment=MESHCORE_TCP_PORT=${TCP_PORT}"
|
||||
;;
|
||||
4)
|
||||
echo "Environment=MESHCORE_BLE_ADDRESS=${BLE_ADDRESS}"
|
||||
echo "Environment=MESHCORE_BLE_PIN=${BLE_PIN}"
|
||||
;;
|
||||
esac
|
||||
|
||||
# Bots
|
||||
if [[ ! "$ENABLE_BOTS" =~ ^[Yy] ]]; then
|
||||
echo "Environment=MESHCORE_DISABLE_BOTS=true"
|
||||
fi
|
||||
|
||||
# Basic auth
|
||||
if [[ "$ENABLE_BOTS" =~ ^[Yy] ]] && [[ "$ENABLE_AUTH" =~ ^[Yy] ]]; then
|
||||
echo "Environment=MESHCORE_BASIC_AUTH_USERNAME=${AUTH_USERNAME}"
|
||||
echo "Environment=MESHCORE_BASIC_AUTH_PASSWORD=${AUTH_PASSWORD}"
|
||||
fi
|
||||
|
||||
# Serial group access
|
||||
if [ "$NEED_DIALOUT" = true ]; then
|
||||
echo "SupplementaryGroups=dialout"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "[Install]"
|
||||
echo "WantedBy=multi-user.target"
|
||||
}
|
||||
|
||||
generate_service_file | sudo tee "$SERVICE_FILE" > /dev/null
|
||||
|
||||
echo -e "${GREEN}Service file written.${NC}"
|
||||
echo
|
||||
|
||||
# ── enable and start ───────────────────────────────────────────────────────────
|
||||
|
||||
echo -e "${YELLOW}Reloading systemd and applying ${SERVICE_NAME}...${NC}"
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl enable "$SERVICE_NAME"
|
||||
sudo systemctl start "$SERVICE_NAME"
|
||||
echo
|
||||
|
||||
# ── status check ───────────────────────────────────────────────────────────────
|
||||
|
||||
echo -e "${YELLOW}Service status:${NC}"
|
||||
sudo systemctl status "$SERVICE_NAME" --no-pager -l || true
|
||||
echo
|
||||
|
||||
# ── summary ────────────────────────────────────────────────────────────────────
|
||||
|
||||
echo -e "${GREEN}${BOLD}=== Installation complete! ===${NC}"
|
||||
echo
|
||||
echo -e "RemoteTerm is running at ${CYAN}http://$(hostname -I | awk '{print $1}'):8000${NC}"
|
||||
echo
|
||||
|
||||
case "$TRANSPORT_CHOICE" in
|
||||
1) echo -e " Transport : ${CYAN}Serial (auto-detect)${NC}" ;;
|
||||
2) echo -e " Transport : ${CYAN}Serial (${SERIAL_PORT})${NC}" ;;
|
||||
3) echo -e " Transport : ${CYAN}TCP (${TCP_HOST}:${TCP_PORT})${NC}" ;;
|
||||
4) echo -e " Transport : ${CYAN}BLE (${BLE_ADDRESS})${NC}" ;;
|
||||
esac
|
||||
if [ "$FRONTEND_MODE" = "build" ]; then
|
||||
echo -e " Frontend : ${GREEN}Built locally${NC}"
|
||||
else
|
||||
echo -e " Frontend : ${YELLOW}Prebuilt download${NC}"
|
||||
fi
|
||||
|
||||
if [[ "$ENABLE_BOTS" =~ ^[Yy] ]]; then
|
||||
echo -e " Bots : ${YELLOW}Enabled${NC}"
|
||||
if [[ "$ENABLE_AUTH" =~ ^[Yy] ]]; then
|
||||
echo -e " Basic Auth: ${GREEN}Enabled (user: ${AUTH_USERNAME})${NC}"
|
||||
else
|
||||
echo -e " Basic Auth: ${YELLOW}Not configured${NC}"
|
||||
fi
|
||||
else
|
||||
echo -e " Bots : ${GREEN}Disabled${NC} (edit ${SERVICE_FILE} to enable)"
|
||||
fi
|
||||
echo
|
||||
|
||||
if [ "$FRONTEND_MODE" = "prebuilt" ]; then
|
||||
echo -e "${YELLOW}Note:${NC} A prebuilt frontend has been fetched and installed. It may lag"
|
||||
echo "behind the latest code. To build the frontend from source for the most"
|
||||
echo "up-to-date features later, run:"
|
||||
echo
|
||||
echo -e " ${CYAN}cd ${REPO_DIR}/frontend && npm install && npm run build${NC}"
|
||||
echo
|
||||
fi
|
||||
|
||||
echo -e "${BOLD}─── Quick Reference ─────────────────────────────────────────────────${NC}"
|
||||
echo
|
||||
echo -e "${YELLOW}Update to latest and restart:${NC}"
|
||||
echo -e " cd ${REPO_DIR}"
|
||||
echo -e " git pull"
|
||||
echo -e " uv sync"
|
||||
echo -e " cd frontend && npm install && npm run build && cd .."
|
||||
echo -e " sudo systemctl restart ${SERVICE_NAME}"
|
||||
echo
|
||||
echo -e "${YELLOW}Refresh prebuilt frontend only (skips local build):${NC}"
|
||||
echo -e " python3 ${REPO_DIR}/scripts/fetch_prebuilt_frontend.py"
|
||||
echo -e " sudo systemctl restart ${SERVICE_NAME}"
|
||||
echo
|
||||
echo -e "${YELLOW}View live logs (useful for troubleshooting):${NC}"
|
||||
echo -e " sudo journalctl -u ${SERVICE_NAME} -f"
|
||||
echo
|
||||
echo -e "${YELLOW}Service control:${NC}"
|
||||
echo -e " sudo systemctl start|stop|restart|status ${SERVICE_NAME}"
|
||||
echo -e "${BOLD}─────────────────────────────────────────────────────────────────────${NC}"
|
||||
@@ -1,6 +1,10 @@
|
||||
import type { Locator, Page } from '@playwright/test';
|
||||
import http from 'http';
|
||||
|
||||
function escapeRegex(text: string): string {
|
||||
return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
|
||||
export function createCaptureServer(urlFactory: (port: number) => string) {
|
||||
const requests: { body: string; headers: http.IncomingHttpHeaders }[] = [];
|
||||
const server = http.createServer((req, res) => {
|
||||
@@ -38,6 +42,15 @@ export async function openFanoutSettings(page: Page): Promise<void> {
|
||||
await page.getByRole('button', { name: /MQTT.*Automation/ }).click();
|
||||
}
|
||||
|
||||
export async function startIntegrationDraft(page: Page, integrationName: string): Promise<void> {
|
||||
await page.getByRole('button', { name: 'Add Integration' }).click();
|
||||
const dialog = page.getByRole('dialog', { name: 'Create Integration' });
|
||||
await dialog
|
||||
.getByRole('button', { name: new RegExp(`^${escapeRegex(integrationName)}(?:\\s|$)`) })
|
||||
.click();
|
||||
await dialog.getByRole('button', { name: 'Create' }).click();
|
||||
}
|
||||
|
||||
export function fanoutHeader(page: Page, name: string): Locator {
|
||||
const nameButton = page.getByRole('button', { name, exact: true });
|
||||
return page
|
||||
|
||||
@@ -25,6 +25,16 @@ export default defineConfig({
|
||||
baseURL: 'http://localhost:8001',
|
||||
trace: 'on-first-retry',
|
||||
screenshot: 'only-on-failure',
|
||||
// Dismiss the security warning modal that blocks interaction on fresh browser contexts
|
||||
storageState: {
|
||||
cookies: [],
|
||||
origins: [
|
||||
{
|
||||
origin: 'http://localhost:8001',
|
||||
localStorage: [{ name: 'meshcore_security_warning_acknowledged', value: 'true' }],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
|
||||
projects: [
|
||||
|
||||
@@ -4,7 +4,12 @@ import {
|
||||
deleteFanoutConfig,
|
||||
getFanoutConfigs,
|
||||
} from '../helpers/api';
|
||||
import { createCaptureServer, fanoutHeader, openFanoutSettings } from '../helpers/fanout';
|
||||
import {
|
||||
createCaptureServer,
|
||||
fanoutHeader,
|
||||
openFanoutSettings,
|
||||
startIntegrationDraft,
|
||||
} from '../helpers/fanout';
|
||||
|
||||
test.describe('Apprise integration settings', () => {
|
||||
let createdAppriseId: string | null = null;
|
||||
@@ -35,9 +40,7 @@ test.describe('Apprise integration settings', () => {
|
||||
await openFanoutSettings(page);
|
||||
await expect(page.getByRole('status', { name: 'Radio OK' })).toBeVisible();
|
||||
|
||||
// Open add menu and pick Apprise
|
||||
await page.getByRole('button', { name: 'Add Integration' }).click();
|
||||
await page.getByRole('menuitem', { name: 'Apprise' }).click();
|
||||
await startIntegrationDraft(page, 'Apprise');
|
||||
|
||||
// Should navigate to the detail/edit view with a numbered default name
|
||||
await expect(page.locator('#fanout-edit-name')).toHaveValue(/Apprise #\d+/);
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import {
|
||||
ensureFlightlessChannel,
|
||||
createFanoutConfig,
|
||||
deleteFanoutConfig,
|
||||
getFanoutConfigs,
|
||||
} from '../helpers/api';
|
||||
import { openFanoutSettings, startIntegrationDraft } from '../helpers/fanout';
|
||||
|
||||
const BOT_CODE = `def bot(sender_name, sender_key, message_text, is_dm, channel_key, channel_name, sender_timestamp, path):
|
||||
if channel_name == "#flightless" and "!e2etest" in message_text.lower():
|
||||
@@ -28,32 +29,35 @@ test.describe('Bot functionality', () => {
|
||||
}
|
||||
});
|
||||
|
||||
test('create a bot via API, verify it in UI, trigger it, and verify response', async ({
|
||||
test('create a bot via UI, trigger it, and verify response', async ({
|
||||
page,
|
||||
}) => {
|
||||
// --- Step 1: Create and enable bot via fanout API ---
|
||||
const bot = await createFanoutConfig({
|
||||
type: 'bot',
|
||||
name: 'E2E Test Bot',
|
||||
config: { code: BOT_CODE },
|
||||
enabled: true,
|
||||
});
|
||||
createdBotId = bot.id;
|
||||
|
||||
// --- Step 2: Verify bot appears in settings UI ---
|
||||
await page.goto('/');
|
||||
await openFanoutSettings(page);
|
||||
await expect(page.getByRole('status', { name: 'Radio OK' })).toBeVisible();
|
||||
|
||||
await page.getByText('Settings').click();
|
||||
await page.getByRole('button', { name: /MQTT.*Automation/ }).click();
|
||||
await startIntegrationDraft(page, 'Python Bot');
|
||||
await expect(page.locator('#fanout-edit-name')).toHaveValue(/Python Bot #\d+/);
|
||||
|
||||
await page.locator('#fanout-edit-name').fill('E2E Test Bot');
|
||||
|
||||
const codeEditor = page.locator('[aria-label="Bot code editor"] [contenteditable]');
|
||||
await codeEditor.click();
|
||||
await codeEditor.press(process.platform === 'darwin' ? 'Meta+A' : 'Control+A');
|
||||
await codeEditor.fill(BOT_CODE);
|
||||
|
||||
await page.getByRole('button', { name: /Save as Enabled/i }).click();
|
||||
await expect(page.getByText('Integration saved and enabled')).toBeVisible();
|
||||
|
||||
// The bot name should be visible in the integration list
|
||||
await expect(page.getByText('E2E Test Bot')).toBeVisible();
|
||||
|
||||
// Exit settings page mode
|
||||
const configs = await getFanoutConfigs();
|
||||
const createdBot = configs.find((config) => config.name === 'E2E Test Bot');
|
||||
if (createdBot) {
|
||||
createdBotId = createdBot.id;
|
||||
}
|
||||
|
||||
await page.getByRole('button', { name: /Back to Chat/i }).click();
|
||||
|
||||
// --- Step 3: Trigger the bot ---
|
||||
await page.getByText('#flightless', { exact: true }).first().click();
|
||||
|
||||
const triggerMessage = `!e2etest ${Date.now()}`;
|
||||
@@ -61,8 +65,6 @@ test.describe('Bot functionality', () => {
|
||||
await input.fill(triggerMessage);
|
||||
await page.getByRole('button', { name: 'Send', exact: true }).click();
|
||||
|
||||
// --- Step 4: Verify bot response appears ---
|
||||
// Bot has ~2s delay before responding, plus radio send time
|
||||
await expect(page.getByText('[BOT] e2e-ok')).toBeVisible({ timeout: 30_000 });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,7 +7,7 @@ import { createChannel, getChannels, getMessages } from '../helpers/api';
|
||||
* Timeout is 3 minutes to allow for intermittent traffic.
|
||||
*/
|
||||
|
||||
const ROOMS = [
|
||||
const CHANNELS = [
|
||||
'#flightless', '#bot', '#snoco', '#skagit', '#edmonds', '#bachelorette',
|
||||
'#emergency', '#furry', '#public', '#puppy', '#foobar', '#capitolhill',
|
||||
'#hamradio', '#icewatch', '#saucefamily', '#scvsar', '#startrek', '#metalmusic',
|
||||
@@ -39,14 +39,14 @@ test.describe('Incoming mesh messages', () => {
|
||||
test.setTimeout(180_000);
|
||||
|
||||
test.beforeAll(async () => {
|
||||
// Ensure all rooms exist — create any that are missing
|
||||
// Ensure all channels exist — create any that are missing
|
||||
const existing = await getChannels();
|
||||
const existingNames = new Set(existing.map((c) => c.name));
|
||||
|
||||
for (const room of ROOMS) {
|
||||
if (!existingNames.has(room)) {
|
||||
for (const channel of CHANNELS) {
|
||||
if (!existingNames.has(channel)) {
|
||||
try {
|
||||
await createChannel(room);
|
||||
await createChannel(channel);
|
||||
} catch {
|
||||
// May already exist from a concurrent creation, ignore
|
||||
}
|
||||
@@ -54,7 +54,7 @@ test.describe('Incoming mesh messages', () => {
|
||||
}
|
||||
});
|
||||
|
||||
test('receive an incoming message in any room', { tag: '@mesh-traffic' }, async ({ page }) => {
|
||||
test('receive an incoming message in any channel', { tag: '@mesh-traffic' }, async ({ page }) => {
|
||||
// Nudge echo bot on #flightless — may generate an incoming packet quickly
|
||||
await nudgeEchoBot();
|
||||
|
||||
|
||||
@@ -4,7 +4,12 @@ import {
|
||||
deleteFanoutConfig,
|
||||
getFanoutConfigs,
|
||||
} from '../helpers/api';
|
||||
import { createCaptureServer, fanoutHeader, openFanoutSettings } from '../helpers/fanout';
|
||||
import {
|
||||
createCaptureServer,
|
||||
fanoutHeader,
|
||||
openFanoutSettings,
|
||||
startIntegrationDraft,
|
||||
} from '../helpers/fanout';
|
||||
|
||||
test.describe('Webhook integration settings', () => {
|
||||
let createdWebhookId: string | null = null;
|
||||
@@ -35,9 +40,7 @@ test.describe('Webhook integration settings', () => {
|
||||
await openFanoutSettings(page);
|
||||
await expect(page.getByRole('status', { name: 'Radio OK' })).toBeVisible();
|
||||
|
||||
// Open add menu and pick Webhook
|
||||
await page.getByRole('button', { name: 'Add Integration' }).click();
|
||||
await page.getByRole('menuitem', { name: 'Webhook' }).click();
|
||||
await startIntegrationDraft(page, 'Webhook');
|
||||
|
||||
// Should navigate to the detail/edit view with a numbered default name
|
||||
await expect(page.locator('#fanout-edit-name')).toHaveValue(/Webhook #\d+/);
|
||||
@@ -77,8 +80,7 @@ test.describe('Webhook integration settings', () => {
|
||||
await openFanoutSettings(page);
|
||||
await expect(page.getByRole('status', { name: 'Radio OK' })).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: 'Add Integration' }).click();
|
||||
await page.getByRole('menuitem', { name: 'Webhook' }).click();
|
||||
await startIntegrationDraft(page, 'Webhook');
|
||||
await expect(page.locator('#fanout-edit-name')).toHaveValue(/Webhook #\d+/);
|
||||
|
||||
await page.locator('#fanout-edit-name').fill('Unsaved Webhook Draft');
|
||||
|
||||
@@ -19,7 +19,6 @@ from app.fanout.community_mqtt import (
|
||||
_build_status_topic,
|
||||
_calculate_packet_hash,
|
||||
_decode_packet_fields,
|
||||
_ed25519_sign_expanded,
|
||||
_format_raw_packet,
|
||||
_generate_jwt_token,
|
||||
_get_client_version,
|
||||
@@ -29,6 +28,7 @@ from app.fanout.mqtt_community import (
|
||||
_publish_community_packet,
|
||||
_render_packet_topic,
|
||||
)
|
||||
from app.keystore import ed25519_sign_expanded
|
||||
|
||||
|
||||
def _make_test_keys() -> tuple[bytes, bytes]:
|
||||
@@ -173,13 +173,13 @@ class TestEddsaSignExpanded:
|
||||
def test_produces_64_byte_signature(self):
|
||||
private_key, public_key = _make_test_keys()
|
||||
message = b"test message"
|
||||
sig = _ed25519_sign_expanded(message, private_key[:32], private_key[32:], public_key)
|
||||
sig = ed25519_sign_expanded(message, private_key[:32], private_key[32:], public_key)
|
||||
assert len(sig) == 64
|
||||
|
||||
def test_signature_verifies_with_nacl(self):
|
||||
private_key, public_key = _make_test_keys()
|
||||
message = b"hello world"
|
||||
sig = _ed25519_sign_expanded(message, private_key[:32], private_key[32:], public_key)
|
||||
sig = ed25519_sign_expanded(message, private_key[:32], private_key[32:], public_key)
|
||||
|
||||
signed_message = sig + message
|
||||
verified = nacl.bindings.crypto_sign_open(signed_message, public_key)
|
||||
@@ -187,8 +187,8 @@ class TestEddsaSignExpanded:
|
||||
|
||||
def test_different_messages_produce_different_signatures(self):
|
||||
private_key, public_key = _make_test_keys()
|
||||
sig1 = _ed25519_sign_expanded(b"msg1", private_key[:32], private_key[32:], public_key)
|
||||
sig2 = _ed25519_sign_expanded(b"msg2", private_key[:32], private_key[32:], public_key)
|
||||
sig1 = ed25519_sign_expanded(b"msg1", private_key[:32], private_key[32:], public_key)
|
||||
sig2 = ed25519_sign_expanded(b"msg2", private_key[:32], private_key[32:], public_key)
|
||||
assert sig1 != sig2
|
||||
|
||||
|
||||
|
||||
@@ -707,6 +707,98 @@ class TestSqsValidation:
|
||||
{"queue_url": "https://sqs.us-east-1.amazonaws.com/123456789012/mesh-events"}
|
||||
)
|
||||
|
||||
|
||||
class TestMapUploadValidation:
|
||||
def test_rejects_bad_api_url_scheme(self):
|
||||
from fastapi import HTTPException
|
||||
|
||||
from app.routers.fanout import _validate_map_upload_config
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
_validate_map_upload_config({"api_url": "ftp://example.com"})
|
||||
assert exc_info.value.status_code == 400
|
||||
assert "api_url" in exc_info.value.detail
|
||||
|
||||
def test_accepts_empty_api_url(self):
|
||||
from app.routers.fanout import _validate_map_upload_config
|
||||
|
||||
config = {"api_url": ""}
|
||||
_validate_map_upload_config(config)
|
||||
assert config["api_url"] == ""
|
||||
|
||||
def test_accepts_valid_api_url(self):
|
||||
from app.routers.fanout import _validate_map_upload_config
|
||||
|
||||
config = {"api_url": "https://custom.example.com/upload"}
|
||||
_validate_map_upload_config(config)
|
||||
assert config["api_url"] == "https://custom.example.com/upload"
|
||||
|
||||
def test_normalizes_dry_run_to_bool(self):
|
||||
from app.routers.fanout import _validate_map_upload_config
|
||||
|
||||
config = {"dry_run": 1}
|
||||
_validate_map_upload_config(config)
|
||||
assert config["dry_run"] is True
|
||||
|
||||
def test_normalizes_geofence_enabled_to_bool(self):
|
||||
from app.routers.fanout import _validate_map_upload_config
|
||||
|
||||
config = {"geofence_enabled": 1}
|
||||
_validate_map_upload_config(config)
|
||||
assert config["geofence_enabled"] is True
|
||||
|
||||
def test_normalizes_geofence_radius_to_float(self):
|
||||
from app.routers.fanout import _validate_map_upload_config
|
||||
|
||||
config = {"geofence_radius_km": 100}
|
||||
_validate_map_upload_config(config)
|
||||
assert config["geofence_radius_km"] == 100.0
|
||||
assert isinstance(config["geofence_radius_km"], float)
|
||||
|
||||
def test_rejects_negative_geofence_radius(self):
|
||||
from fastapi import HTTPException
|
||||
|
||||
from app.routers.fanout import _validate_map_upload_config
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
_validate_map_upload_config({"geofence_radius_km": -1})
|
||||
assert exc_info.value.status_code == 400
|
||||
assert "geofence_radius_km" in exc_info.value.detail
|
||||
|
||||
def test_rejects_non_numeric_geofence_radius(self):
|
||||
from fastapi import HTTPException
|
||||
|
||||
from app.routers.fanout import _validate_map_upload_config
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
_validate_map_upload_config({"geofence_radius_km": "bad"})
|
||||
assert exc_info.value.status_code == 400
|
||||
assert "geofence_radius_km" in exc_info.value.detail
|
||||
|
||||
def test_accepts_zero_geofence_radius(self):
|
||||
from app.routers.fanout import _validate_map_upload_config
|
||||
|
||||
config = {"geofence_radius_km": 0}
|
||||
_validate_map_upload_config(config)
|
||||
assert config["geofence_radius_km"] == 0.0
|
||||
|
||||
def test_defaults_applied_when_keys_absent(self):
|
||||
from app.routers.fanout import _validate_map_upload_config
|
||||
|
||||
config = {}
|
||||
_validate_map_upload_config(config)
|
||||
assert config["api_url"] == ""
|
||||
assert config["dry_run"] is True
|
||||
assert config["geofence_enabled"] is False
|
||||
assert config["geofence_radius_km"] == 0.0
|
||||
|
||||
def test_enforce_scope_map_upload_forces_raw_only(self):
|
||||
"""map_upload scope is always fixed regardless of what the caller passes."""
|
||||
from app.routers.fanout import _enforce_scope
|
||||
|
||||
scope = _enforce_scope("map_upload", {"messages": "all", "raw_packets": "none"})
|
||||
assert scope == {"messages": "none", "raw_packets": "all"}
|
||||
|
||||
def test_enforce_scope_sqs_preserves_raw_packets_setting(self):
|
||||
from app.routers.fanout import _enforce_scope
|
||||
|
||||
|
||||
@@ -1790,3 +1790,100 @@ class TestManagerRestartFailure:
|
||||
|
||||
assert len(healthy.messages_received) == 1
|
||||
assert len(dead.messages_received) == 0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# MapUploadModule integration tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestMapUploadIntegration:
|
||||
"""Integration tests: FanoutManager loads and dispatches to MapUploadModule."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_map_upload_module_loaded_and_receives_raw(self, integration_db):
|
||||
"""Enabled map_upload config is loaded by the manager and its on_raw is called."""
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
cfg = await FanoutConfigRepository.create(
|
||||
config_type="map_upload",
|
||||
name="Map",
|
||||
config={"dry_run": True, "api_url": ""},
|
||||
scope={"messages": "none", "raw_packets": "all"},
|
||||
enabled=True,
|
||||
)
|
||||
|
||||
manager = FanoutManager()
|
||||
await manager.load_from_db()
|
||||
|
||||
assert cfg["id"] in manager._modules
|
||||
module, scope = manager._modules[cfg["id"]]
|
||||
assert scope == {"messages": "none", "raw_packets": "all"}
|
||||
|
||||
# Raw ADVERT event should be dispatched to on_raw
|
||||
advert_data = {
|
||||
"payload_type": "ADVERT",
|
||||
"data": "aabbccdd",
|
||||
"timestamp": 1000,
|
||||
"id": 1,
|
||||
"observation_id": 1,
|
||||
}
|
||||
|
||||
with patch.object(module, "_upload", new_callable=AsyncMock):
|
||||
# Provide a parseable but minimal packet so on_raw gets past hex decode;
|
||||
# parse_packet/parse_advertisement returning None is fine — on_raw silently exits
|
||||
await manager.broadcast_raw(advert_data)
|
||||
# Give the asyncio task a chance to run
|
||||
import asyncio
|
||||
|
||||
await asyncio.sleep(0.05)
|
||||
# _upload may or may not be called depending on parse result, but no exception
|
||||
|
||||
await manager.stop_all()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_map_upload_disabled_not_loaded(self, integration_db):
|
||||
"""Disabled map_upload config is not loaded by the manager."""
|
||||
await FanoutConfigRepository.create(
|
||||
config_type="map_upload",
|
||||
name="Map Disabled",
|
||||
config={"dry_run": True, "api_url": ""},
|
||||
scope={"messages": "none", "raw_packets": "all"},
|
||||
enabled=False,
|
||||
)
|
||||
|
||||
manager = FanoutManager()
|
||||
await manager.load_from_db()
|
||||
|
||||
assert len(manager._modules) == 0
|
||||
await manager.stop_all()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_map_upload_does_not_receive_messages(self, integration_db):
|
||||
"""map_upload scope forces raw_packets only — message events must not reach it."""
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
cfg = await FanoutConfigRepository.create(
|
||||
config_type="map_upload",
|
||||
name="Map",
|
||||
config={"dry_run": True, "api_url": ""},
|
||||
scope={"messages": "none", "raw_packets": "all"},
|
||||
enabled=True,
|
||||
)
|
||||
|
||||
manager = FanoutManager()
|
||||
await manager.load_from_db()
|
||||
|
||||
assert cfg["id"] in manager._modules
|
||||
module, _ = manager._modules[cfg["id"]]
|
||||
|
||||
with patch.object(module, "on_message", new_callable=AsyncMock) as mock_msg:
|
||||
await manager.broadcast_message(
|
||||
{"type": "CHAN", "conversation_key": "k1", "text": "hi"}
|
||||
)
|
||||
import asyncio
|
||||
|
||||
await asyncio.sleep(0.05)
|
||||
mock_msg.assert_not_called()
|
||||
|
||||
await manager.stop_all()
|
||||
|
||||
1087
tests/test_map_upload.py
Normal file
1087
tests/test_map_upload.py
Normal file
File diff suppressed because it is too large
Load Diff
@@ -5,12 +5,14 @@ contact/channel sync operations, and default channel management.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
from contextlib import asynccontextmanager
|
||||
from unittest.mock import AsyncMock, MagicMock, call, patch
|
||||
|
||||
import pytest
|
||||
from meshcore import EventType
|
||||
from meshcore.events import Event
|
||||
|
||||
import app.radio_sync as radio_sync
|
||||
from app.models import Favorite
|
||||
from app.radio import RadioManager, radio_manager
|
||||
from app.radio_sync import (
|
||||
@@ -36,8 +38,6 @@ from app.repository import (
|
||||
@pytest.fixture(autouse=True)
|
||||
def reset_sync_state():
|
||||
"""Reset polling pause state, sync timestamp, and radio_manager before/after each test."""
|
||||
import app.radio_sync as radio_sync
|
||||
|
||||
prev_mc = radio_manager._meshcore
|
||||
prev_lock = radio_manager._operation_lock
|
||||
prev_max_channels = radio_manager.max_channels
|
||||
@@ -45,12 +45,20 @@ def reset_sync_state():
|
||||
prev_slot_by_key = radio_manager._channel_slot_by_key.copy()
|
||||
prev_key_by_slot = radio_manager._channel_key_by_slot.copy()
|
||||
prev_pending_channel_key_by_slot = radio_manager._pending_message_channel_key_by_slot.copy()
|
||||
prev_contact_reconcile_task = radio_sync._contact_reconcile_task
|
||||
|
||||
radio_sync._polling_pause_count = 0
|
||||
radio_sync._last_contact_sync = 0.0
|
||||
yield
|
||||
if (
|
||||
radio_sync._contact_reconcile_task is not None
|
||||
and radio_sync._contact_reconcile_task is not prev_contact_reconcile_task
|
||||
and not radio_sync._contact_reconcile_task.done()
|
||||
):
|
||||
radio_sync._contact_reconcile_task.cancel()
|
||||
radio_sync._polling_pause_count = 0
|
||||
radio_sync._last_contact_sync = 0.0
|
||||
radio_sync._contact_reconcile_task = prev_contact_reconcile_task
|
||||
radio_manager._meshcore = prev_mc
|
||||
radio_manager._operation_lock = prev_lock
|
||||
radio_manager.max_channels = prev_max_channels
|
||||
@@ -433,7 +441,7 @@ class TestSyncAndOffloadAll:
|
||||
"""Test session-local contact radio residency reset behavior."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_clears_stale_contact_on_radio_flags_before_reload(self, test_db):
|
||||
async def test_clears_stale_contact_on_radio_flags_before_background_reconcile(self, test_db):
|
||||
await _insert_contact(KEY_A, "Alice", on_radio=True)
|
||||
await _insert_contact(KEY_B, "Bob", on_radio=True)
|
||||
|
||||
@@ -441,8 +449,8 @@ class TestSyncAndOffloadAll:
|
||||
|
||||
with (
|
||||
patch(
|
||||
"app.radio_sync.sync_and_offload_contacts",
|
||||
new=AsyncMock(return_value={"synced": 0, "removed": 0}),
|
||||
"app.radio_sync.sync_contacts_from_radio",
|
||||
new=AsyncMock(return_value={"synced": 0, "radio_contacts": {}}),
|
||||
),
|
||||
patch(
|
||||
"app.radio_sync.sync_and_offload_channels",
|
||||
@@ -450,8 +458,7 @@ class TestSyncAndOffloadAll:
|
||||
),
|
||||
patch("app.radio_sync.ensure_default_channels", new=AsyncMock()),
|
||||
patch(
|
||||
"app.radio_sync.sync_recent_contacts_to_radio",
|
||||
new=AsyncMock(return_value={"loaded": 0, "already_on_radio": 0, "failed": 0}),
|
||||
"app.radio_sync.start_background_contact_reconciliation",
|
||||
),
|
||||
):
|
||||
await sync_and_offload_all(mock_mc)
|
||||
@@ -461,6 +468,30 @@ class TestSyncAndOffloadAll:
|
||||
assert alice is not None and alice.on_radio is False
|
||||
assert bob is not None and bob.on_radio is False
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_starts_background_contact_reconcile_with_radio_snapshot(self, test_db):
|
||||
mock_mc = MagicMock()
|
||||
radio_contacts = {KEY_A: {"public_key": KEY_A}}
|
||||
|
||||
with (
|
||||
patch(
|
||||
"app.radio_sync.sync_contacts_from_radio",
|
||||
new=AsyncMock(return_value={"synced": 1, "radio_contacts": radio_contacts}),
|
||||
),
|
||||
patch(
|
||||
"app.radio_sync.sync_and_offload_channels",
|
||||
new=AsyncMock(return_value={"synced": 0, "cleared": 0}),
|
||||
),
|
||||
patch("app.radio_sync.ensure_default_channels", new=AsyncMock()),
|
||||
patch("app.radio_sync.start_background_contact_reconciliation") as mock_start,
|
||||
):
|
||||
result = await sync_and_offload_all(mock_mc)
|
||||
|
||||
mock_start.assert_called_once_with(
|
||||
initial_radio_contacts=radio_contacts, expected_mc=mock_mc
|
||||
)
|
||||
assert result["contact_reconcile_started"] is True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_advert_fill_skips_repeaters(self, test_db):
|
||||
"""Recent advert fallback only considers non-repeaters."""
|
||||
@@ -1036,6 +1067,98 @@ class TestSyncAndOffloadContacts:
|
||||
assert KEY_A in mock_mc._contacts
|
||||
|
||||
|
||||
class TestBackgroundContactReconcile:
|
||||
"""Test the yielding background contact reconcile loop."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rechecks_desired_set_before_deleting_contact(self, test_db):
|
||||
await _insert_contact(KEY_A, "Alice", last_contacted=2000)
|
||||
await _insert_contact(KEY_B, "Bob", last_contacted=1000)
|
||||
alice = await ContactRepository.get_by_key(KEY_A)
|
||||
bob = await ContactRepository.get_by_key(KEY_B)
|
||||
assert alice is not None
|
||||
assert bob is not None
|
||||
|
||||
mock_mc = MagicMock()
|
||||
mock_mc.is_connected = True
|
||||
mock_mc.get_contact_by_key_prefix = MagicMock(return_value=None)
|
||||
mock_mc.commands.remove_contact = AsyncMock(return_value=MagicMock(type=EventType.OK))
|
||||
mock_mc.commands.add_contact = AsyncMock(return_value=MagicMock(type=EventType.OK))
|
||||
radio_manager._meshcore = mock_mc
|
||||
|
||||
@asynccontextmanager
|
||||
async def _radio_operation(*args, **kwargs):
|
||||
del args, kwargs
|
||||
yield mock_mc
|
||||
|
||||
with (
|
||||
patch.object(
|
||||
radio_sync.radio_manager,
|
||||
"radio_operation",
|
||||
side_effect=lambda *args, **kwargs: _radio_operation(*args, **kwargs),
|
||||
),
|
||||
patch(
|
||||
"app.radio_sync.get_contacts_selected_for_radio_sync",
|
||||
side_effect=[[bob], [alice, bob], [alice, bob]],
|
||||
),
|
||||
patch("app.radio_sync.asyncio.sleep", new=AsyncMock()),
|
||||
):
|
||||
await radio_sync._reconcile_radio_contacts_in_background(
|
||||
initial_radio_contacts={KEY_A: {"public_key": KEY_A}},
|
||||
expected_mc=mock_mc,
|
||||
)
|
||||
|
||||
mock_mc.commands.remove_contact.assert_not_called()
|
||||
mock_mc.commands.add_contact.assert_awaited_once()
|
||||
payload = mock_mc.commands.add_contact.call_args.args[0]
|
||||
assert payload["public_key"] == KEY_B
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_yields_radio_lock_every_two_contact_operations(self, test_db):
|
||||
await _insert_contact(KEY_A, "Alice", last_contacted=3000)
|
||||
await _insert_contact(KEY_B, "Bob", last_contacted=2000)
|
||||
extra_key = "cc" * 32
|
||||
await _insert_contact(extra_key, "Carol", last_contacted=1000)
|
||||
|
||||
mock_mc = MagicMock()
|
||||
mock_mc.is_connected = True
|
||||
mock_mc.get_contact_by_key_prefix = MagicMock(return_value=None)
|
||||
mock_mc.commands.remove_contact = AsyncMock(return_value=MagicMock(type=EventType.OK))
|
||||
mock_mc.commands.add_contact = AsyncMock()
|
||||
radio_manager._meshcore = mock_mc
|
||||
|
||||
acquire_count = 0
|
||||
|
||||
@asynccontextmanager
|
||||
async def _radio_operation(*args, **kwargs):
|
||||
del args, kwargs
|
||||
nonlocal acquire_count
|
||||
acquire_count += 1
|
||||
yield mock_mc
|
||||
|
||||
with (
|
||||
patch.object(
|
||||
radio_sync.radio_manager,
|
||||
"radio_operation",
|
||||
side_effect=lambda *args, **kwargs: _radio_operation(*args, **kwargs),
|
||||
),
|
||||
patch("app.radio_sync.get_contacts_selected_for_radio_sync", return_value=[]),
|
||||
patch("app.radio_sync.asyncio.sleep", new=AsyncMock()),
|
||||
):
|
||||
await radio_sync._reconcile_radio_contacts_in_background(
|
||||
initial_radio_contacts={
|
||||
KEY_A: {"public_key": KEY_A},
|
||||
KEY_B: {"public_key": KEY_B},
|
||||
extra_key: {"public_key": extra_key},
|
||||
},
|
||||
expected_mc=mock_mc,
|
||||
)
|
||||
|
||||
assert acquire_count == 2
|
||||
assert mock_mc.commands.remove_contact.await_count == 3
|
||||
mock_mc.commands.add_contact.assert_not_called()
|
||||
|
||||
|
||||
class TestSyncAndOffloadChannels:
|
||||
"""Test sync_and_offload_channels: pull channels from radio, save to DB, clear from radio."""
|
||||
|
||||
|
||||
Reference in New Issue
Block a user