78 Commits

Author SHA1 Message Date
Jack Kingsman
7151cf3846 Be much, much clearer about room server ops. Closes #78. 2026-03-27 13:01:34 -07:00
Jack Kingsman
6e5256acce Be more flexible about radio offload. Closes #118. 2026-03-27 12:49:01 -07:00
Jack Kingsman
7d27567ae9 Merge pull request #109 from jkingsman/fix-room-server-ordering
Order room server messages by sender timestamp, not packet-receipt time
2026-03-27 10:18:21 -07:00
Jack Kingsman
5f0d042252 Fix time rendering unit issue 2026-03-26 21:32:23 -07:00
Jack Kingsman
6f68dfc609 Deal with non-existent hashes better 2026-03-26 20:36:13 -07:00
Jack Kingsman
a32ddda79d Cut down bloat in unreads endpoint 2026-03-26 20:36:04 -07:00
Jack Kingsman
ac6a5774af Updating changelog + build for 3.6.1 2026-03-26 19:14:44 -07:00
Jack Kingsman
b12e612596 Merge pull request #117 from jkingsman/settings-scroll-fix. Closes #112
More content-paint patchy patchy bs
2026-03-26 18:27:55 -07:00
Jack Kingsman
d1499ad75f Merge pull request #116 from kizniche/feat-int-mc-map-auto-uploader
Add automatic mesh map upload (integration/fanout module). Closes #108. Thank you!!
2026-03-26 18:08:34 -07:00
jkingsman
79d5e69ee0 Format + lint 2026-03-26 17:59:59 -07:00
jkingsman
498770bd88 More content-paint patchy patchy bs 2026-03-26 17:30:40 -07:00
jkingsman
1405df6039 Beef up some noopy tests 2026-03-26 17:22:42 -07:00
jkingsman
ac5e71d6f2 Validate geofence radius to be positive 2026-03-26 17:20:13 -07:00
jkingsman
650a24a68c Centralize duplicated crypto code 2026-03-26 17:18:28 -07:00
Kizniche
53f122e503 formatting changes to satisfy check 2026-03-26 20:08:42 -04:00
Jack Kingsman
bf0533807a Rich install script. Closes #111 2026-03-26 17:04:12 -07:00
jkingsman
094058bad7 Tweak install script 2026-03-26 16:59:53 -07:00
Kizniche
efeb047116 Switching to using radio lat/lon, rename Community MQTT to Community Sharing, update AGENTS_fanout.md 2026-03-26 19:55:30 -04:00
jkingsman
88c99e0983 Add note in readme 2026-03-26 16:50:48 -07:00
jkingsman
983a37f68f Idempotentify and remove the explicit setup instructions in the advanced readme 2026-03-26 16:46:27 -07:00
jkingsman
bea3495b79 Improve coverage around desktop notifications. Closes #115. 2026-03-26 16:39:38 -07:00
jkingsman
54c24c50d3 Clarify MQTT error logs when persistent 2026-03-26 13:39:08 -07:00
Kizniche
b7972f50a8 Fix issues identified in failing checks 2026-03-25 19:06:33 -04:00
Kizniche
bab1693c82 Fix freq and BW values, add geofence calc to dry run log 2026-03-25 18:39:27 -04:00
Kyle Gabriel
f93844a01b Merge branch 'jkingsman:main' into feat-int-mc-map-auto-uploader 2026-03-25 14:40:59 -04:00
jkingsman
26b740fe3c Fix lint 2026-03-25 08:57:43 -07:00
jkingsman
b0f5930e01 Swipe away 2026-03-25 08:46:50 -07:00
jkingsman
5b05fdefa1 Change room finder to be channels not rooms 2026-03-25 08:34:21 -07:00
jkingsman
b63153b3a1 Initial swipe work 2026-03-25 08:32:06 -07:00
Jack Kingsman
3c5a832bef Merge pull request #113 from an0key/main
Update Sidebar.tsx
2026-03-25 08:19:04 -07:00
jkingsman
fd8bc4b56a First draft of install script 2026-03-25 08:09:55 -07:00
Luke
2d943dedc5 Update Sidebar.tsx 2026-03-25 15:09:32 +00:00
Jack Kingsman
137f41970d Fix some places where we used vh instead of dvh for modal sizing 2026-03-24 21:07:20 -07:00
Jack Kingsman
c833f1036b Test scroll fix for mobile browsers 2026-03-24 21:05:29 -07:00
Kyle Gabriel
e15e6d83f7 Merge branch 'jkingsman:main' into feat-int-mc-map-auto-uploader 2026-03-24 19:55:14 -04:00
jkingsman
4ead2ffcde Add prebuilt frontend fetch script. Closes #110. 2026-03-24 16:42:49 -07:00
Kizniche
f9ca35b3ae Switch from block list to allow list, add test to ensure certain nodes are skipped, fix test 2026-03-24 19:41:25 -04:00
Kizniche
7c4a244e05 Add geofence option 2026-03-24 19:41:25 -04:00
Kyle
6eab75ec7e Add Map Upload Integration and tests 2026-03-24 19:41:18 -04:00
jkingsman
caf4bf4eff Fix linting 2026-03-24 16:32:19 -07:00
jkingsman
74e1f49db8 Show hop map in a larger modal. Closes #102. 2026-03-24 16:14:43 -07:00
jkingsman
95c874e643 Order room server messages by sender timestamp, not arrival-at-our-radio timestamp 2026-03-24 15:55:28 -07:00
Jack Kingsman
3b28ebfa49 Fix e2e tests 2026-03-24 14:51:29 -07:00
jkingsman
d36c63f6b1 Complete room -> channel rename 2026-03-24 14:02:43 -07:00
jkingsman
e8a4f5c349 Make a better integration/fanout selector 2026-03-24 13:48:50 -07:00
jkingsman
b022aea71f Adjust phrasing on new-chat modal, and remove the unusable existing-contact scren. Closes #105. 2026-03-24 10:02:39 -07:00
jkingsman
5225a1c766 Don't be so eager on the quality gate 2026-03-24 09:59:37 -07:00
Jack Kingsman
41400c0528 Change page title and favicon for unreads. Green for favorite group chats, red for unread mentions or DMs. Closes #100 WOOOO 2026-03-23 21:36:54 -07:00
Jack Kingsman
07928d930c Clarify phrasing around bot system 2026-03-23 19:32:45 -07:00
Jack Kingsman
26742d0c88 Merge pull request #103 from jkingsman/bot-safety
Bot safety
2026-03-23 18:44:50 -07:00
Jack Kingsman
8b73bef30b More styling 2026-03-23 18:42:09 -07:00
Jack Kingsman
4b583fe337 Rephrasing and add env vars to docker compose 2026-03-23 18:36:55 -07:00
Jack Kingsman
e6e7267eb1 Fix mobile modal 2026-03-23 18:33:37 -07:00
Jack Kingsman
36eeeae64d Protect against uncheck race condition 2026-03-23 18:27:42 -07:00
Jack Kingsman
7c988ae3d0 Initial bot safety warning pass 2026-03-23 15:16:04 -07:00
Jack Kingsman
1a0c4833d5 Enrich the error text for notification blockage and mention http/s issues 2026-03-23 09:12:17 -07:00
Jack Kingsman
84c500d018 Add clearer warning on frontend fetching invalid backend 2026-03-22 23:32:52 -07:00
Jack Kingsman
1960a16fb0 Add note about CORS + Basic auth 2026-03-22 23:28:33 -07:00
Jack Kingsman
3580aeda5a Updating changelog + build for 3.6.0 2026-03-22 22:14:55 -07:00
Jack Kingsman
bb97b983bb Add room activity to stats view 2026-03-22 22:13:40 -07:00
Jack Kingsman
da31b67d54 Add on-receive packet analyzer for canonical copy. Closes #97. 2026-03-22 21:34:41 -07:00
Jack Kingsman
d840159f9c Update meshcore_py and remove monkeypatch for serial frame start detection. 2026-03-22 11:06:24 -07:00
Jack Kingsman
9de4158a6c Monkeypatch the meshcore_py lib for frame-start handling 2026-03-21 22:46:59 -07:00
Jack Kingsman
1e21644d74 Swap repeaters and room servers for better ordering, and the less common contact type at the bottom 2026-03-21 13:15:18 -07:00
Jack Kingsman
df0ed8452b Add BYOPacket analyzer. Closes #98. 2026-03-20 21:57:07 -07:00
Jack Kingsman
d4a5f0f728 Scroll in room server control pane. Closes #99. 2026-03-20 19:43:55 -07:00
Jack Kingsman
3e2c48457d Be more compact about the room server controls 2026-03-20 18:16:29 -07:00
Jack Kingsman
d4f518df0c Retry e2e tests one before failing 2026-03-19 21:57:03 -07:00
Jack Kingsman
5213c8c84c Updating changelog + build for 3.5.0 2026-03-19 21:53:45 -07:00
Jack Kingsman
33c2b0c948 Be better about identity resolution for stats view 2026-03-19 21:42:39 -07:00
Jack Kingsman
b021a4a8ac Fix e2e tests for contact stuff 2026-03-19 21:39:18 -07:00
Jack Kingsman
c74fdec10b Add database information to debug endpoint 2026-03-19 21:17:23 -07:00
Jack Kingsman
cf314e02ff Be cleaner about message cache dedupe after trimming inactive convos 2026-03-19 21:03:20 -07:00
Jack Kingsman
8ae600d010 Docs updates 2026-03-19 20:58:49 -07:00
Jack Kingsman
fdd82e1f77 Clean up orphaned contact child rows and add foreign key enforcement 2026-03-19 20:56:36 -07:00
Jack Kingsman
9d129260fd Fix up the header collapse to be less terrible 2026-03-19 20:53:24 -07:00
Jack Kingsman
2b80760696 Add DB entry for outgoing inside the radio lock (didn't we just do the opposite?) 2026-03-19 20:43:35 -07:00
Jack Kingsman
c2655c1809 Add basic room server support. Closes #78.
Allow basic room server usage
2026-03-19 20:28:44 -07:00
121 changed files with 7091 additions and 1244 deletions

View File

@@ -165,7 +165,8 @@ MeshCore firmware can encode path hops as 1-byte, 2-byte, or 3-byte identifiers.
Direct-message send behavior intentionally mirrors the firmware/library `send_msg_with_retry(...)` flow:
- We push the contact's effective route to the radio via `add_contact(...)` before sending.
- Non-final attempts use the effective route (`override > direct > flood`).
- If the initial `MSG_SENT` result includes an expected ACK code, background retries are armed.
- Non-final retry attempts use the effective route (`override > direct > flood`).
- Retry timing follows the radio's `suggested_timeout`.
- The final retry is sent as flood by resetting the path on the radio first, even if an override or direct route exists.
- Path math is always hop-count based; hop bytes are interpreted using the stored `path_hash_mode`.
@@ -174,7 +175,7 @@ Direct-message send behavior intentionally mirrors the firmware/library `send_ms
**Direct messages**: Expected ACK code is tracked. When ACK event arrives, message marked as acked.
Outgoing DMs send once immediately, then may retry up to 2 more times in the background if still unacked. Retry timing follows the radio's `suggested_timeout` from `PACKET_MSG_SENT`, and the final retry is sent as flood even when a routing override is configured. DM ACK state is terminal on first ACK: sibling retry ACK codes are cleared so one DM should not accumulate multiple delivery confirmations from different retry attempts.
Outgoing DMs send once immediately, then may retry up to 2 more times in the background only when the initial `MSG_SENT` result includes an expected ACK code and the message remains unacked. Retry timing follows the radio's `suggested_timeout` from `PACKET_MSG_SENT`, and the final retry is sent as flood even when a routing override is configured. DM ACK state is terminal on first ACK: sibling retry ACK codes are cleared so one DM should not accumulate multiple delivery confirmations from different retry attempts.
ACKs are not a contact-route source. They drive message delivery state and may appear in analytics/detail surfaces, but they do not update `direct_path*` or otherwise influence route selection for future sends.
@@ -295,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
@@ -324,6 +325,7 @@ All endpoints are prefixed with `/api` (e.g., `/api/health`).
| POST | `/api/contacts/{public_key}/command` | Send CLI command to repeater |
| POST | `/api/contacts/{public_key}/routing-override` | Set or clear a forced routing override |
| POST | `/api/contacts/{public_key}/trace` | Trace route to contact |
| POST | `/api/contacts/{public_key}/path-discovery` | Discover forward/return paths and persist the learned direct route |
| POST | `/api/contacts/{public_key}/repeater/login` | Log in to a repeater |
| POST | `/api/contacts/{public_key}/repeater/status` | Fetch repeater status telemetry |
| POST | `/api/contacts/{public_key}/repeater/lpp-telemetry` | Fetch CayenneLPP sensor data |
@@ -348,7 +350,7 @@ All endpoints are prefixed with `/api` (e.g., `/api/health`).
| GET | `/api/packets/undecrypted/count` | Count of undecrypted packets |
| POST | `/api/packets/decrypt/historical` | Decrypt stored packets |
| POST | `/api/packets/maintenance` | Delete old packets and vacuum |
| GET | `/api/read-state/unreads` | Server-computed unread counts, mentions, last message times |
| GET | `/api/read-state/unreads` | Server-computed unread counts, mentions, last message times, and `last_read_ats` boundaries |
| POST | `/api/read-state/mark-all-read` | Mark all conversations as read |
| GET | `/api/settings` | Get app settings |
| PATCH | `/api/settings` | Update app settings |
@@ -398,7 +400,7 @@ Read state (`last_read_at`) is tracked **server-side** for consistency across de
- Stored as Unix timestamp in `contacts.last_read_at` and `channels.last_read_at`
- Updated via `POST /api/contacts/{public_key}/mark-read` and `POST /api/channels/{key}/mark-read`
- Bulk update via `POST /api/read-state/mark-all-read`
- Aggregated counts via `GET /api/read-state/unreads` (server-side computation)
- Aggregated counts via `GET /api/read-state/unreads` (server-side computation of counts, mention flags, `last_message_times`, and `last_read_ats`)
**State Tracking Keys (Frontend)**: Generated by `getStateKey()` for message times (sidebar sorting):
- Channels: `channel-{channel_key}`

View File

@@ -1,3 +1,49 @@
## [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
Feature: BYOPacket for analysis
Feature: Add room activity to stats view
Bugfix: Handle Heltec v3 serial noise
Misc: Swap repeaters and room servers for better ordering
## [3.5.0] - 2026-03-19
Feature: Add room server alpha support
Feature: Add option to force-reset node clock when it's too far ahead
Feature: DMs auto-retry before resorting to flood
Feature: Add impulse zero-hop advert
Feature: Utilize PATH packets to correctly source a contact's route
Feature: Metrics view on raw packet pane
Feature: Metric, Imperial, and Smoots are now selectable for distance display
Feature: Allow favorites to be sorted
Feature: Add multi-ack support
Feature: Password-remember checkbox on repeaters + room servers
Bugfix: Serialize radio disconnect in a lock
Bugfix: Fix contact bar layout issues
Bugfix: Fix sidebar ordering for contacts by advert recency
Bugfix: Fix version reporting in community MQTT
Bugfix: Fix Apprise duplicate names
Bugfix: Be better about identity resolution in the stats pane
Misc: Docs, test, and performance enhancements
Misc: Don't prompt "Are you sure" when leaving an unedited interation
Misc: Log node time on startup
Misc: Improve community MQTT error bubble-up
Misc: Unread DMs always have a red unread counter
Misc: Improve information in the debug view to show DB status
## [3.4.1] - 2026-03-16
Bugfix: Improve handling of version information on prebuilt bundles

View File

@@ -330,7 +330,7 @@ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
</details>
### meshcore (2.2.29) — MIT
### meshcore (2.3.2) — MIT
<details>
<summary>Full license text</summary>
@@ -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>

View File

@@ -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.
@@ -192,7 +194,7 @@ $env:MESHCORE_SERIAL_PORT="COM8" # or your COM port
uv run uvicorn app.main:app --host 0.0.0.0 --port 8000
```
If you enable Basic Auth, protect the app with HTTPS. HTTP Basic credentials are not safe on plain HTTP.
If you enable Basic Auth, protect the app with HTTPS. HTTP Basic credentials are not safe on plain HTTP. Also note that the app's permissive CORS policy is a deliberate trusted-network tradeoff, so cross-origin browser JavaScript is not a reliable way to use that Basic Auth gate.
## Where To Go Next

View File

@@ -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

View File

@@ -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.
@@ -113,16 +113,16 @@ app/
### Read/unread state
- Server is source of truth (`contacts.last_read_at`, `channels.last_read_at`).
- `GET /api/read-state/unreads` returns counts, mention flags, and `last_message_times`.
- `GET /api/read-state/unreads` returns counts, mention flags, `last_message_times`, and `last_read_ats`.
### DM ingest + ACKs
- `services/dm_ingest.py` is the one place that should decide fallback-context resolution, DM dedup/reconciliation, and packet-linked vs. content-based storage behavior.
- `CONTACT_MSG_RECV` is a fallback path, not a parallel source of truth. If you change DM storage behavior, trace both `event_handlers.py` and `packet_processor.py`.
- DM ACK tracking is an in-memory pending/buffered map in `services/dm_ack_tracker.py`, with periodic expiry from `radio_sync.py`.
- Outgoing DMs send once inline, store/broadcast immediately after the first successful `MSG_SENT`, then may retry up to 2 more times in the background if still unacked.
- Outgoing DMs send once inline, store/broadcast immediately after the first successful `MSG_SENT`, then may retry up to 2 more times in the background only when the initial `MSG_SENT` result includes an expected ACK code and the message remains unacked.
- DM retry timing follows the firmware-provided `suggested_timeout` from `PACKET_MSG_SENT`; do not replace it with a fixed app timeout unless you intentionally want more aggressive duplicate-prone retries.
- Direct-message send behavior is intended to emulate `meshcore_py.commands.send_msg_with_retry(...)`: stage the effective contact route on the radio, send, wait for ACK, and on the final retry force flood via `reset_path(...)`.
- Direct-message send behavior is intended to emulate `meshcore_py.commands.send_msg_with_retry(...)` when the radio provides an expected ACK code: stage the effective contact route on the radio, send, wait for ACK, and on the final retry force flood via `reset_path(...)`.
- Non-final DM attempts use the contact's effective route (`override > direct > flood`). The final retry is intentionally sent as flood even when a routing override exists.
- DM ACK state is terminal on first ACK. Retry attempts may register multiple expected ACK codes for the same message, but sibling pending codes are cleared once one ACK wins so a DM should not accrue multiple delivery confirmations from retries.
- ACKs are delivery state, not routing state. Bundled ACKs inside PATH packets still satisfy pending DM sends, but ACK history does not feed contact route learning.
@@ -188,6 +188,7 @@ app/
- `POST /contacts/{public_key}/command`
- `POST /contacts/{public_key}/routing-override`
- `POST /contacts/{public_key}/trace`
- `POST /contacts/{public_key}/path-discovery` — discover forward/return paths, persist the learned direct route, and sync it back to the radio best-effort
- `POST /contacts/{public_key}/repeater/login`
- `POST /contacts/{public_key}/repeater/status`
- `POST /contacts/{public_key}/repeater/lpp-telemetry`
@@ -219,7 +220,7 @@ app/
- `POST /packets/maintenance`
### Read state
- `GET /read-state/unreads`
- `GET /read-state/unreads` — counts, mention flags, `last_message_times`, and `last_read_ats`
- `POST /read-state/mark-all-read`
### Settings

View File

@@ -44,6 +44,7 @@ class MessageAckedPayload(TypedDict):
message_id: int
ack_count: int
paths: NotRequired[list[MessagePath]]
packet_id: NotRequired[int | None]
class ToastPayload(TypedDict):

View File

@@ -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

View File

@@ -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()}"

View File

@@ -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:
@@ -82,6 +84,21 @@ class FanoutManager:
def __init__(self) -> None:
self._modules: dict[str, tuple[FanoutModule, dict]] = {} # id -> (module, scope)
self._restart_locks: dict[str, asyncio.Lock] = {}
self._bots_disabled_until_restart = False
def get_bots_disabled_source(self) -> str | None:
"""Return why bot modules are unavailable, if at all."""
from app.config import settings as server_settings
if server_settings.disable_bots:
return "env"
if self._bots_disabled_until_restart:
return "until_restart"
return None
def bots_disabled_effective(self) -> bool:
"""Return True when bot modules should be treated as unavailable."""
return self.get_bots_disabled_source() is not None
async def load_from_db(self) -> None:
"""Read enabled fanout_configs and instantiate modules."""
@@ -99,13 +116,14 @@ class FanoutManager:
config_blob = cfg["config"]
scope = cfg["scope"]
# Skip bot modules when bots are disabled server-wide
if config_type == "bot":
from app.config import settings as server_settings
if server_settings.disable_bots:
logger.info("Skipping bot module %s (bots disabled by server config)", config_id)
return
# Skip bot modules when bots are disabled server-wide or until restart.
if config_type == "bot" and self.bots_disabled_effective():
logger.info(
"Skipping bot module %s (bots disabled: %s)",
config_id,
self.get_bots_disabled_source(),
)
return
cls = _MODULE_TYPES.get(config_type)
if cls is None:
@@ -240,6 +258,26 @@ class FanoutManager:
}
return result
async def disable_bots_until_restart(self) -> str:
"""Stop active bot modules and prevent them from starting again until restart."""
source = self.get_bots_disabled_source()
if source == "env":
return source
self._bots_disabled_until_restart = True
from app.repository.fanout import _configs_cache
bot_ids = [
config_id
for config_id in list(self._modules)
if _configs_cache.get(config_id, {}).get("type") == "bot"
]
for config_id in bot_ids:
await self.remove_config(config_id)
return "until_restart"
# Module-level singleton
fanout_manager = FanoutManager()

320
app/fanout/map_upload.py Normal file
View 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"

View File

@@ -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,

View File

@@ -139,6 +139,18 @@ def register_frontend_static_routes(app: FastAPI, frontend_dir: Path) -> bool:
@app.get("/{path:path}")
async def serve_frontend(path: str):
"""Serve frontend files, falling back to index.html for SPA routing."""
if path == "api" or path.startswith("api/"):
return JSONResponse(
status_code=404,
content={
"detail": (
"API endpoint not found. If you are seeing this in response to a "
"frontend request, you may be running a newer frontend with an older "
"backend or vice versa. A full update is suggested."
)
},
)
file_path = (frontend_dir / path).resolve()
try:
file_path.relative_to(frontend_dir)

View File

@@ -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

View File

@@ -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()

View File

@@ -353,6 +353,13 @@ async def run_migrations(conn: aiosqlite.Connection) -> int:
await set_version(conn, 45)
applied += 1
# Migration 46: Clean orphaned contact child rows left by old prefix promotion
if version < 46:
logger.info("Applying migration 46: clean orphaned contact child rows")
await _migrate_046_cleanup_orphaned_contact_child_rows(conn)
await set_version(conn, 46)
applied += 1
if applied > 0:
logger.info(
"Applied %d migration(s), schema now at version %d", applied, await get_version(conn)
@@ -2773,3 +2780,91 @@ async def _migrate_045_rebuild_contacts_direct_route_columns(conn: aiosqlite.Con
await conn.execute("DROP TABLE contacts")
await conn.execute("ALTER TABLE contacts_new RENAME TO contacts")
await conn.commit()
async def _migrate_046_cleanup_orphaned_contact_child_rows(conn: aiosqlite.Connection) -> None:
"""Move uniquely resolvable orphan contact child rows onto full contacts, drop the rest."""
existing_tables_cursor = await conn.execute("SELECT name FROM sqlite_master WHERE type='table'")
existing_tables = {row[0] for row in await existing_tables_cursor.fetchall()}
if "contacts" not in existing_tables:
await conn.commit()
return
child_tables = [
table
for table in ("contact_name_history", "contact_advert_paths")
if table in existing_tables
]
if not child_tables:
await conn.commit()
return
orphan_keys: set[str] = set()
for table in child_tables:
cursor = await conn.execute(
f"""
SELECT DISTINCT child.public_key
FROM {table} child
LEFT JOIN contacts c ON c.public_key = child.public_key
WHERE c.public_key IS NULL
"""
)
orphan_keys.update(row[0] for row in await cursor.fetchall())
for orphan_key in sorted(orphan_keys, key=len, reverse=True):
match_cursor = await conn.execute(
"""
SELECT public_key
FROM contacts
WHERE length(public_key) = 64
AND public_key LIKE ? || '%'
ORDER BY public_key
""",
(orphan_key.lower(),),
)
matches = [row[0] for row in await match_cursor.fetchall()]
resolved_key = matches[0] if len(matches) == 1 else None
if resolved_key is not None:
if "contact_name_history" in child_tables:
await conn.execute(
"""
INSERT INTO contact_name_history (public_key, name, first_seen, last_seen)
SELECT ?, name, first_seen, last_seen
FROM contact_name_history
WHERE public_key = ?
ON CONFLICT(public_key, name) DO UPDATE SET
first_seen = MIN(contact_name_history.first_seen, excluded.first_seen),
last_seen = MAX(contact_name_history.last_seen, excluded.last_seen)
""",
(resolved_key, orphan_key),
)
if "contact_advert_paths" in child_tables:
await conn.execute(
"""
INSERT INTO contact_advert_paths
(public_key, path_hex, path_len, first_seen, last_seen, heard_count)
SELECT ?, path_hex, path_len, first_seen, last_seen, heard_count
FROM contact_advert_paths
WHERE public_key = ?
ON CONFLICT(public_key, path_hex, path_len) DO UPDATE SET
first_seen = MIN(contact_advert_paths.first_seen, excluded.first_seen),
last_seen = MAX(contact_advert_paths.last_seen, excluded.last_seen),
heard_count = contact_advert_paths.heard_count + excluded.heard_count
""",
(resolved_key, orphan_key),
)
if "contact_name_history" in child_tables:
await conn.execute(
"DELETE FROM contact_name_history WHERE public_key = ?",
(orphan_key,),
)
if "contact_advert_paths" in child_tables:
await conn.execute(
"DELETE FROM contact_advert_paths WHERE public_key = ?",
(orphan_key,),
)
await conn.commit()

View File

@@ -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
@@ -413,6 +413,10 @@ class Message(BaseModel):
acked: int = 0
sender_name: str | None = None
channel_name: str | None = None
packet_id: int | None = Field(
default=None,
description="Representative raw packet row ID when archival raw bytes exist",
)
class MessagesAroundResponse(BaseModel):
@@ -458,6 +462,21 @@ class RawPacketBroadcast(BaseModel):
decrypted_info: RawPacketDecryptedInfo | None = None
class RawPacketDetail(BaseModel):
"""Stored raw-packet detail returned by the packet API."""
id: int
timestamp: int
data: str = Field(description="Hex-encoded packet data")
payload_type: str = Field(description="Packet type name (e.g. GROUP_TEXT, ADVERT)")
snr: float | None = Field(default=None, description="Signal-to-noise ratio in dB if available")
rssi: int | None = Field(
default=None, description="Received signal strength in dBm if available"
)
decrypted: bool = False
decrypted_info: RawPacketDecryptedInfo | None = None
class SendMessageRequest(BaseModel):
text: str = Field(min_length=1)
@@ -814,4 +833,5 @@ class StatisticsResponse(BaseModel):
total_outgoing: int
contacts_heard: ContactActivityCounts
repeaters_heard: ContactActivityCounts
known_channels_active: ContactActivityCounts
path_hash_width_24h: PathHashWidthStats

View File

@@ -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

View File

@@ -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]:

View File

@@ -431,6 +431,43 @@ class ContactRepository:
Returns the placeholder public keys that were merged into the full key.
"""
async def migrate_child_rows(old_key: str, new_key: str) -> None:
await db.conn.execute(
"""
INSERT INTO contact_name_history (public_key, name, first_seen, last_seen)
SELECT ?, name, first_seen, last_seen
FROM contact_name_history
WHERE public_key = ?
ON CONFLICT(public_key, name) DO UPDATE SET
first_seen = MIN(contact_name_history.first_seen, excluded.first_seen),
last_seen = MAX(contact_name_history.last_seen, excluded.last_seen)
""",
(new_key, old_key),
)
await db.conn.execute(
"""
INSERT INTO contact_advert_paths
(public_key, path_hex, path_len, first_seen, last_seen, heard_count)
SELECT ?, path_hex, path_len, first_seen, last_seen, heard_count
FROM contact_advert_paths
WHERE public_key = ?
ON CONFLICT(public_key, path_hex, path_len) DO UPDATE SET
first_seen = MIN(contact_advert_paths.first_seen, excluded.first_seen),
last_seen = MAX(contact_advert_paths.last_seen, excluded.last_seen),
heard_count = contact_advert_paths.heard_count + excluded.heard_count
""",
(new_key, old_key),
)
await db.conn.execute(
"DELETE FROM contact_name_history WHERE public_key = ?",
(old_key,),
)
await db.conn.execute(
"DELETE FROM contact_advert_paths WHERE public_key = ?",
(old_key,),
)
normalized_full_key = full_key.lower()
cursor = await db.conn.execute(
"""
@@ -467,6 +504,8 @@ class ContactRepository:
if (match_row["match_count"] if match_row is not None else 0) != 1:
continue
await migrate_child_rows(old_key, normalized_full_key)
if full_exists:
await db.conn.execute(
"""

View File

@@ -331,6 +331,12 @@ class MessageRepository:
@staticmethod
def _row_to_message(row: Any) -> Message:
"""Convert a database row to a Message model."""
packet_id = None
if hasattr(row, "keys"):
row_keys = row.keys()
if "packet_id" in row_keys:
packet_id = row["packet_id"]
return Message(
id=row["id"],
type=row["type"],
@@ -345,6 +351,14 @@ class MessageRepository:
outgoing=bool(row["outgoing"]),
acked=row["acked"],
sender_name=row["sender_name"],
packet_id=packet_id,
)
@staticmethod
def _message_select(message_alias: str = "messages") -> str:
return (
f"{message_alias}.*, "
f"(SELECT MIN(id) FROM raw_packets WHERE message_id = {message_alias}.id) AS packet_id"
)
@staticmethod
@@ -363,7 +377,7 @@ class MessageRepository:
) -> list[Message]:
search_query = MessageRepository._parse_search_query(q) if q else None
query = (
"SELECT messages.* FROM messages "
f"SELECT {MessageRepository._message_select('messages')} FROM messages "
"LEFT JOIN contacts ON messages.type = 'PRIV' "
"AND LOWER(messages.conversation_key) = LOWER(contacts.public_key) "
"LEFT JOIN channels ON messages.type = 'CHAN' "
@@ -470,7 +484,8 @@ class MessageRepository:
# 1. Get the target message (must satisfy filters if provided)
target_cursor = await db.conn.execute(
f"SELECT * FROM messages WHERE id = ? AND {where_sql}",
f"SELECT {MessageRepository._message_select('messages')} "
f"FROM messages WHERE id = ? AND {where_sql}",
(message_id, *base_params),
)
target_row = await target_cursor.fetchone()
@@ -481,7 +496,7 @@ class MessageRepository:
# 2. Get context_size+1 messages before target (DESC)
before_query = f"""
SELECT * FROM messages WHERE {where_sql}
SELECT {MessageRepository._message_select("messages")} FROM messages WHERE {where_sql}
AND (received_at < ? OR (received_at = ? AND id < ?))
ORDER BY received_at DESC, id DESC LIMIT ?
"""
@@ -500,7 +515,7 @@ class MessageRepository:
# 3. Get context_size+1 messages after target (ASC)
after_query = f"""
SELECT * FROM messages WHERE {where_sql}
SELECT {MessageRepository._message_select("messages")} FROM messages WHERE {where_sql}
AND (received_at > ? OR (received_at = ? AND id > ?))
ORDER BY received_at ASC, id ASC LIMIT ?
"""
@@ -545,7 +560,7 @@ class MessageRepository:
async def get_by_id(message_id: int) -> "Message | None":
"""Look up a message by its ID."""
cursor = await db.conn.execute(
"SELECT * FROM messages WHERE id = ?",
f"SELECT {MessageRepository._message_select('messages')} FROM messages WHERE id = ?",
(message_id,),
)
row = await cursor.fetchone()
@@ -554,6 +569,12 @@ class MessageRepository:
return MessageRepository._row_to_message(row)
@staticmethod
async def delete_by_id(message_id: int) -> None:
"""Delete a message row by ID."""
await db.conn.execute("DELETE FROM messages WHERE id = ?", (message_id,))
await db.conn.commit()
@staticmethod
async def get_by_content(
msg_type: str,
@@ -564,7 +585,9 @@ class MessageRepository:
) -> "Message | None":
"""Look up a message by its unique content fields."""
query = """
SELECT * FROM messages
SELECT messages.*,
(SELECT MIN(id) FROM raw_packets WHERE message_id = messages.id) AS packet_id
FROM messages
WHERE type = ? AND conversation_key = ? AND text = ?
AND (sender_timestamp = ? OR (sender_timestamp IS NULL AND ? IS NULL))
"""
@@ -700,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,

View File

@@ -121,6 +121,18 @@ class RawPacketRepository:
return None
return row["message_id"]
@staticmethod
async def get_by_id(packet_id: int) -> tuple[int, bytes, int, int | None] | None:
"""Return a raw packet row as (id, data, timestamp, message_id)."""
cursor = await db.conn.execute(
"SELECT id, data, timestamp, message_id FROM raw_packets WHERE id = ?",
(packet_id,),
)
row = await cursor.fetchone()
if not row:
return None
return (row["id"], bytes(row["data"]), row["timestamp"], row["message_id"])
@staticmethod
async def prune_old_undecrypted(max_age_days: int) -> int:
"""Delete undecrypted packets older than max_age_days. Returns count deleted."""

View File

@@ -270,6 +270,30 @@ class StatisticsRepository:
"last_week": row["last_week"] or 0,
}
@staticmethod
async def _known_channels_active() -> dict[str, int]:
"""Count distinct known channel keys with channel traffic in each time window."""
now = int(time.time())
cursor = await db.conn.execute(
"""
SELECT
COUNT(DISTINCT CASE WHEN m.received_at >= ? THEN m.conversation_key END) AS last_hour,
COUNT(DISTINCT CASE WHEN m.received_at >= ? THEN m.conversation_key END) AS last_24_hours,
COUNT(DISTINCT CASE WHEN m.received_at >= ? THEN m.conversation_key END) AS last_week
FROM messages m
INNER JOIN channels c ON UPPER(m.conversation_key) = UPPER(c.key)
WHERE m.type = 'CHAN'
""",
(now - SECONDS_1H, now - SECONDS_24H, now - SECONDS_7D),
)
row = await cursor.fetchone()
assert row is not None
return {
"last_hour": row["last_hour"] or 0,
"last_24_hours": row["last_24_hours"] or 0,
"last_week": row["last_week"] or 0,
}
@staticmethod
async def _path_hash_width_24h() -> dict[str, int | float]:
"""Count parsed raw packets from the last 24h by hop hash width."""
@@ -396,6 +420,7 @@ class StatisticsRepository:
# Activity windows
contacts_heard = await StatisticsRepository._activity_counts(contact_type=2, exclude=True)
repeaters_heard = await StatisticsRepository._activity_counts(contact_type=2)
known_channels_active = await StatisticsRepository._known_channels_active()
path_hash_width_24h = await StatisticsRepository._path_hash_width_24h()
return {
@@ -411,5 +436,6 @@ class StatisticsRepository:
"total_outgoing": total_outgoing,
"contacts_heard": contacts_heard,
"repeaters_heard": repeaters_heard,
"known_channels_active": known_channels_active,
"path_hash_width_24h": path_hash_width_24h,
}

View File

@@ -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:

View File

@@ -10,7 +10,7 @@ from pydantic import BaseModel, Field
from app.config import get_recent_log_lines, settings
from app.radio_sync import get_contacts_selected_for_radio_sync, get_radio_channel_limit
from app.repository import MessageRepository
from app.repository import MessageRepository, StatisticsRepository
from app.routers.health import HealthResponse, build_health_data
from app.services.radio_runtime import radio_runtime
from app.version_info import get_app_build_info, git_output
@@ -87,11 +87,18 @@ class DebugRadioProbe(BaseModel):
channels: DebugChannelAudit | None = None
class DebugDatabaseInfo(BaseModel):
total_dms: int
total_channel_messages: int
total_outgoing: int
class DebugSnapshotResponse(BaseModel):
captured_at: str
application: DebugApplicationInfo
health: HealthResponse
runtime: DebugRuntimeInfo
database: DebugDatabaseInfo
radio_probe: DebugRadioProbe
logs: list[str]
@@ -258,6 +265,7 @@ async def _probe_radio() -> DebugRadioProbe:
async def debug_support_snapshot() -> DebugSnapshotResponse:
"""Return a support/debug snapshot with recent logs and live radio state."""
health_data = await build_health_data(radio_runtime.is_connected, radio_runtime.connection_info)
statistics = await StatisticsRepository.get_all()
radio_probe = await _probe_radio()
channels_with_incoming_messages = (
await MessageRepository.count_channels_with_incoming_messages()
@@ -282,6 +290,11 @@ async def debug_support_snapshot() -> DebugSnapshotResponse:
"force_channel_slot_reconfigure": settings.force_channel_slot_reconfigure,
},
),
database=DebugDatabaseInfo(
total_dms=statistics["total_dms"],
total_channel_messages=statistics["total_channel_messages"],
total_outgoing=statistics["total_outgoing"],
),
radio_probe=radio_probe,
logs=[*LOG_COPY_BOUNDARY_PREFIX, *get_recent_log_lines(limit=1000)],
)

View File

@@ -9,14 +9,14 @@ import string
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel, Field
from app.config import settings as server_settings
from app.fanout.bot_exec import _analyze_bot_signature
from app.fanout.manager import fanout_manager
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"):
@@ -325,6 +350,15 @@ def _enforce_scope(config_type: str, scope: dict) -> dict:
return {"messages": messages, "raw_packets": raw_packets}
def _bot_system_disabled_detail() -> str | None:
source = fanout_manager.get_bots_disabled_source()
if source == "env":
return "Bot system disabled by server configuration (MESHCORE_DISABLE_BOTS)"
if source == "until_restart":
return "Bot system disabled until the server restarts"
return None
@router.get("")
async def list_fanout_configs() -> list[dict]:
"""List all fanout configs."""
@@ -340,8 +374,10 @@ async def create_fanout_config(body: FanoutConfigCreate) -> dict:
detail=f"Invalid type '{body.type}'. Must be one of: {', '.join(sorted(_VALID_TYPES))}",
)
if body.type == "bot" and server_settings.disable_bots:
raise HTTPException(status_code=403, detail="Bot system disabled by server configuration")
if body.type == "bot":
disabled_detail = _bot_system_disabled_detail()
if disabled_detail:
raise HTTPException(status_code=403, detail=disabled_detail)
normalized_config = _validate_and_normalize_config(body.type, body.config)
scope = _enforce_scope(body.type, body.scope)
@@ -356,8 +392,6 @@ async def create_fanout_config(body: FanoutConfigCreate) -> dict:
# Start the module if enabled
if cfg["enabled"]:
from app.fanout.manager import fanout_manager
await fanout_manager.reload_config(cfg["id"])
logger.info("Created fanout config %s (type=%s, name=%s)", cfg["id"], body.type, body.name)
@@ -371,8 +405,10 @@ async def update_fanout_config(config_id: str, body: FanoutConfigUpdate) -> dict
if existing is None:
raise HTTPException(status_code=404, detail="Fanout config not found")
if existing["type"] == "bot" and server_settings.disable_bots:
raise HTTPException(status_code=403, detail="Bot system disabled by server configuration")
if existing["type"] == "bot":
disabled_detail = _bot_system_disabled_detail()
if disabled_detail:
raise HTTPException(status_code=403, detail=disabled_detail)
kwargs = {}
if body.name is not None:
@@ -390,8 +426,6 @@ async def update_fanout_config(config_id: str, body: FanoutConfigUpdate) -> dict
raise HTTPException(status_code=404, detail="Fanout config not found")
# Reload the module to pick up changes
from app.fanout.manager import fanout_manager
await fanout_manager.reload_config(config_id)
logger.info("Updated fanout config %s", config_id)
@@ -406,10 +440,24 @@ async def delete_fanout_config(config_id: str) -> dict:
raise HTTPException(status_code=404, detail="Fanout config not found")
# Stop the module first
from app.fanout.manager import fanout_manager
await fanout_manager.remove_config(config_id)
await FanoutConfigRepository.delete(config_id)
logger.info("Deleted fanout config %s", config_id)
return {"deleted": True}
@router.post("/bots/disable-until-restart")
async def disable_bots_until_restart() -> dict:
"""Stop active bot modules and prevent them from running again until restart."""
source = await fanout_manager.disable_bots_until_restart()
from app.services.radio_runtime import radio_runtime as radio_manager
from app.websocket import broadcast_health
broadcast_health(radio_manager.is_connected, radio_manager.connection_info)
return {
"status": "ok",
"bots_disabled": True,
"bots_disabled_source": source,
}

View File

@@ -1,5 +1,5 @@
import os
from typing import Any
from typing import Any, Literal
from fastapi import APIRouter
from pydantic import BaseModel
@@ -37,6 +37,8 @@ class HealthResponse(BaseModel):
oldest_undecrypted_timestamp: int | None
fanout_statuses: dict[str, dict[str, str]] = {}
bots_disabled: bool = False
bots_disabled_source: Literal["env", "until_restart"] | None = None
basic_auth_enabled: bool = False
def _clean_optional_str(value: object) -> str | None:
@@ -46,6 +48,11 @@ def _clean_optional_str(value: object) -> str | None:
return cleaned or None
def _read_optional_bool_setting(name: str) -> bool:
value = getattr(settings, name, False)
return value if isinstance(value, bool) else False
async def build_health_data(radio_connected: bool, connection_info: str | None) -> dict:
"""Build the health status payload used by REST endpoint and WebSocket broadcasts."""
app_build_info = get_app_build_info()
@@ -64,10 +71,14 @@ async def build_health_data(radio_connected: bool, connection_info: str | None)
# Fanout module statuses
fanout_statuses: dict[str, Any] = {}
bots_disabled_source = "env" if _read_optional_bool_setting("disable_bots") else None
try:
from app.fanout.manager import fanout_manager
fanout_statuses = fanout_manager.get_statuses()
manager_bots_disabled_source = fanout_manager.get_bots_disabled_source()
if manager_bots_disabled_source is not None:
bots_disabled_source = manager_bots_disabled_source
except Exception:
pass
@@ -118,7 +129,9 @@ async def build_health_data(radio_connected: bool, connection_info: str | None)
"database_size_mb": db_size_mb,
"oldest_undecrypted_timestamp": oldest_ts,
"fanout_statuses": fanout_statuses,
"bots_disabled": settings.disable_bots,
"bots_disabled": bots_disabled_source is not None,
"bots_disabled_source": bots_disabled_source,
"basic_auth_enabled": _read_optional_bool_setting("basic_auth_enabled"),
}

View File

@@ -8,8 +8,9 @@ from pydantic import BaseModel, Field
from app.database import db
from app.decoder import parse_packet, try_decrypt_packet_with_channel_key
from app.models import RawPacketDecryptedInfo, RawPacketDetail
from app.packet_processor import create_message_from_decrypted, run_historical_dm_decryption
from app.repository import ChannelRepository, RawPacketRepository
from app.repository import ChannelRepository, MessageRepository, RawPacketRepository
from app.websocket import broadcast_success
logger = logging.getLogger(__name__)
@@ -102,6 +103,45 @@ async def get_undecrypted_count() -> dict:
return {"count": count}
@router.get("/{packet_id}", response_model=RawPacketDetail)
async def get_raw_packet(packet_id: int) -> RawPacketDetail:
"""Fetch one stored raw packet by row ID for on-demand inspection."""
packet_row = await RawPacketRepository.get_by_id(packet_id)
if packet_row is None:
raise HTTPException(status_code=404, detail="Raw packet not found")
stored_packet_id, packet_data, packet_timestamp, message_id = packet_row
packet_info = parse_packet(packet_data)
payload_type_name = packet_info.payload_type.name if packet_info else "Unknown"
decrypted_info: RawPacketDecryptedInfo | None = None
if message_id is not None:
message = await MessageRepository.get_by_id(message_id)
if message is not None:
if message.type == "CHAN":
channel = await ChannelRepository.get_by_key(message.conversation_key)
decrypted_info = RawPacketDecryptedInfo(
channel_name=channel.name if channel else None,
sender=message.sender_name,
channel_key=message.conversation_key,
contact_key=message.sender_key,
)
else:
decrypted_info = RawPacketDecryptedInfo(
sender=message.sender_name,
contact_key=message.conversation_key,
)
return RawPacketDetail(
id=stored_packet_id,
timestamp=packet_timestamp,
data=packet_data.hex(),
payload_type=payload_type_name,
decrypted=message_id is not None,
decrypted_info=decrypted_info,
)
@router.post("/decrypt/historical", response_model=DecryptResult)
async def decrypt_historical_packets(
request: DecryptRequest, background_tasks: BackgroundTasks, response: Response

View File

@@ -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."
)

View File

@@ -238,6 +238,7 @@ async def _store_direct_message(
sender_key=sender_key,
outgoing=outgoing,
sender_name=sender_name,
packet_id=packet_id,
)
broadcast_message(message=message, broadcast_fn=broadcast_fn, realtime=realtime)

View File

@@ -13,7 +13,8 @@ from app.region_scope import normalize_region_scope
from app.repository import AppSettingsRepository, ContactRepository, MessageRepository
from app.services import dm_ack_tracker
from app.services.messages import (
build_message_model,
broadcast_message,
build_stored_outgoing_channel_message,
create_outgoing_channel_message,
create_outgoing_direct_message,
increment_ack_and_broadcast,
@@ -586,6 +587,23 @@ async def send_channel_message_to_channel(
requested_timestamp=sent_at,
)
timestamp_bytes = sender_timestamp.to_bytes(4, "little")
outgoing_message = await create_outgoing_channel_message(
conversation_key=channel_key_upper,
text=text_with_sender,
sender_timestamp=sender_timestamp,
received_at=sent_at,
sender_name=radio_name or None,
sender_key=our_public_key,
channel_name=channel.name,
broadcast_fn=broadcast_fn,
broadcast=False,
message_repository=message_repository,
)
if outgoing_message is None:
raise HTTPException(
status_code=500,
detail="Failed to store outgoing message - unexpected duplicate",
)
result = await send_channel_message_with_effective_scope(
mc=mc,
@@ -611,23 +629,11 @@ async def send_channel_message_to_channel(
raise HTTPException(
status_code=500, detail=f"Failed to send message: {result.payload}"
)
outgoing_message = await create_outgoing_channel_message(
conversation_key=channel_key_upper,
text=text_with_sender,
sender_timestamp=sender_timestamp,
received_at=sent_at,
sender_name=radio_name or None,
sender_key=our_public_key,
channel_name=channel.name,
broadcast_fn=broadcast_fn,
message_repository=message_repository,
)
if outgoing_message is None:
raise HTTPException(
status_code=500,
detail="Failed to store outgoing message - unexpected duplicate",
)
except Exception:
if outgoing_message is not None:
await message_repository.delete_by_id(outgoing_message.id)
outgoing_message = None
raise
finally:
if sender_timestamp is not None:
await release_outgoing_sender_timestamp(
@@ -640,22 +646,19 @@ async def send_channel_message_to_channel(
if sent_at is None or sender_timestamp is None or outgoing_message is None:
raise HTTPException(status_code=500, detail="Failed to store outgoing message")
message_id = outgoing_message.id
acked_count, paths = await message_repository.get_ack_and_paths(message_id)
return build_message_model(
message_id=message_id,
msg_type="CHAN",
outgoing_message = await build_stored_outgoing_channel_message(
message_id=outgoing_message.id,
conversation_key=channel_key_upper,
text=text_with_sender,
sender_timestamp=sender_timestamp,
received_at=sent_at,
paths=paths,
outgoing=True,
acked=acked_count,
sender_name=radio_name or None,
sender_key=our_public_key,
channel_name=channel.name,
message_repository=message_repository,
)
broadcast_message(message=outgoing_message, broadcast_fn=broadcast_fn)
return outgoing_message
async def resend_channel_message_record(
@@ -705,6 +708,23 @@ async def resend_channel_message_record(
requested_timestamp=sent_at,
)
timestamp_bytes = sender_timestamp.to_bytes(4, "little")
new_message = await create_outgoing_channel_message(
conversation_key=message.conversation_key,
text=message.text,
sender_timestamp=sender_timestamp,
received_at=sent_at,
sender_name=radio_name or None,
sender_key=resend_public_key,
channel_name=channel.name,
broadcast_fn=broadcast_fn,
broadcast=False,
message_repository=message_repository,
)
if new_message is None:
raise HTTPException(
status_code=500,
detail="Failed to store resent message - unexpected duplicate",
)
result = await send_channel_message_with_effective_scope(
mc=mc,
@@ -729,26 +749,11 @@ async def resend_channel_message_record(
status_code=500,
detail=f"Failed to resend message: {result.payload}",
)
if new_timestamp:
if sent_at is None:
raise HTTPException(status_code=500, detail="Failed to assign resend timestamp")
new_message = await create_outgoing_channel_message(
conversation_key=message.conversation_key,
text=message.text,
sender_timestamp=sender_timestamp,
received_at=sent_at,
sender_name=radio_name or None,
sender_key=resend_public_key,
channel_name=channel.name,
broadcast_fn=broadcast_fn,
message_repository=message_repository,
)
if new_message is None:
raise HTTPException(
status_code=500,
detail="Failed to store resent message - unexpected duplicate",
)
except Exception:
if new_message is not None:
await message_repository.delete_by_id(new_message.id)
new_message = None
raise
finally:
if new_timestamp and sent_at is not None:
await release_outgoing_sender_timestamp(
@@ -762,6 +767,19 @@ async def resend_channel_message_record(
if sent_at is None or new_message is None:
raise HTTPException(status_code=500, detail="Failed to assign resend timestamp")
new_message = await build_stored_outgoing_channel_message(
message_id=new_message.id,
conversation_key=message.conversation_key,
text=message.text,
sender_timestamp=sender_timestamp,
received_at=sent_at,
sender_name=radio_name or None,
sender_key=resend_public_key,
channel_name=channel.name,
message_repository=message_repository,
)
broadcast_message(message=new_message, broadcast_fn=broadcast_fn)
logger.info(
"Resent channel message %d as new message %d to %s",
message.id,

View File

@@ -62,6 +62,7 @@ def build_message_model(
acked: int = 0,
sender_name: str | None = None,
channel_name: str | None = None,
packet_id: int | None = None,
) -> Message:
"""Build a Message model with the canonical backend payload shape."""
return Message(
@@ -79,6 +80,7 @@ def build_message_model(
acked=acked,
sender_name=sender_name,
channel_name=channel_name,
packet_id=packet_id,
)
@@ -96,11 +98,42 @@ def broadcast_message(
broadcast_fn("message", payload, realtime=realtime)
async def build_stored_outgoing_channel_message(
*,
message_id: int,
conversation_key: str,
text: str,
sender_timestamp: int,
received_at: int,
sender_name: str | None,
sender_key: str | None,
channel_name: str | None,
message_repository=MessageRepository,
) -> Message:
"""Build the current payload for a stored outgoing channel message."""
acked_count, paths = await message_repository.get_ack_and_paths(message_id)
return build_message_model(
message_id=message_id,
msg_type="CHAN",
conversation_key=conversation_key,
text=text,
sender_timestamp=sender_timestamp,
received_at=received_at,
paths=paths,
outgoing=True,
acked=acked_count,
sender_name=sender_name,
sender_key=sender_key,
channel_name=channel_name,
)
def broadcast_message_acked(
*,
message_id: int,
ack_count: int,
paths: list[MessagePath] | None,
packet_id: int | None,
broadcast_fn: BroadcastFn,
) -> None:
"""Broadcast a message_acked payload."""
@@ -110,6 +143,7 @@ def broadcast_message_acked(
"message_id": message_id,
"ack_count": ack_count,
"paths": [path.model_dump() for path in paths] if paths else [],
"packet_id": packet_id,
},
)
@@ -152,11 +186,16 @@ async def reconcile_duplicate_message(
else:
ack_count = existing_msg.acked
representative_packet_id = (
existing_msg.packet_id if existing_msg.packet_id is not None else packet_id
)
if existing_msg.outgoing or path is not None:
broadcast_message_acked(
message_id=existing_msg.id,
ack_count=ack_count,
paths=paths,
packet_id=representative_packet_id,
broadcast_fn=broadcast_fn,
)
@@ -277,6 +316,7 @@ async def create_message_from_decrypted(
sender_name=sender,
sender_key=resolved_sender_key,
channel_name=channel_name,
packet_id=packet_id,
),
broadcast_fn=broadcast_fn,
realtime=realtime,
@@ -428,6 +468,7 @@ async def create_outgoing_channel_message(
sender_key: str | None,
channel_name: str | None,
broadcast_fn: BroadcastFn,
broadcast: bool = True,
message_repository=MessageRepository,
) -> Message | None:
"""Store and broadcast an outgoing channel message."""
@@ -444,18 +485,17 @@ async def create_outgoing_channel_message(
if msg_id is None:
return None
message = build_message_model(
message = await build_stored_outgoing_channel_message(
message_id=msg_id,
msg_type="CHAN",
conversation_key=conversation_key,
text=text,
sender_timestamp=sender_timestamp,
received_at=received_at,
outgoing=True,
acked=0,
sender_name=sender_name,
sender_key=sender_key,
channel_name=channel_name,
message_repository=message_repository,
)
broadcast_message(message=message, broadcast_fn=broadcast_fn)
if broadcast:
broadcast_message(message=message, broadcast_fn=broadcast_fn)
return message

View File

@@ -18,15 +18,18 @@ services:
environment:
MESHCORE_DATABASE_PATH: data/meshcore.db
# Radio connection -- optional if you map just a single serial device above, as the app will autodetect
# Serial (USB)
# MESHCORE_SERIAL_PORT: /dev/ttyUSB0
# MESHCORE_SERIAL_BAUDRATE: 115200
# TCP
# MESHCORE_TCP_HOST: 192.168.1.100
# MESHCORE_TCP_PORT: 4000
# Security
# MESHCORE_DISABLE_BOTS: "true"
# MESHCORE_BASIC_AUTH_USERNAME: changeme
# MESHCORE_BASIC_AUTH_PASSWORD: changeme
# Logging
# MESHCORE_LOG_LEVEL: INFO
restart: unless-stopped

View File

@@ -198,9 +198,9 @@ High-level state is delegated to hooks:
- `useContactsAndChannels`: contact/channel lists, creation, deletion
- `useConversationRouter`: URL hash → active conversation routing
- `useConversationNavigation`: search target, conversation selection reset, and info-pane state
- `useConversationActions`: send/resend/trace/block handlers and channel override updates
- `useConversationActions`: send/resend/trace/path-discovery/block handlers and channel override updates
- `useConversationMessages`: conversation switch loading, embedded conversation-scoped cache, jump-target loading, pagination, dedup/update helpers, reconnect reconciliation, and pending ACK buffering
- `useUnreadCounts`: unread counters, mention tracking, recent-sort timestamps
- `useUnreadCounts`: unread counters, mention tracking, recent-sort timestamps, and server `last_read_ats` boundaries
- `useRealtimeAppState`: typed WS event application, reconnect recovery, cache/unread coordination
- `useRepeaterDashboard`: repeater dashboard state (login, pane data/retries, console, actions)
@@ -331,6 +331,8 @@ Note: MQTT, bot, and community MQTT settings were migrated to the `fanout_config
`RawPacket.decrypted_info` includes `channel_key` and `contact_key` for MQTT topic routing.
`UnreadCounts` includes `counts`, `mentions`, `last_message_times`, and `last_read_ats`. The unread-boundary/jump-to-unread behavior uses the server-provided `last_read_ats` map keyed by `getStateKey(...)`.
## Contact Info Pane
Clicking a contact's avatar in `ChatHeader` or `MessageList` opens a `ContactInfoPane` sheet (right drawer) showing comprehensive contact details fetched from `GET /api/contacts/analytics` using either `?public_key=...` or `?name=...`:

View File

@@ -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" />

View File

@@ -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",

View File

@@ -1,7 +1,7 @@
{
"name": "remoteterm-meshcore-frontend",
"private": true,
"version": "3.4.1",
"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",

View File

@@ -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,

View File

@@ -15,6 +15,7 @@ import type {
MessagesAroundResponse,
MigratePreferencesRequest,
MigratePreferencesResponse,
RawPacket,
RadioAdvertMode,
RadioConfig,
RadioConfigUpdate,
@@ -247,6 +248,7 @@ export const api = {
),
// Packets
getPacket: (packetId: number) => fetchJson<RawPacket>(`/packets/${packetId}`),
getUndecryptedPacketCount: () => fetchJson<{ count: number }>('/packets/undecrypted/count'),
decryptHistoricalPackets: (params: {
key_type: 'channel' | 'contact';
@@ -341,6 +343,14 @@ export const api = {
fetchJson<{ deleted: boolean }>(`/fanout/${id}`, {
method: 'DELETE',
}),
disableBotsUntilRestart: () =>
fetchJson<{
status: string;
bots_disabled: boolean;
bots_disabled_source: 'env' | 'until_restart';
}>('/fanout/bots/disable-until-restart', {
method: 'POST',
}),
// Statistics
getStatistics: () => fetchJson<StatisticsResponse>('/statistics'),

View File

@@ -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';
@@ -6,6 +7,7 @@ import { ConversationPane } from './ConversationPane';
import { NewMessageModal } from './NewMessageModal';
import { ContactInfoPane } from './ContactInfoPane';
import { ChannelInfoPane } from './ChannelInfoPane';
import { SecurityWarningModal } from './SecurityWarningModal';
import { Toaster } from './ui/sonner';
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from './ui/sheet';
import {
@@ -88,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;
@@ -152,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"
@@ -195,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>
@@ -283,12 +305,9 @@ export function AppShell({
{...newMessageModalProps}
open={showNewMessage}
onClose={onCloseNewMessage}
onSelectConversation={(conv) => {
newMessageModalProps.onSelectConversation(conv);
onCloseNewMessage();
}}
/>
<SecurityWarningModal health={statusProps.health} />
<ContactInfoPane {...contactInfoPaneProps} />
<ChannelInfoPane {...channelInfoPaneProps} />
<Toaster position="top-right" />

View File

@@ -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>

View File

@@ -9,6 +9,7 @@ import { handleKeyboardActivate } from '../utils/a11y';
import { isPublicChannelKey } from '../utils/publicChannel';
import { stripRegionScopePrefix } from '../utils/regionScope';
import { isPrefixOnlyContact } from '../utils/pubkey';
import { cn } from '../lib/utils';
import { ContactAvatar } from './ContactAvatar';
import { ContactStatusInfo } from './ContactStatusInfo';
import type {
@@ -118,8 +119,15 @@ export function ChatHeader({
};
return (
<header className="conversation-header flex justify-between items-start px-4 py-2.5 border-b border-border gap-2">
<span className="flex min-w-0 flex-1 items-start gap-2">
<header
className={cn(
'conversation-header grid items-start gap-x-2 gap-y-0.5 border-b border-border px-4 py-2.5',
conversation.type === 'contact' && activeContact
? 'grid-cols-[minmax(0,1fr)_auto] min-[1100px]:grid-cols-[minmax(0,1fr)_auto_auto]'
: 'grid-cols-[minmax(0,1fr)_auto]'
)}
>
<span className="flex min-w-0 items-start gap-2">
{conversation.type === 'contact' && onOpenContactInfo && (
<button
type="button"
@@ -137,16 +145,31 @@ export function ChatHeader({
/>
</button>
)}
<span className="grid min-w-0 flex-1 grid-cols-1 gap-y-0.5 min-[1200px]:grid-cols-[minmax(0,1fr)_auto] min-[1200px]:items-baseline min-[1200px]:gap-x-2">
<span className="flex min-w-0 items-baseline gap-2 whitespace-nowrap">
<h2 className="min-w-0 shrink font-semibold text-base">
{titleClickable ? (
<button
type="button"
className="flex min-w-0 shrink items-center gap-1.5 text-left hover:text-primary transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring rounded-sm"
aria-label={`View info for ${conversation.name}`}
onClick={handleOpenConversationInfo}
>
<span className="flex min-w-0 flex-1 flex-col">
<span className="flex min-w-0 flex-wrap items-baseline gap-x-2 gap-y-0.5">
<span className="flex min-w-0 flex-1 items-baseline gap-2 whitespace-nowrap">
<h2 className="min-w-0 flex-shrink font-semibold text-base">
{titleClickable ? (
<button
type="button"
className="flex max-w-full min-w-0 items-center gap-1.5 overflow-hidden rounded-sm text-left transition-colors hover:text-primary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
aria-label={`View info for ${conversation.name}`}
onClick={handleOpenConversationInfo}
>
<span className="truncate">
{conversation.type === 'channel' &&
!conversation.name.startsWith('#') &&
activeChannel?.is_hashtag
? '#'
: ''}
{conversation.name}
</span>
<Info
className="h-3.5 w-3.5 flex-shrink-0 text-muted-foreground/80"
aria-hidden="true"
/>
</button>
) : (
<span className="truncate">
{conversation.type === 'channel' &&
!conversation.name.startsWith('#') &&
@@ -155,83 +178,74 @@ export function ChatHeader({
: ''}
{conversation.name}
</span>
<Info
className="h-3.5 w-3.5 flex-shrink-0 text-muted-foreground/80"
aria-hidden="true"
/>
)}
</h2>
{isPrivateChannel && !showKey ? (
<button
className="min-w-0 flex-shrink text-[11px] font-mono text-muted-foreground transition-colors hover:text-primary"
onClick={(e) => {
e.stopPropagation();
setShowKey(true);
}}
title="Reveal channel key"
>
Show Key
</button>
) : (
<span className="truncate">
{conversation.type === 'channel' &&
!conversation.name.startsWith('#') &&
activeChannel?.is_hashtag
? '#'
: ''}
{conversation.name}
<span
className="min-w-0 flex-1 truncate font-mono text-[11px] text-muted-foreground transition-colors hover:text-primary"
role="button"
tabIndex={0}
onKeyDown={handleKeyboardActivate}
onClick={(e) => {
e.stopPropagation();
navigator.clipboard.writeText(conversation.id);
toast.success(
conversation.type === 'channel'
? 'Channel key copied!'
: 'Contact key copied!'
);
}}
title="Click to copy"
aria-label={
conversation.type === 'channel' ? 'Copy channel key' : 'Copy contact key'
}
>
{conversation.type === 'channel'
? conversation.id.toLowerCase()
: conversation.id}
</span>
)}
</h2>
{isPrivateChannel && !showKey ? (
</span>
{conversation.type === 'channel' && activeFloodScopeDisplay && (
<button
className="min-w-0 flex-shrink text-[11px] font-mono text-muted-foreground transition-colors hover:text-primary"
onClick={(e) => {
e.stopPropagation();
setShowKey(true);
}}
title="Reveal channel key"
className="mt-0.5 flex basis-full items-center gap-1 text-left sm:hidden"
onClick={handleEditFloodScopeOverride}
title="Set regional override"
aria-label="Set regional override"
>
Show Key
<Globe2
className="h-3.5 w-3.5 flex-shrink-0 text-[hsl(var(--region-override))]"
aria-hidden="true"
/>
<span className="min-w-0 truncate text-[11px] font-medium text-[hsl(var(--region-override))]">
{activeFloodScopeDisplay}
</span>
</button>
) : (
<span
className="min-w-0 flex-1 truncate font-mono text-[11px] text-muted-foreground transition-colors hover:text-primary"
role="button"
tabIndex={0}
onKeyDown={handleKeyboardActivate}
onClick={(e) => {
e.stopPropagation();
navigator.clipboard.writeText(conversation.id);
toast.success(
conversation.type === 'channel' ? 'Room key copied!' : 'Contact key copied!'
);
}}
title="Click to copy"
aria-label={
conversation.type === 'channel' ? 'Copy channel key' : 'Copy contact key'
}
>
{conversation.type === 'channel' ? conversation.id.toLowerCase() : conversation.id}
</span>
)}
</span>
{conversation.type === 'contact' && activeContact && (
<span className="min-w-0 text-[11px] text-muted-foreground min-[1200px]:justify-self-end">
<ContactStatusInfo
contact={activeContact}
ourLat={config?.lat ?? null}
ourLon={config?.lon ?? null}
/>
</span>
)}
{conversation.type === 'channel' && activeFloodScopeDisplay && (
<button
className="mt-0.5 flex items-center gap-1 text-left sm:hidden"
onClick={handleEditFloodScopeOverride}
title="Set regional override"
aria-label="Set regional override"
>
<Globe2
className="h-3.5 w-3.5 flex-shrink-0 text-[hsl(var(--region-override))]"
aria-hidden="true"
/>
<span className="min-w-0 truncate text-[11px] font-medium text-[hsl(var(--region-override))]">
{activeFloodScopeDisplay}
</span>
</button>
)}
</span>
</span>
<div className="flex items-center justify-end gap-0.5 flex-shrink-0">
{conversation.type === 'contact' && activeContact && (
<div className="col-span-2 row-start-2 min-w-0 text-[11px] text-muted-foreground min-[1100px]:col-span-1 min-[1100px]:col-start-2 min-[1100px]:row-start-1">
<ContactStatusInfo
contact={activeContact}
ourLat={config?.lat ?? null}
ourLon={config?.lon ?? null}
/>
</div>
)}
<div className="flex items-center justify-end gap-0.5">
{conversation.type === 'contact' && !activeContactIsRoomServer && (
<button
className="p-1 rounded hover:bg-accent text-lg leading-none transition-colors disabled:cursor-not-allowed disabled:opacity-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"

View File

@@ -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>
))}

View File

@@ -261,6 +261,7 @@ export function ConversationPane({
key={activeConversation.id}
messages={messages}
contacts={contacts}
channels={channels}
loading={messagesLoading}
loadingOlder={loadingOlder}
hasOlderMessages={hasOlderMessages}

View File

@@ -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,16 +604,17 @@ 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.
<strong> Try word pairs</strong> will also try every combination of two dictionary words
concatenated together (e.g. "hello" + "world" = "#helloworld") after the single-word
dictionary pass; this can substantially increase search time.
<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.
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 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>

View File

@@ -8,19 +8,23 @@ import {
useState,
type ReactNode,
} from 'react';
import type { Contact, Message, MessagePath, RadioConfig } from '../types';
import type { Channel, Contact, Message, MessagePath, RadioConfig, RawPacket } from '../types';
import { CONTACT_TYPE_REPEATER, CONTACT_TYPE_ROOM } from '../types';
import { api } from '../api';
import { formatTime, parseSenderFromText } from '../utils/messageParser';
import { formatHopCounts, type SenderInfo } from '../utils/pathUtils';
import { getDirectContactRoute } from '../utils/pathUtils';
import { ContactAvatar } from './ContactAvatar';
import { PathModal } from './PathModal';
import { RawPacketInspectorDialog } from './RawPacketDetailModal';
import { toast } from './ui/sonner';
import { handleKeyboardActivate } from '../utils/a11y';
import { cn } from '@/lib/utils';
interface MessageListProps {
messages: Message[];
contacts: Contact[];
channels?: Channel[];
loading: boolean;
loadingOlder?: boolean;
hasOlderMessages?: boolean;
@@ -153,6 +157,8 @@ function HopCountBadge({ paths, onClick, variant }: HopCountBadgeProps) {
const RESEND_WINDOW_SECONDS = 30;
const CORRUPT_SENDER_LABEL = '<No name -- corrupt packet?>';
const ANALYZE_PACKET_NOTICE =
'This analyzer shows one stored full packet copy only. When multiple receives have identical payloads, the backend deduplicates them to a single stored packet and appends any additional receive paths onto the message path history instead of storing multiple full packet copies.';
function hasUnexpectedControlChars(text: string): boolean {
for (const char of text) {
@@ -173,6 +179,7 @@ function hasUnexpectedControlChars(text: string): boolean {
export function MessageList({
messages,
contacts,
channels = [],
loading,
loadingOlder = false,
hasOlderMessages = false,
@@ -199,10 +206,18 @@ export function MessageList({
paths: MessagePath[];
senderInfo: SenderInfo;
messageId?: number;
packetId?: number | null;
isOutgoingChan?: boolean;
} | null>(null);
const [resendableIds, setResendableIds] = useState<Set<number>>(new Set());
const resendTimersRef = useRef<Map<number, ReturnType<typeof setTimeout>>>(new Map());
const packetCacheRef = useRef<Map<number, RawPacket>>(new Map());
const [packetInspectorSource, setPacketInspectorSource] = useState<
| { kind: 'packet'; packet: RawPacket }
| { kind: 'loading'; message: string }
| { kind: 'unavailable'; message: string }
| null
>(null);
const [highlightedMessageId, setHighlightedMessageId] = useState<number | null>(null);
const [showJumpToUnread, setShowJumpToUnread] = useState(false);
const [jumpToUnreadDismissed, setJumpToUnreadDismissed] = useState(false);
@@ -221,6 +236,43 @@ export function MessageList({
// Track conversation key to detect when entire message set changes
const prevConvKeyRef = useRef<string | null>(null);
const handleAnalyzePacket = useCallback(async (message: Message) => {
if (message.packet_id == null) {
setPacketInspectorSource({
kind: 'unavailable',
message:
'No archival raw packet is available for this message, so packet analysis cannot be shown.',
});
return;
}
const cached = packetCacheRef.current.get(message.packet_id);
if (cached) {
setPacketInspectorSource({ kind: 'packet', packet: cached });
return;
}
setPacketInspectorSource({ kind: 'loading', message: 'Loading packet analysis...' });
try {
const packet = await api.getPacket(message.packet_id);
packetCacheRef.current.set(message.packet_id, packet);
setPacketInspectorSource({ kind: 'packet', packet });
} catch (error) {
const description = error instanceof Error ? error.message : 'Unknown error';
const isMissing = error instanceof Error && /not found/i.test(error.message);
if (!isMissing) {
toast.error('Failed to load raw packet', { description });
}
setPacketInspectorSource({
kind: 'unavailable',
message: isMissing
? 'The archival raw packet for this message is no longer available. It may have been purged from Settings > Database, so only the stored message and merged route history remain.'
: `Could not load the archival raw packet for this message: ${description}`,
});
}
}, []);
// Handle scroll position AFTER render
useLayoutEffect(() => {
if (!listRef.current) return;
@@ -833,6 +885,8 @@ export function MessageList({
setSelectedPath({
paths: msg.paths!,
senderInfo: getSenderInfo(msg, contact, directSenderName || sender),
messageId: msg.id,
packetId: msg.packet_id,
})
}
/>
@@ -859,6 +913,8 @@ export function MessageList({
setSelectedPath({
paths: msg.paths!,
senderInfo: getSenderInfo(msg, contact, directSenderName || sender),
messageId: msg.id,
packetId: msg.packet_id,
})
}
/>
@@ -879,6 +935,7 @@ export function MessageList({
paths: msg.paths!,
senderInfo: selfSenderInfo,
messageId: msg.id,
packetId: msg.packet_id,
isOutgoingChan: msg.type === 'CHAN' && !!onResendChannelMessage,
});
}}
@@ -900,6 +957,7 @@ export function MessageList({
paths: [],
senderInfo: selfSenderInfo,
messageId: msg.id,
packetId: msg.packet_id,
isOutgoingChan: true,
});
}}
@@ -997,9 +1055,31 @@ export function MessageList({
contacts={contacts}
config={config ?? null}
messageId={selectedPath.messageId}
packetId={selectedPath.packetId}
isOutgoingChan={selectedPath.isOutgoingChan}
isResendable={isSelectedMessageResendable}
onResend={onResendChannelMessage}
onAnalyzePacket={
selectedPath.packetId != null
? () => {
const message = messages.find((entry) => entry.id === selectedPath.messageId);
if (message) {
void handleAnalyzePacket(message);
}
}
: undefined
}
/>
)}
{packetInspectorSource && (
<RawPacketInspectorDialog
open={packetInspectorSource !== null}
onOpenChange={(isOpen) => !isOpen && setPacketInspectorSource(null)}
channels={channels}
source={packetInspectorSource}
title="Analyze Packet"
description="On-demand raw packet analysis for a message-backed archival packet."
notice={ANALYZE_PACKET_NOTICE}
/>
)}
</div>

View File

@@ -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>

View File

@@ -29,9 +29,11 @@ interface PathModalProps {
contacts: Contact[];
config: RadioConfig | null;
messageId?: number;
packetId?: number | null;
isOutgoingChan?: boolean;
isResendable?: boolean;
onResend?: (messageId: number, newTimestamp?: boolean) => void;
onAnalyzePacket?: () => void;
}
export function PathModal({
@@ -42,14 +44,17 @@ export function PathModal({
contacts,
config,
messageId,
packetId,
isOutgoingChan,
isResendable,
onResend,
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;
// Resolve all paths
const resolvedPaths = hasPaths
@@ -63,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
@@ -90,6 +95,12 @@ export function PathModal({
{hasPaths && (
<div className="flex-1 overflow-y-auto py-2 space-y-4">
{showAnalyzePacket ? (
<Button type="button" variant="outline" className="w-full" onClick={onAnalyzePacket}>
Analyze Packet
</Button>
) : null}
{/* Raw path summary */}
<div className="text-sm">
{paths.map((p, index) => {
@@ -130,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>
)}

View File

@@ -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>

View File

@@ -1,4 +1,4 @@
import { useMemo, useState } from 'react';
import { useEffect, useMemo, useState, type ReactNode } from 'react';
import { ChannelCrypto, PayloadType } from '@michaelhart/meshcore-decoder';
import type { Channel, RawPacket } from '../types';
@@ -8,6 +8,8 @@ import {
inspectRawPacketWithOptions,
type PacketByteField,
} from '../utils/rawPacketInspector';
import { toast } from './ui/sonner';
import { Button } from './ui/button';
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from './ui/dialog';
interface RawPacketDetailModalProps {
@@ -16,6 +18,38 @@ interface RawPacketDetailModalProps {
onClose: () => void;
}
type RawPacketInspectorDialogSource =
| {
kind: 'packet';
packet: RawPacket;
}
| {
kind: 'paste';
}
| {
kind: 'loading';
message: string;
}
| {
kind: 'unavailable';
message: string;
};
interface RawPacketInspectorDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
channels: Channel[];
source: RawPacketInspectorDialogSource;
title: string;
description: string;
notice?: ReactNode;
}
interface RawPacketInspectionPanelProps {
packet: RawPacket;
channels: Channel[];
}
interface FieldPaletteEntry {
box: string;
boxActive: string;
@@ -127,7 +161,7 @@ function buildGroupTextResolutionCandidates(channels: Channel[]): GroupTextResol
}));
}
function resolveGroupTextRoomName(
function resolveGroupTextChannelName(
payload: {
channelHash?: string;
cipherMac?: string;
@@ -177,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,
};
}
@@ -197,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
@@ -358,6 +393,36 @@ function renderFieldValue(field: PacketByteField) {
);
}
function normalizePacketHex(input: string): string {
return input.replace(/\s+/g, '').toUpperCase();
}
function validatePacketHex(input: string): string | null {
if (!input) {
return 'Paste a packet hex string to analyze.';
}
if (!/^[0-9A-F]+$/.test(input)) {
return 'Packet hex may only contain 0-9 and A-F characters.';
}
if (input.length % 2 !== 0) {
return 'Packet hex must contain an even number of characters.';
}
return null;
}
function buildPastedRawPacket(packetHex: string): RawPacket {
return {
id: -1,
timestamp: Math.floor(Date.now() / 1000),
data: packetHex,
payload_type: 'Unknown',
snr: null,
rssi: null,
decrypted: false,
decrypted_info: null,
};
}
function FieldBox({
field,
palette,
@@ -500,145 +565,256 @@ function FieldSection({
);
}
export function RawPacketDetailModal({ packet, channels, onClose }: RawPacketDetailModalProps) {
export function RawPacketInspectionPanel({ packet, channels }: RawPacketInspectionPanelProps) {
const decoderOptions = useMemo(() => createDecoderOptions(channels), [channels]);
const groupTextCandidates = useMemo(
() => buildGroupTextResolutionCandidates(channels),
[channels]
);
const inspection = useMemo(
() => (packet ? inspectRawPacketWithOptions(packet, decoderOptions) : null),
() => inspectRawPacketWithOptions(packet, decoderOptions),
[decoderOptions, packet]
);
const [hoveredFieldId, setHoveredFieldId] = useState<string | null>(null);
const packetDisplayFields = useMemo(
() => (inspection ? inspection.packetFields.filter((field) => field.name !== 'Payload') : []),
[inspection]
);
const fullPacketFields = useMemo(
() => (inspection ? buildDisplayFields(inspection) : []),
() => inspection.packetFields.filter((field) => field.name !== 'Payload'),
[inspection]
);
const fullPacketFields = useMemo(() => buildDisplayFields(inspection), [inspection]);
const colorMap = useMemo(() => buildFieldColorMap(fullPacketFields), [fullPacketFields]);
const packetContext = useMemo(
() => (packet && inspection ? getPacketContext(packet, inspection, groupTextCandidates) : null),
() => getPacketContext(packet, inspection, groupTextCandidates),
[groupTextCandidates, inspection, packet]
);
const packetIsDecrypted = useMemo(
() => (packet && inspection ? packetShowsDecryptedState(packet, inspection) : false),
() => packetShowsDecryptedState(packet, inspection),
[inspection, packet]
);
if (!packet || !inspection) {
return null;
return (
<div className="min-h-0 flex-1 overflow-y-auto p-3">
<div className="grid gap-2 lg:grid-cols-[minmax(0,1.45fr)_minmax(0,1fr)]">
<section className="rounded-lg border border-border/70 bg-card/70 p-3">
<div className="flex flex-wrap items-start justify-between gap-2">
<div className="min-w-0">
<div className="text-[10px] uppercase tracking-[0.18em] text-muted-foreground">
Summary
</div>
<div className="mt-1 text-base font-semibold leading-tight text-foreground">
{inspection.summary.summary}
</div>
</div>
<div className="shrink-0 text-xs text-muted-foreground">
{formatTimestamp(packet.timestamp)}
</div>
</div>
{packetContext ? (
<div className="mt-2 rounded-md border border-border/60 bg-background/35 px-2.5 py-2">
<div className="text-[10px] uppercase tracking-[0.18em] text-muted-foreground">
{packetContext.title}
</div>
<div className="mt-1 text-sm font-medium leading-tight text-foreground">
{packetContext.primary}
</div>
{packetContext.secondary ? (
<div className="mt-1 text-xs leading-tight text-muted-foreground">
{packetContext.secondary}
</div>
) : null}
</div>
) : null}
</section>
<section className="grid gap-2 sm:grid-cols-3 lg:grid-cols-1 xl:grid-cols-3">
<CompactMetaCard
label="Packet"
primary={`${packet.data.length / 2} bytes · ${packetIsDecrypted ? 'Decrypted' : 'Encrypted'}`}
secondary={`Storage #${packet.id}${packet.observation_id !== undefined ? ` · Observation #${packet.observation_id}` : ''}`}
/>
<CompactMetaCard
label="Transport"
primary={`${inspection.routeTypeName} · ${inspection.payloadTypeName}`}
secondary={`${inspection.payloadVersionName} · ${formatPathMode(inspection.decoded?.pathHashSize, inspection.pathTokens.length)}`}
/>
<CompactMetaCard
label="Signal"
primary={formatSignal(packet)}
secondary={packetContext ? null : undefined}
/>
</section>
</div>
{inspection.validationErrors.length > 0 ? (
<div className="mt-3 rounded-lg border border-warning/40 bg-warning/10 p-2.5">
<div className="text-sm font-semibold text-foreground">Validation notes</div>
<div className="mt-1.5 space-y-1 text-sm text-foreground">
{inspection.validationErrors.map((error) => (
<div key={error}>{error}</div>
))}
</div>
</div>
) : null}
<div className="mt-3 rounded-lg border border-border/70 bg-card/70 p-3">
<div className="flex items-center justify-between gap-3">
<div className="text-xl font-semibold text-foreground">Full packet hex</div>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => {
navigator.clipboard.writeText(packet.data);
toast.success('Packet hex copied!');
}}
>
Copy
</Button>
</div>
<div className="mt-2.5">
<FullPacketHex
packetHex={packet.data}
fields={fullPacketFields}
colorMap={colorMap}
hoveredFieldId={hoveredFieldId}
onHoverField={setHoveredFieldId}
/>
</div>
</div>
<div className="mt-3 grid gap-3 xl:grid-cols-[minmax(0,0.85fr)_minmax(0,1.15fr)]">
<FieldSection
title="Packet fields"
fields={packetDisplayFields}
colorMap={colorMap}
hoveredFieldId={hoveredFieldId}
onHoverField={setHoveredFieldId}
/>
<FieldSection
title="Payload fields"
fields={inspection.payloadFields}
colorMap={colorMap}
hoveredFieldId={hoveredFieldId}
onHoverField={setHoveredFieldId}
/>
</div>
</div>
);
}
export function RawPacketInspectorDialog({
open,
onOpenChange,
channels,
source,
title,
description,
notice,
}: RawPacketInspectorDialogProps) {
const [packetInput, setPacketInput] = useState('');
useEffect(() => {
if (!open || source.kind !== 'paste') {
setPacketInput('');
}
}, [open, source.kind]);
const normalizedPacketInput = useMemo(() => normalizePacketHex(packetInput), [packetInput]);
const packetInputError = useMemo(
() => (normalizedPacketInput.length > 0 ? validatePacketHex(normalizedPacketInput) : null),
[normalizedPacketInput]
);
const analyzedPacket = useMemo(
() =>
normalizedPacketInput.length > 0 && packetInputError === null
? buildPastedRawPacket(normalizedPacketInput)
: null,
[normalizedPacketInput, packetInputError]
);
let body: ReactNode;
if (source.kind === 'packet') {
body = <RawPacketInspectionPanel packet={source.packet} channels={channels} />;
} else if (source.kind === 'paste') {
body = (
<>
<div className="border-b border-border px-4 py-3 pr-14">
<div className="flex flex-col gap-3">
<label className="text-sm font-medium text-foreground" htmlFor="raw-packet-input">
Packet Hex
</label>
<textarea
id="raw-packet-input"
value={packetInput}
onChange={(event) => setPacketInput(event.target.value)}
placeholder="Paste raw packet hex here..."
className="min-h-14 w-full rounded-md border border-input bg-background px-3 py-2 font-mono text-sm text-foreground outline-none transition-colors focus-visible:ring-2 focus-visible:ring-ring"
spellCheck={false}
/>
{packetInputError ? (
<div className="text-sm text-destructive">{packetInputError}</div>
) : null}
</div>
</div>
{analyzedPacket ? (
<RawPacketInspectionPanel packet={analyzedPacket} channels={channels} />
) : (
<div className="flex flex-1 items-center justify-center p-6 text-sm text-muted-foreground">
Paste a packet above to inspect it.
</div>
)}
</>
);
} else if (source.kind === 'loading') {
body = (
<div className="flex flex-1 items-center justify-center p-6 text-sm text-muted-foreground">
{source.message}
</div>
);
} else {
body = (
<div className="flex flex-1 items-center justify-center p-6">
<div className="max-w-xl rounded-lg border border-warning/40 bg-warning/10 p-4 text-sm text-foreground">
{source.message}
</div>
</div>
);
}
return (
<Dialog open={packet !== null} onOpenChange={(isOpen) => !isOpen && onClose()}>
<DialogContent className="flex h-[92vh] max-w-[min(96vw,82rem)] flex-col gap-0 overflow-hidden p-0">
<Dialog open={open} onOpenChange={onOpenChange}>
<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>Packet Details</DialogTitle>
<DialogDescription className="sr-only">
Detailed byte and field breakdown for the selected raw packet.
</DialogDescription>
<DialogTitle>{title}</DialogTitle>
<DialogDescription className="sr-only">{description}</DialogDescription>
</DialogHeader>
<div className="min-h-0 flex-1 overflow-y-auto p-3">
<div className="grid gap-2 lg:grid-cols-[minmax(0,1.45fr)_minmax(0,1fr)]">
<section className="rounded-lg border border-border/70 bg-card/70 p-3">
<div className="flex flex-wrap items-start justify-between gap-2">
<div className="min-w-0">
<div className="text-[10px] uppercase tracking-[0.18em] text-muted-foreground">
Summary
</div>
<div className="mt-1 text-base font-semibold leading-tight text-foreground">
{inspection.summary.summary}
</div>
</div>
<div className="shrink-0 text-xs text-muted-foreground">
{formatTimestamp(packet.timestamp)}
</div>
</div>
{packetContext ? (
<div className="mt-2 rounded-md border border-border/60 bg-background/35 px-2.5 py-2">
<div className="text-[10px] uppercase tracking-[0.18em] text-muted-foreground">
{packetContext.title}
</div>
<div className="mt-1 text-sm font-medium leading-tight text-foreground">
{packetContext.primary}
</div>
{packetContext.secondary ? (
<div className="mt-1 text-xs leading-tight text-muted-foreground">
{packetContext.secondary}
</div>
) : null}
</div>
) : null}
</section>
<section className="grid gap-2 sm:grid-cols-3 lg:grid-cols-1 xl:grid-cols-3">
<CompactMetaCard
label="Packet"
primary={`${packet.data.length / 2} bytes · ${packetIsDecrypted ? 'Decrypted' : 'Encrypted'}`}
secondary={`Storage #${packet.id}${packet.observation_id !== undefined ? ` · Observation #${packet.observation_id}` : ''}`}
/>
<CompactMetaCard
label="Transport"
primary={`${inspection.routeTypeName} · ${inspection.payloadTypeName}`}
secondary={`${inspection.payloadVersionName} · ${formatPathMode(inspection.decoded?.pathHashSize, inspection.pathTokens.length)}`}
/>
<CompactMetaCard
label="Signal"
primary={formatSignal(packet)}
secondary={packetContext ? null : undefined}
/>
</section>
</div>
{inspection.validationErrors.length > 0 ? (
<div className="mt-3 rounded-lg border border-warning/40 bg-warning/10 p-2.5">
<div className="text-sm font-semibold text-foreground">Validation notes</div>
<div className="mt-1.5 space-y-1 text-sm text-foreground">
{inspection.validationErrors.map((error) => (
<div key={error}>{error}</div>
))}
</div>
</div>
) : null}
<div className="mt-3 rounded-lg border border-border/70 bg-card/70 p-3">
<div className="text-xl font-semibold text-foreground">Full packet hex</div>
<div className="mt-2.5">
<FullPacketHex
packetHex={packet.data}
fields={fullPacketFields}
colorMap={colorMap}
hoveredFieldId={hoveredFieldId}
onHoverField={setHoveredFieldId}
/>
{notice ? (
<div className="border-b border-border px-3 py-3 text-sm text-foreground">
<div className="rounded-md border border-destructive/40 bg-destructive/10 px-3 py-2 text-destructive">
{notice}
</div>
</div>
<div className="mt-3 grid gap-3 xl:grid-cols-[minmax(0,0.85fr)_minmax(0,1.15fr)]">
<FieldSection
title="Packet fields"
fields={packetDisplayFields}
colorMap={colorMap}
hoveredFieldId={hoveredFieldId}
onHoverField={setHoveredFieldId}
/>
<FieldSection
title="Payload fields"
fields={inspection.payloadFields}
colorMap={colorMap}
hoveredFieldId={hoveredFieldId}
onHoverField={setHoveredFieldId}
/>
</div>
</div>
) : null}
{body}
</DialogContent>
</Dialog>
);
}
export function RawPacketDetailModal({ packet, channels, onClose }: RawPacketDetailModalProps) {
if (!packet) {
return null;
}
return (
<RawPacketInspectorDialog
open={packet !== null}
onOpenChange={(isOpen) => !isOpen && onClose()}
channels={channels}
source={{ kind: 'packet', packet }}
title="Packet Details"
description="Detailed byte and field breakdown for the selected raw packet."
/>
);
}

View File

@@ -2,7 +2,8 @@ import { useEffect, useMemo, useState } from 'react';
import { ChevronLeft, ChevronRight } from 'lucide-react';
import { RawPacketList } from './RawPacketList';
import { RawPacketDetailModal } from './RawPacketDetailModal';
import { RawPacketInspectorDialog } from './RawPacketDetailModal';
import { Button } from './ui/button';
import type { Channel, Contact, RawPacket } from '../types';
import {
RAW_PACKET_STATS_WINDOWS,
@@ -77,12 +78,16 @@ function formatRssi(value: number | null): string {
return value === null ? '-' : `${Math.round(value)} dBm`;
}
function resolveContactLabel(sourceKey: string | null, contacts: Contact[]): string | null {
function normalizeResolvableSourceKey(sourceKey: string): string {
return sourceKey.startsWith('hash1:') ? sourceKey.slice(6) : sourceKey;
}
function resolveContact(sourceKey: string | null, contacts: Contact[]): Contact | null {
if (!sourceKey || sourceKey.startsWith('name:')) {
return null;
}
const normalizedSourceKey = sourceKey.toLowerCase();
const normalizedSourceKey = normalizeResolvableSourceKey(sourceKey).toLowerCase();
const matches = contacts.filter((contact) =>
contact.public_key.toLowerCase().startsWith(normalizedSourceKey)
);
@@ -90,7 +95,14 @@ function resolveContactLabel(sourceKey: string | null, contacts: Contact[]): str
return null;
}
const contact = matches[0];
return matches[0];
}
function resolveContactLabel(sourceKey: string | null, contacts: Contact[]): string | null {
const contact = resolveContact(sourceKey, contacts);
if (!contact) {
return null;
}
return getContactDisplayName(contact.name, contact.public_key, contact.last_advert);
}
@@ -101,11 +113,46 @@ function resolveNeighbor(item: NeighborStat, contacts: Contact[]): NeighborStat
};
}
function mergeResolvedNeighbors(items: NeighborStat[], contacts: Contact[]): NeighborStat[] {
const merged = new Map<string, NeighborStat>();
for (const item of items) {
const contact = resolveContact(item.key, contacts);
const canonicalKey = contact?.public_key ?? item.key;
const resolvedLabel =
contact != null
? getContactDisplayName(contact.name, contact.public_key, contact.last_advert)
: item.label;
const existing = merged.get(canonicalKey);
if (!existing) {
merged.set(canonicalKey, {
...item,
key: canonicalKey,
label: resolvedLabel,
});
continue;
}
existing.count += item.count;
existing.lastSeen = Math.max(existing.lastSeen, item.lastSeen);
existing.bestRssi =
existing.bestRssi === null
? item.bestRssi
: item.bestRssi === null
? existing.bestRssi
: Math.max(existing.bestRssi, item.bestRssi);
existing.label = resolvedLabel;
}
return Array.from(merged.values());
}
function isNeighborIdentityResolvable(item: NeighborStat, contacts: Contact[]): boolean {
if (item.key.startsWith('name:')) {
return true;
}
return resolveContactLabel(item.key, contacts) !== null;
return resolveContact(item.key, contacts) !== null;
}
function formatStrongestPacketDetail(
@@ -219,14 +266,29 @@ function NeighborList({
mode: 'heard' | 'signal' | 'recent';
contacts: Contact[];
}) {
const mergedItems = mergeResolvedNeighbors(items, contacts);
const sortedItems = [...mergedItems].sort((a, b) => {
if (mode === 'heard') {
return b.count - a.count || b.lastSeen - a.lastSeen || a.label.localeCompare(b.label);
}
if (mode === 'signal') {
return (
(b.bestRssi ?? Number.NEGATIVE_INFINITY) - (a.bestRssi ?? Number.NEGATIVE_INFINITY) ||
b.count - a.count ||
a.label.localeCompare(b.label)
);
}
return b.lastSeen - a.lastSeen || b.count - a.count || a.label.localeCompare(b.label);
});
return (
<section className="mb-4 break-inside-avoid rounded-lg border border-border/70 bg-card/70 p-3">
<h3 className="text-sm font-semibold text-foreground">{title}</h3>
{items.length === 0 ? (
{sortedItems.length === 0 ? (
<p className="mt-3 text-sm text-muted-foreground">{emptyLabel}</p>
) : (
<div className="mt-3 space-y-2">
{items.map((item) => (
{sortedItems.map((item) => (
<div
key={item.key}
className="flex items-center justify-between gap-3 rounded-md bg-background/70 px-2 py-1.5"
@@ -324,6 +386,7 @@ export function RawPacketFeedView({
const [selectedWindow, setSelectedWindow] = useState<RawPacketStatsWindow>('10m');
const [nowSec, setNowSec] = useState(() => Math.floor(Date.now() / 1000));
const [selectedPacket, setSelectedPacket] = useState<RawPacket | null>(null);
const [analyzeModalOpen, setAnalyzeModalOpen] = useState(false);
useEffect(() => {
const interval = window.setInterval(() => {
@@ -357,7 +420,6 @@ export function RawPacketFeedView({
() => stats.newestNeighbors.map((item) => resolveNeighbor(item, contacts)),
[contacts, stats.newestNeighbors]
);
return (
<>
<div className="flex items-center justify-between gap-3 border-b border-border px-4 py-2.5">
@@ -367,15 +429,26 @@ export function RawPacketFeedView({
Collecting stats since {formatTimestamp(rawPacketStatsSession.sessionStartedAt)}
</p>
</div>
<button
type="button"
onClick={() => setStatsOpen((current) => !current)}
aria-expanded={statsOpen}
className="inline-flex items-center gap-1 rounded-md border border-border bg-card px-3 py-1.5 text-sm text-foreground transition-colors hover:bg-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
{statsOpen ? <ChevronRight className="h-4 w-4" /> : <ChevronLeft className="h-4 w-4" />}
{statsOpen ? 'Hide Stats' : 'Show Stats'}
</button>
<div className="flex items-center gap-2">
<Button
type="button"
variant="outline"
size="sm"
onClick={() => setAnalyzeModalOpen(true)}
>
Analyze Packet
</Button>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => setStatsOpen((current) => !current)}
aria-expanded={statsOpen}
>
{statsOpen ? <ChevronRight className="h-4 w-4" /> : <ChevronLeft className="h-4 w-4" />}
{statsOpen ? 'Hide Stats' : 'Show Stats'}
</Button>
</div>
</div>
<div className="flex min-h-0 flex-1 flex-col md:flex-row">
@@ -538,10 +611,26 @@ export function RawPacketFeedView({
</aside>
</div>
<RawPacketDetailModal
packet={selectedPacket}
<RawPacketInspectorDialog
open={selectedPacket !== null}
onOpenChange={(isOpen) => !isOpen && setSelectedPacket(null)}
channels={channels}
onClose={() => setSelectedPacket(null)}
source={
selectedPacket
? { kind: 'packet', packet: selectedPacket }
: { kind: 'loading', message: 'Loading packet...' }
}
title="Packet Details"
description="Detailed byte and field breakdown for the selected raw packet."
/>
<RawPacketInspectorDialog
open={analyzeModalOpen}
onOpenChange={setAnalyzeModalOpen}
channels={channels}
source={{ kind: 'paste' }}
title="Analyze Packet"
description="Paste and inspect a raw packet hex string."
/>
</>
);

View File

@@ -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';
@@ -12,6 +13,7 @@ import { handleKeyboardActivate } from '../utils/a11y';
import { isValidLocation } from '../utils/pathUtils';
import { ContactStatusInfo } from './ContactStatusInfo';
import type { Contact, Conversation, Favorite, PathDiscoveryResponse } from '../types';
import { cn } from '../lib/utils';
import { TelemetryPane } from './repeater/RepeaterTelemetryPane';
import { NeighborsPane } from './repeater/RepeaterNeighborsPane';
import { AclPane } from './repeater/RepeaterAclPane';
@@ -68,6 +70,7 @@ export function RepeaterDashboard({
loggedIn,
loginLoading,
loginError,
lastLoginAttempt,
paneData,
paneStates,
consoleHistory,
@@ -101,8 +104,15 @@ export function RepeaterDashboard({
return (
<div className="flex-1 flex flex-col min-h-0">
{/* Header */}
<header className="flex justify-between items-start px-4 py-2.5 border-b border-border gap-2">
<span className="flex min-w-0 flex-1 flex-col">
<header
className={cn(
'grid items-start gap-x-2 gap-y-0.5 border-b border-border px-4 py-2.5',
contact
? 'grid-cols-[minmax(0,1fr)_auto] min-[1100px]:grid-cols-[minmax(0,1fr)_auto_auto]'
: 'grid-cols-[minmax(0,1fr)_auto]'
)}
>
<span className="flex min-w-0 flex-col">
<span className="flex min-w-0 flex-wrap items-baseline gap-x-2 gap-y-0.5">
<span className="flex min-w-0 flex-1 items-baseline gap-2">
<span className="min-w-0 flex-shrink truncate font-semibold text-base">
@@ -122,14 +132,14 @@ export function RepeaterDashboard({
{conversation.id}
</span>
</span>
{contact && (
<span className="min-w-0 flex-none text-[11px] text-muted-foreground max-sm:basis-full">
<ContactStatusInfo contact={contact} ourLat={radioLat} ourLon={radioLon} />
</span>
)}
</span>
</span>
<div className="flex items-center gap-0.5 flex-shrink-0">
{contact && (
<div className="col-span-2 row-start-2 min-w-0 text-[11px] text-muted-foreground min-[1100px]:col-span-1 min-[1100px]:col-start-2 min-[1100px]:row-start-1">
<ContactStatusInfo contact={contact} ourLat={radioLat} ourLon={radioLon} />
</div>
)}
<div className="flex items-center gap-0.5">
{loggedIn && (
<Button
variant="outline"
@@ -241,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">

View File

@@ -3,6 +3,7 @@ import { useCallback, useEffect, useMemo, useState } from 'react';
import { api } from '../api';
import { toast } from './ui/sonner';
import { Button } from './ui/button';
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from './ui/sheet';
import type {
Contact,
PaneState,
@@ -15,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;
@@ -59,8 +66,8 @@ export function RoomServerPanel({ contact, onAuthenticatedChange }: RoomServerPa
useRememberedServerPassword('room', contact.public_key);
const [loginLoading, setLoginLoading] = useState(false);
const [loginError, setLoginError] = useState<string | null>(null);
const [loginMessage, setLoginMessage] = 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,
@@ -74,8 +81,8 @@ export function RoomServerPanel({ contact, onAuthenticatedChange }: RoomServerPa
useEffect(() => {
setLoginLoading(false);
setLoginError(null);
setLoginMessage(null);
setAuthenticated(false);
setLastLoginAttempt(null);
setAdvancedOpen(false);
setPaneData({
status: null,
@@ -130,31 +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);
setLoginMessage(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);
setLoginMessage(
result.message ??
(result.authenticated
? 'Login confirmed. You can now send room messages and open admin tools.'
: 'Login request sent, but authentication was not confirmed.')
);
if (result.authenticated) {
toast.success('Room login confirmed');
toast.success('Login confirmed by the room server.');
} else {
toast(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);
}
@@ -163,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]);
@@ -213,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 (
@@ -242,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>
@@ -251,62 +261,97 @@ export function RoomServerPanel({ contact, onAuthenticatedChange }: RoomServerPa
return (
<section className="border-b border-border bg-muted/20 px-4 py-3">
<div className="flex flex-col gap-3">
<div className="flex flex-col gap-2 lg:flex-row lg:items-center lg:justify-between">
<div className="space-y-1">
<div className="text-sm font-medium">Room Server Controls</div>
<p className="text-xs text-muted-foreground">
Room access is active. Use the chat history and message box below to participate, and
open admin tools when needed.
</p>
{loginMessage && <p className="text-xs text-muted-foreground">{loginMessage}</p>}
</div>
<div className="flex w-full flex-col gap-2 sm:flex-row lg:w-auto">
<Button
type="button"
variant="outline"
onClick={handleLoginAsGuest}
disabled={loginLoading}
>
Refresh ACL Login
</Button>
<Button
type="button"
variant="outline"
onClick={() => setAdvancedOpen((prev) => !prev)}
>
{advancedOpen ? 'Hide Tools' : 'Show Tools'}
</Button>
</div>
<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>
{advancedOpen && (
<div className="grid gap-3 xl:grid-cols-2">
<TelemetryPane
data={paneData.status}
state={paneStates.status}
onRefresh={() => refreshPane('status', () => api.roomStatus(contact.public_key))}
/>
<AclPane
data={paneData.acl}
state={paneStates.acl}
onRefresh={() => refreshPane('acl', () => api.roomAcl(contact.public_key))}
/>
<LppTelemetryPane
data={paneData.lppTelemetry}
state={paneStates.lppTelemetry}
onRefresh={() =>
refreshPane('lppTelemetry', () => api.roomLppTelemetry(contact.public_key))
}
/>
<ConsolePane
history={consoleHistory}
loading={consoleLoading}
onSend={handleConsoleCommand}
/>
</div>
)}
</div>
<Sheet open={advancedOpen} onOpenChange={setAdvancedOpen}>
<SheetContent side="right" className="w-full sm:max-w-4xl p-0 flex flex-col">
<SheetHeader className="sr-only">
<SheetTitle>Room Server Tools</SheetTitle>
<SheetDescription>
Room server telemetry, ACL tools, sensor data, and CLI console
</SheetDescription>
</SheetHeader>
<div className="border-b border-border px-4 py-3 pr-14">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="min-w-0">
<h2 className="truncate text-base font-semibold">Room Server Tools</h2>
<p className="text-sm text-muted-foreground">{panelTitle}</p>
</div>
</div>
</div>
<div className="flex-1 overflow-y-auto p-4">
<div className="grid gap-3 xl:grid-cols-2">
<TelemetryPane
data={paneData.status}
state={paneStates.status}
onRefresh={() => refreshPane('status', () => api.roomStatus(contact.public_key))}
/>
<AclPane
data={paneData.acl}
state={paneStates.acl}
onRefresh={() => refreshPane('acl', () => api.roomAcl(contact.public_key))}
/>
<LppTelemetryPane
data={paneData.lppTelemetry}
state={paneStates.lppTelemetry}
onRefresh={() =>
refreshPane('lppTelemetry', () => api.roomLppTelemetry(contact.public_key))
}
/>
<ConsolePane
history={consoleHistory}
loading={consoleLoading}
onSend={handleConsoleCommand}
/>
</div>
</div>
</SheetContent>
</Sheet>
</section>
);
}

View File

@@ -0,0 +1,181 @@
import { useEffect, useState } from 'react';
import { AlertTriangle } from 'lucide-react';
import { api } from '../api';
import type { HealthStatus } from '../types';
import { Button } from './ui/button';
import { Checkbox } from './ui/checkbox';
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from './ui/dialog';
import { toast } from './ui/sonner';
const STORAGE_KEY = 'meshcore_security_warning_acknowledged';
function readAcknowledgedState(): boolean {
if (typeof window === 'undefined') {
return false;
}
try {
return window.localStorage.getItem(STORAGE_KEY) === 'true';
} catch {
return false;
}
}
function writeAcknowledgedState(): void {
if (typeof window === 'undefined') {
return;
}
try {
window.localStorage.setItem(STORAGE_KEY, 'true');
} catch {
// Best effort only; the warning will continue to show if localStorage is unavailable.
}
}
interface SecurityWarningModalProps {
health: HealthStatus | null;
}
export function SecurityWarningModal({ health }: SecurityWarningModalProps) {
const [acknowledged, setAcknowledged] = useState(readAcknowledgedState);
const [confirmedRisk, setConfirmedRisk] = useState(false);
const [disablingBots, setDisablingBots] = useState(false);
const [botsDisabledLocally, setBotsDisabledLocally] = useState(false);
const shouldWarn =
health !== null &&
health.bots_disabled !== true &&
health.basic_auth_enabled !== true &&
!botsDisabledLocally &&
!acknowledged;
useEffect(() => {
if (!shouldWarn) {
setConfirmedRisk(false);
}
}, [shouldWarn]);
useEffect(() => {
if (health?.bots_disabled !== true) {
setBotsDisabledLocally(false);
}
}, [health?.bots_disabled, health?.bots_disabled_source]);
if (!shouldWarn) {
return null;
}
return (
<Dialog open>
<DialogContent
hideCloseButton
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()}
>
<DialogHeader className="space-y-0 text-left">
<div className="flex items-center gap-3">
<div className="inline-flex h-11 w-11 shrink-0 items-center justify-center rounded-full border border-destructive/30 bg-destructive/10 text-destructive">
<AlertTriangle className="h-5 w-5" aria-hidden="true" />
</div>
<DialogTitle className="leading-tight">
Unprotected bot execution is enabled
</DialogTitle>
</div>
</DialogHeader>
<hr className="border-border" />
<div className="space-y-3 break-words text-sm leading-6 text-muted-foreground">
<DialogDescription>
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 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
your network is untrusted or later compromised, this setup may expose the host system to
arbitrary code execution.
</p>
<p>
To reduce that risk, run the server with environment variables to either disable bots
with{' '}
<code className="break-all rounded bg-muted px-1 py-0.5 text-foreground">
MESHCORE_DISABLE_BOTS=true
</code>{' '}
or enable the built-in login with{' '}
<code className="break-all rounded bg-muted px-1 py-0.5 text-foreground">
MESHCORE_BASIC_AUTH_USERNAME
</code>{' '}
/{' '}
<code className="break-all rounded bg-muted px-1 py-0.5 text-foreground">
MESHCORE_BASIC_AUTH_PASSWORD
</code>
. Another external auth or access-control system is also acceptable.
</p>
<p>
If you just want a temporary safety measure while you learn the system, you can use the
button below to disable bots until the server restarts. That is only a temporary guard;
permanent protection through Basic Auth or env-based bot disablement is still
encouraged.
</p>
</div>
<div className="space-y-2">
<Button
type="button"
className="h-auto w-full whitespace-normal py-3 text-center"
disabled={disablingBots}
onClick={async () => {
setDisablingBots(true);
try {
await api.disableBotsUntilRestart();
setBotsDisabledLocally(true);
toast.success('Bots disabled until restart');
} catch (err) {
toast.error('Failed to disable bots', {
description: err instanceof Error ? err.message : undefined,
});
} finally {
setDisablingBots(false);
}
}}
>
{disablingBots ? 'Disabling Bots...' : 'Disable Bots Until Server Restart'}
</Button>
</div>
<div className="space-y-3 rounded-md border border-input bg-muted/20 p-4">
<label className="flex items-start gap-3">
<Checkbox
checked={confirmedRisk}
onCheckedChange={(checked) => setConfirmedRisk(checked === true)}
aria-label="Acknowledge bot security risk"
className="mt-0.5"
/>
<span className="text-sm leading-6 text-foreground">
I understand that continuing with my existing security setup may put me at risk on
untrusted networks or if my home network is compromised.
</span>
</label>
<Button
type="button"
className="h-auto w-full whitespace-normal py-3 text-center"
variant="outline"
disabled={!confirmedRisk || disablingBots}
onClick={() => {
writeAcknowledgedState();
setAcknowledged(true);
}}
>
Do Not Warn Me On This Device Again
</Button>
</div>
</DialogContent>
</Dialog>
);
}

View 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>
);
}

View File

@@ -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';

View File

@@ -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)}
@@ -945,21 +945,6 @@ export function Sidebar({
</>
)}
{/* Room Servers */}
{nonFavoriteRooms.length > 0 && (
<>
{renderSectionHeader(
'Room Servers',
roomsCollapsed,
() => setRoomsCollapsed((prev) => !prev),
'rooms',
roomsUnreadCount,
roomsUnreadCount > 0
)}
{(isSearching || !roomsCollapsed) && roomRows.map((row) => renderConversationRow(row))}
</>
)}
{/* Repeaters */}
{nonFavoriteRepeaters.length > 0 && (
<>
@@ -975,6 +960,21 @@ export function Sidebar({
</>
)}
{/* Room Servers */}
{nonFavoriteRooms.length > 0 && (
<>
{renderSectionHeader(
'Room Servers',
roomsCollapsed,
() => setRoomsCollapsed((prev) => !prev),
'rooms',
roomsUnreadCount,
roomsUnreadCount > 0
)}
{(isSearching || !roomsCollapsed) && roomRows.map((row) => renderConversationRow(row))}
</>
)}
{/* Empty state */}
{nonFavoriteContacts.length === 0 &&
nonFavoriteRooms.length === 0 &&

View File

@@ -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)}

View File

@@ -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 {

View File

@@ -173,8 +173,8 @@ export function SettingsDatabaseSection({
Deletes archival copies of raw packet bytes for messages that are already decrypted and
visible in your chat history.{' '}
<em className="text-muted-foreground/80">
This will not affect any displayed messages or app functionality, nor impact your
ability to do historical decryption.
This will not affect any displayed messages or your ability to do historical decryption,
but it will remove packet-analysis availability for those historical messages.
</em>{' '}
The raw bytes are only useful for manual packet analysis.
</p>

View File

@@ -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&apos;s private key to be available (firmware must have{' '}
<code>ENABLE_PRIVATE_KEY_EXPORT=1</code>). Only raw RF packets are shared &mdash; 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&apos;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 &rarr; Radio &rarr; 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&apos;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">
@@ -1817,42 +2184,28 @@ export function SettingsFanoutSection({
{health?.bots_disabled && (
<div className="rounded-md border border-destructive/50 bg-destructive/10 px-4 py-3 text-sm text-destructive">
Bot system is disabled by server configuration (MESHCORE_DISABLE_BOTS). Bot integrations
cannot be created or modified.
{health.bots_disabled_source === 'until_restart'
? 'Bot system is disabled until the server restarts. Bot integrations cannot run, be created, or be modified right now.'
: 'Bot system is disabled by server configuration (MESHCORE_DISABLE_BOTS). Bot integrations cannot run, be created, or be modified.'}
</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">

View File

@@ -164,6 +164,12 @@ export function SettingsStatisticsSection({ className }: { className?: string })
<td className="text-right py-1">{stats.repeaters_heard.last_24_hours}</td>
<td className="text-right py-1">{stats.repeaters_heard.last_week}</td>
</tr>
<tr>
<td className="py-1">Known-channels active</td>
<td className="text-right py-1">{stats.known_channels_active.last_hour}</td>
<td className="text-right py-1">{stats.known_channels_active.last_24_hours}</td>
<td className="text-right py-1">{stats.known_channels_active.last_week}</td>
</tr>
</tbody>
</table>
</div>

View File

@@ -29,10 +29,16 @@ const DialogOverlay = React.forwardRef<
));
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
interface DialogContentProps extends React.ComponentPropsWithoutRef<
typeof DialogPrimitive.Content
> {
hideCloseButton?: boolean;
}
const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
>(({ className, children, ...props }, ref) => (
DialogContentProps
>(({ className, children, hideCloseButton = false, ...props }, ref) => (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
@@ -44,10 +50,12 @@ const DialogContent = React.forwardRef<
{...props}
>
{children}
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
{!hideCloseButton && (
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
)}
</DialogPrimitive.Content>
</DialogPortal>
));

View File

@@ -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';

View File

@@ -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] =
@@ -118,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: 'Allow notifications in your browser settings, then try again.',
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;
}
@@ -153,15 +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'
? 'Permission was denied by the browser.'
: 'Permission request was dismissed.',
? 'Desktop notifications were denied by your browser. Allow notifications in browser settings, then try again.'
: 'The browser permission request was dismissed.',
});
},
[enabledByConversation, permission]

View File

@@ -21,6 +21,24 @@ interface InternalCachedConversationEntry extends CachedConversationEntry {
export class ConversationMessageCache {
private readonly cache = new Map<string, InternalCachedConversationEntry>();
private normalizeEntry(entry: CachedConversationEntry): InternalCachedConversationEntry {
let messages = entry.messages;
let hasOlderMessages = entry.hasOlderMessages;
if (messages.length > MAX_MESSAGES_PER_ENTRY) {
messages = [...messages]
.sort((a, b) => b.received_at - a.received_at)
.slice(0, MAX_MESSAGES_PER_ENTRY);
hasOlderMessages = true;
}
return {
messages,
hasOlderMessages,
contentKeys: new Set(messages.map((message) => getMessageContentKey(message))),
};
}
get(id: string): CachedConversationEntry | undefined {
const entry = this.cache.get(id);
if (!entry) return undefined;
@@ -33,17 +51,7 @@ export class ConversationMessageCache {
}
set(id: string, entry: CachedConversationEntry): void {
const contentKeys = new Set(entry.messages.map((message) => getMessageContentKey(message)));
if (entry.messages.length > MAX_MESSAGES_PER_ENTRY) {
const trimmed = [...entry.messages]
.sort((a, b) => b.received_at - a.received_at)
.slice(0, MAX_MESSAGES_PER_ENTRY);
entry = { ...entry, messages: trimmed, hasOlderMessages: true };
}
const internalEntry: InternalCachedConversationEntry = {
...entry,
contentKeys,
};
const internalEntry = this.normalizeEntry(entry);
this.cache.delete(id);
this.cache.set(id, internalEntry);
if (this.cache.size > MAX_CACHED_CONVERSATIONS) {
@@ -69,19 +77,21 @@ export class ConversationMessageCache {
}
if (entry.contentKeys.has(contentKey)) return false;
if (entry.messages.some((message) => message.id === msg.id)) return false;
entry.contentKeys.add(contentKey);
entry.messages = [...entry.messages, msg];
if (entry.messages.length > MAX_MESSAGES_PER_ENTRY) {
entry.messages = [...entry.messages]
.sort((a, b) => b.received_at - a.received_at)
.slice(0, MAX_MESSAGES_PER_ENTRY);
}
const nextEntry = this.normalizeEntry({
messages: [...entry.messages, msg],
hasOlderMessages: entry.hasOlderMessages,
});
this.cache.delete(id);
this.cache.set(id, entry);
this.cache.set(id, nextEntry);
return true;
}
updateAck(messageId: number, ackCount: number, paths?: MessagePath[]): void {
updateAck(
messageId: number,
ackCount: number,
paths?: MessagePath[],
packetId?: number | null
): void {
for (const entry of this.cache.values()) {
const index = entry.messages.findIndex((message) => message.id === messageId);
if (index < 0) continue;
@@ -91,6 +101,7 @@ export class ConversationMessageCache {
...current,
acked: Math.max(current.acked, ackCount),
...(paths !== undefined && paths.length >= (current.paths?.length ?? 0) && { paths }),
...(packetId !== undefined && { packet_id: packetId }),
};
entry.messages = updated;
return;
@@ -123,11 +134,13 @@ export class ConversationMessageCache {
}
this.cache.delete(oldId);
this.cache.set(newId, {
messages: mergedMessages,
hasOlderMessages: newEntry.hasOlderMessages || oldEntry.hasOlderMessages,
contentKeys: new Set([...newEntry.contentKeys, ...oldEntry.contentKeys]),
});
this.cache.set(
newId,
this.normalizeEntry({
messages: mergedMessages,
hasOlderMessages: newEntry.hasOlderMessages || oldEntry.hasOlderMessages,
})
);
}
clear(): void {
@@ -139,12 +152,16 @@ export function reconcileConversationMessages(
current: Message[],
fetched: Message[]
): Message[] | null {
const currentById = new Map<number, { acked: number; pathsLen: number; text: string }>();
const currentById = new Map<
number,
{ acked: number; pathsLen: number; text: string; packetId: number | null | undefined }
>();
for (const message of current) {
currentById.set(message.id, {
acked: message.acked,
pathsLen: message.paths?.length ?? 0,
text: message.text,
packetId: message.packet_id,
});
}
@@ -155,7 +172,8 @@ export function reconcileConversationMessages(
!currentMessage ||
currentMessage.acked !== message.acked ||
currentMessage.pathsLen !== (message.paths?.length ?? 0) ||
currentMessage.text !== message.text
currentMessage.text !== message.text ||
currentMessage.packetId !== message.packet_id
) {
needsUpdate = true;
break;
@@ -173,17 +191,20 @@ export const conversationMessageCache = new ConversationMessageCache();
interface PendingAckUpdate {
ackCount: number;
paths?: MessagePath[];
packetId?: number | null;
}
export function mergePendingAck(
existing: PendingAckUpdate | undefined,
ackCount: number,
paths?: MessagePath[]
paths?: MessagePath[],
packetId?: number | null
): PendingAckUpdate {
if (!existing) {
return {
ackCount,
...(paths !== undefined && { paths }),
...(packetId !== undefined && { packetId }),
};
}
@@ -192,6 +213,9 @@ export function mergePendingAck(
ackCount,
...(paths !== undefined && { paths }),
...(paths === undefined && existing.paths !== undefined && { paths: existing.paths }),
...(packetId !== undefined && { packetId }),
...(packetId === undefined &&
existing.packetId !== undefined && { packetId: existing.packetId }),
};
}
@@ -199,16 +223,31 @@ export function mergePendingAck(
return existing;
}
const packetIdChanged = packetId !== undefined && packetId !== existing.packetId;
if (paths === undefined) {
return existing;
if (!packetIdChanged) {
return existing;
}
return {
...existing,
packetId,
};
}
const existingPathCount = existing.paths?.length ?? -1;
if (paths.length >= existingPathCount) {
return { ackCount, paths };
return { ackCount, paths, ...(packetId !== undefined && { packetId }) };
}
return existing;
if (!packetIdChanged) {
return existing;
}
return {
...existing,
packetId,
};
}
interface UseConversationMessagesResult {
@@ -223,7 +262,12 @@ interface UseConversationMessagesResult {
jumpToBottom: () => void;
reloadCurrentConversation: () => void;
observeMessage: (msg: Message) => { added: boolean; activeConversation: boolean };
receiveMessageAck: (messageId: number, ackCount: number, paths?: MessagePath[]) => void;
receiveMessageAck: (
messageId: number,
ackCount: number,
paths?: MessagePath[],
packetId?: number | null
) => void;
reconcileOnReconnect: () => void;
renameConversationMessages: (oldId: string, newId: string) => void;
removeConversationMessages: (conversationId: string) => void;
@@ -284,9 +328,9 @@ export function useConversationMessages(
const pendingAcksRef = useRef<Map<number, PendingAckUpdate>>(new Map());
const setPendingAck = useCallback(
(messageId: number, ackCount: number, paths?: MessagePath[]) => {
(messageId: number, ackCount: number, paths?: MessagePath[], packetId?: number | null) => {
const existing = pendingAcksRef.current.get(messageId);
const merged = mergePendingAck(existing, ackCount, paths);
const merged = mergePendingAck(existing, ackCount, paths, packetId);
// Update insertion order so most recent updates remain in the buffer longest.
pendingAcksRef.current.delete(messageId);
@@ -312,6 +356,7 @@ export function useConversationMessages(
...msg,
acked: Math.max(msg.acked, pending.ackCount),
...(pending.paths !== undefined && { paths: pending.paths }),
...(pending.packetId !== undefined && { packet_id: pending.packetId }),
};
}, []);
const [messages, setMessages] = useState<Message[]>([]);
@@ -775,10 +820,10 @@ export function useConversationMessages(
// Update a message's ack count and paths
const updateMessageAck = useCallback(
(messageId: number, ackCount: number, paths?: MessagePath[]) => {
(messageId: number, ackCount: number, paths?: MessagePath[], packetId?: number | null) => {
const hasMessageLoaded = messagesRef.current.some((m) => m.id === messageId);
if (!hasMessageLoaded) {
setPendingAck(messageId, ackCount, paths);
setPendingAck(messageId, ackCount, paths, packetId);
return;
}
@@ -800,10 +845,11 @@ export function useConversationMessages(
...current,
acked: nextAck,
...(paths !== undefined && { paths: nextPaths }),
...(packetId !== undefined && { packet_id: packetId }),
};
return updated;
}
setPendingAck(messageId, ackCount, paths);
setPendingAck(messageId, ackCount, paths, packetId);
return prev;
});
},
@@ -811,9 +857,9 @@ export function useConversationMessages(
);
const receiveMessageAck = useCallback(
(messageId: number, ackCount: number, paths?: MessagePath[]) => {
updateMessageAck(messageId, ackCount, paths);
conversationMessageCache.updateAck(messageId, ackCount, paths);
(messageId: number, ackCount: number, paths?: MessagePath[], packetId?: number | null) => {
updateMessageAck(messageId, ackCount, paths, packetId);
conversationMessageCache.updateAck(messageId, ackCount, paths, packetId);
},
[updateMessageAck]
);

View File

@@ -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);

View 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]);
}

View File

@@ -48,7 +48,12 @@ interface UseRealtimeAppStateArgs {
setActiveConversation: (conv: Conversation | null) => void;
renameConversationMessages: (oldId: string, newId: string) => void;
removeConversationMessages: (conversationId: string) => void;
receiveMessageAck: (messageId: number, ackCount: number, paths?: MessagePath[]) => void;
receiveMessageAck: (
messageId: number,
ackCount: number,
paths?: MessagePath[],
packetId?: number | null
) => void;
notifyIncomingMessage?: (msg: Message) => void;
recordRawPacketObservation?: (packet: RawPacket) => void;
maxRawPackets?: number;
@@ -246,8 +251,13 @@ export function useRealtimeAppState({
recordRawPacketObservation?.(packet);
setRawPackets((prev) => appendRawPacketUnique(prev, packet, maxRawPackets));
},
onMessageAcked: (messageId: number, ackCount: number, paths?: MessagePath[]) => {
receiveMessageAck(messageId, ackCount, paths);
onMessageAcked: (
messageId: number,
ackCount: number,
paths?: MessagePath[],
packetId?: number | null
) => {
receiveMessageAck(messageId, ackCount, paths, packetId);
},
}),
[

View File

@@ -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]

View File

@@ -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,

View File

@@ -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)

View File

@@ -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 () => {
@@ -114,14 +181,21 @@ describe('SettingsFanoutSection', () => {
});
});
it('hides bot option from add integration menu when bots_disabled', async () => {
renderSection({ health: { ...baseHealth, bots_disabled: true } });
await waitFor(() => {
expect(screen.getByRole('button', { name: 'Add Integration' })).toBeInTheDocument();
it('shows restart-scoped bots disabled messaging when disabled until restart', async () => {
renderSection({
health: { ...baseHealth, bots_disabled: true, bots_disabled_source: 'until_restart' },
});
await waitFor(() => {
expect(screen.getByText(/disabled until the server restarts/i)).toBeInTheDocument();
});
});
fireEvent.click(screen.getByRole('button', { name: 'Add Integration' }));
expect(screen.queryByRole('menuitem', { name: 'Bot' })).not.toBeInTheDocument();
it('hides bot option from add integration dialog when bots_disabled', async () => {
renderSection({ health: { ...baseHealth, bots_disabled: true } });
const dialog = await openCreateIntegrationDialog();
expect(
within(dialog).queryByRole('button', { name: startsWithAccessibleName('Python Bot') })
).not.toBeInTheDocument();
});
it('lists existing configs after load', async () => {
@@ -296,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();
@@ -315,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();
@@ -332,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'));
@@ -411,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' }));
@@ -444,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'));
});
@@ -647,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());
@@ -698,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'), {
@@ -765,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)');
@@ -833,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' } });
@@ -871,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();
@@ -900,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();
});
@@ -920,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 () => {
@@ -941,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 () => {
@@ -963,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 () => {

View File

@@ -167,17 +167,15 @@ describe('Integration: Duplicate Message Handling', () => {
});
});
describe('Integration: No phantom unreads from mesh echoes (hitlist #8 regression)', () => {
it('does not increment unread when a mesh echo arrives after many unique messages', () => {
describe('Integration: Trimmed cache entries can reappear (hitlist #7 regression)', () => {
it('increments unread when an evicted inactive-conversation message arrives again', () => {
const state = createMockState();
const convKey = 'channel_busy';
// Deliver 1001 unique messages — exceeding the old global
// seenMessageContentRef prune threshold (1000→500). Under the old
// dual-set design the global set would drop msg-0's key during pruning,
// so a later mesh echo of msg-0 would pass the global check and
// phantom-increment unread. With the fix, messageCache's per-conversation
// Cached messages remain the source of truth for inactive-conversation dedup.
// Deliver enough unique messages to evict msg-0 from the inactive
// conversation cache. Once it falls out of that window, a later arrival
// with the same content should be allowed back in instead of being
// suppressed forever by a stale content key.
const MESSAGE_COUNT = 1001;
for (let i = 0; i < MESSAGE_COUNT; i++) {
const msg: Message = {
@@ -219,9 +217,8 @@ describe('Integration: No phantom unreads from mesh echoes (hitlist #8 regressio
};
const result = handleMessageEvent(state, echo, 'other_active_conv');
// Must NOT increment unread — the echo is a duplicate
expect(result.unreadIncremented).toBe(false);
expect(state.unreadCounts[stateKey]).toBe(MESSAGE_COUNT);
expect(result.unreadIncremented).toBe(true);
expect(state.unreadCounts[stateKey]).toBe(MESSAGE_COUNT + 1);
});
});

View File

@@ -214,6 +214,76 @@ describe('messageCache', () => {
expect(entry!.messages.some((m) => m.id === 0)).toBe(false);
});
it('allows a trimmed-out message to be re-added after set() trimming', () => {
const messages = Array.from({ length: MAX_MESSAGES_PER_ENTRY + 1 }, (_, i) =>
createMessage({
id: i,
text: `message-${i}`,
received_at: 1700000000 + i,
sender_timestamp: 1700000000 + i,
})
);
messageCache.set('conv1', createEntry(messages));
const trimmedOut = createMessage({
id: 10_000,
text: 'message-0',
received_at: 1800000000,
sender_timestamp: 1700000000,
});
expect(messageCache.addMessage('conv1', trimmedOut)).toBe(true);
const entry = messageCache.get('conv1');
expect(entry!.messages.some((m) => m.id === 10_000)).toBe(true);
});
it('allows a trimmed-out message to be re-added after addMessage() trimming', () => {
const messages = Array.from({ length: MAX_MESSAGES_PER_ENTRY - 1 }, (_, i) =>
createMessage({
id: i,
text: `message-${i}`,
received_at: 1700000000 + i,
sender_timestamp: 1700000000 + i,
})
);
messageCache.set('conv1', createEntry(messages));
expect(
messageCache.addMessage(
'conv1',
createMessage({
id: MAX_MESSAGES_PER_ENTRY,
text: 'newest-a',
received_at: 1800000000,
sender_timestamp: 1800000000,
})
)
).toBe(true);
expect(
messageCache.addMessage(
'conv1',
createMessage({
id: MAX_MESSAGES_PER_ENTRY + 1,
text: 'newest-b',
received_at: 1800000001,
sender_timestamp: 1800000001,
})
)
).toBe(true);
const readdedTrimmedMessage = createMessage({
id: 10_001,
text: 'message-0',
received_at: 1900000000,
sender_timestamp: 1700000000,
});
expect(messageCache.addMessage('conv1', readdedTrimmedMessage)).toBe(true);
const entry = messageCache.get('conv1');
expect(entry!.messages.some((m) => m.id === 10_001)).toBe(true);
});
it('auto-creates a minimal entry for never-visited conversations and returns true', () => {
const msg = createMessage({ id: 10, text: 'First contact' });
const result = messageCache.addMessage('new_conv', msg);

View File

@@ -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();

View File

@@ -4,6 +4,19 @@ import { describe, expect, it, vi } from 'vitest';
import { RawPacketDetailModal } from '../components/RawPacketDetailModal';
import type { Channel, RawPacket } from '../types';
vi.mock('../components/ui/sonner', () => ({
toast: Object.assign(vi.fn(), {
success: vi.fn(),
error: vi.fn(),
warning: vi.fn(),
}),
}));
const { toast } = await import('../components/ui/sonner');
const mockToast = toast as unknown as {
success: ReturnType<typeof vi.fn>;
};
const BOT_CHANNEL: Channel = {
key: 'eb50a1bcb3e4e5d7bf69a57c9dada211',
name: '#bot',
@@ -25,6 +38,20 @@ const BOT_PACKET: RawPacket = {
};
describe('RawPacketDetailModal', () => {
it('copies the full packet hex to the clipboard', async () => {
const writeText = vi.fn().mockResolvedValue(undefined);
Object.assign(navigator, {
clipboard: { writeText },
});
render(<RawPacketDetailModal packet={BOT_PACKET} channels={[BOT_CHANNEL]} onClose={vi.fn()} />);
fireEvent.click(screen.getByRole('button', { name: 'Copy' }));
expect(writeText).toHaveBeenCalledWith(BOT_PACKET.data);
expect(mockToast.success).toHaveBeenCalledWith('Packet hex copied!');
});
it('renders path hops as nowrap arrow-delimited groups and links hover state to the full packet hex', () => {
render(<RawPacketDetailModal packet={BOT_PACKET} channels={[BOT_CHANNEL]} onClose={vi.fn()} />);

View File

@@ -135,6 +135,22 @@ describe('RawPacketFeedView', () => {
expect(screen.getByText('Traffic Timeline')).toBeInTheDocument();
});
it('analyzes a pasted raw packet without adding it to the live feed', () => {
renderView({ channels: [TEST_CHANNEL] });
fireEvent.click(screen.getByRole('button', { name: 'Analyze Packet' }));
expect(screen.getByRole('heading', { name: 'Analyze Packet' })).toBeInTheDocument();
fireEvent.change(screen.getByLabelText('Packet Hex'), {
target: { value: GROUP_TEXT_PACKET_HEX },
});
expect(screen.getByText('Full packet hex')).toBeInTheDocument();
expect(screen.getByText('Packet fields')).toBeInTheDocument();
expect(screen.getByText('Payload fields')).toBeInTheDocument();
});
it('shows stats by default on desktop', () => {
vi.stubGlobal(
'matchMedia',
@@ -297,7 +313,55 @@ describe('RawPacketFeedView', () => {
expect(screen.getAllByText('Identity not resolvable').length).toBeGreaterThan(0);
});
it('opens a packet detail modal from the raw feed and decrypts room messages when a key is loaded', () => {
it('collapses uniquely resolved hash buckets into the same visible contact row', () => {
const alphaContact = createContact({
public_key: 'aa11bb22cc33' + '0'.repeat(52),
name: 'Alpha',
});
renderView({
rawPacketStatsSession: createSession({
totalObservedPackets: 2,
observations: [
{
observationKey: 'obs-1',
timestamp: 1_700_000_000,
payloadType: 'TextMessage',
routeType: 'Direct',
decrypted: true,
rssi: -70,
snr: 6,
sourceKey: 'hash1:AA',
sourceLabel: 'AA',
pathTokenCount: 0,
pathSignature: null,
},
{
observationKey: 'obs-2',
timestamp: 1_700_000_030,
payloadType: 'TextMessage',
routeType: 'Direct',
decrypted: true,
rssi: -67,
snr: 7,
sourceKey: alphaContact.public_key.toUpperCase(),
sourceLabel: alphaContact.public_key.slice(0, 12).toUpperCase(),
pathTokenCount: 0,
pathSignature: null,
},
],
}),
contacts: [alphaContact],
});
fireEvent.click(screen.getByRole('button', { name: /show stats/i }));
fireEvent.change(screen.getByLabelText('Stats window'), { target: { value: 'session' } });
expect(screen.getAllByText('Alpha').length).toBeGreaterThan(0);
expect(screen.queryByText('Identity not resolvable')).not.toBeInTheDocument();
});
it('opens a packet detail modal from the raw feed and decrypts channel messages when a key is loaded', () => {
renderView({
packets: [
{
@@ -328,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: [
{

View File

@@ -2,8 +2,12 @@ import { describe, expect, it } from 'vitest';
import {
buildRawPacketStatsSnapshot,
summarizeRawPacketForStats,
type RawPacketStatsSessionState,
} from '../utils/rawPacketStats';
import type { RawPacket } from '../types';
const TEXT_MESSAGE_PACKET = '09046F17C47ED00A13E16AB5B94B1CC2D1A5059C6E5A6253C60D';
function createSession(
overrides: Partial<RawPacketStatsSessionState> = {}
@@ -75,6 +79,49 @@ function createSession(
}
describe('buildRawPacketStatsSnapshot', () => {
it('prefers decrypted contact identity over one-byte sourceHash for stats bucketing', () => {
const packet: RawPacket = {
id: 1,
observation_id: 10,
timestamp: 1_700_000_000,
data: TEXT_MESSAGE_PACKET,
payload_type: 'TextMessage',
snr: 4,
rssi: -72,
decrypted: true,
decrypted_info: {
channel_name: null,
sender: 'Alpha',
channel_key: null,
contact_key: '0a'.repeat(32),
},
};
const summary = summarizeRawPacketForStats(packet);
expect(summary.sourceKey).toBe('0A'.repeat(32));
expect(summary.sourceLabel).toBe('Alpha');
});
it('tags unresolved one-byte source hashes so they do not collide with full contact keys', () => {
const packet: RawPacket = {
id: 2,
observation_id: 11,
timestamp: 1_700_000_000,
data: TEXT_MESSAGE_PACKET,
payload_type: 'TextMessage',
snr: 4,
rssi: -72,
decrypted: false,
decrypted_info: null,
};
const summary = summarizeRawPacketForStats(packet);
expect(summary.sourceKey).toBe('hash1:0A');
expect(summary.sourceLabel).toBe('0A');
});
it('computes counts, rankings, and rolling-window coverage from session observations', () => {
const stats = buildRawPacketStatsSnapshot(createSession(), '5m', 1_000);

View File

@@ -11,6 +11,7 @@ const mockHook: {
loggedIn: false,
loginLoading: false,
loginError: null,
lastLoginAttempt: null,
paneData: {
status: null,
nodeInfo: null,

View File

@@ -24,6 +24,8 @@ vi.mock('../components/ui/sonner', () => ({
const { api: _rawApi } = await import('../api');
const mockApi = _rawApi as unknown as Record<string, Mock>;
const { toast } = await import('../components/ui/sonner');
const mockToast = toast as unknown as Record<string, Mock>;
const roomContact: Contact = {
public_key: 'aa'.repeat(32),
@@ -54,18 +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('Room Server Controls')).toBeInTheDocument();
expect(screen.getByText('Show Tools')).toBeInTheDocument();
});
expect(screen.getByText('Show Tools')).toBeInTheDocument();
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. You're free to try sending messages; try logging in again if authenticated actions fail.",
});
expect(screen.getByText(/control panel is still available/i)).toBeInTheDocument();
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.');
});
});

View File

@@ -0,0 +1,119 @@
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { beforeEach, describe, expect, it, vi } from 'vitest';
const mocks = vi.hoisted(() => ({
disableBotsUntilRestart: vi.fn(),
toast: {
success: vi.fn(),
error: vi.fn(),
},
}));
vi.mock('../api', () => ({
api: {
disableBotsUntilRestart: mocks.disableBotsUntilRestart,
},
}));
vi.mock('../components/ui/sonner', () => ({
toast: mocks.toast,
}));
import { SecurityWarningModal } from '../components/SecurityWarningModal';
import type { HealthStatus } from '../types';
const baseHealth: HealthStatus = {
status: 'degraded',
radio_connected: false,
radio_initializing: false,
connection_info: null,
database_size_mb: 1.2,
oldest_undecrypted_timestamp: null,
fanout_statuses: {},
bots_disabled: false,
basic_auth_enabled: false,
};
describe('SecurityWarningModal', () => {
beforeEach(() => {
vi.clearAllMocks();
window.localStorage.clear();
mocks.disableBotsUntilRestart.mockResolvedValue({
status: 'ok',
bots_disabled: true,
bots_disabled_source: 'until_restart',
});
});
it('shows the warning when bots are enabled and basic auth is off', () => {
render(<SecurityWarningModal health={baseHealth} />);
expect(screen.getByText('Unprotected bot execution is enabled')).toBeInTheDocument();
expect(
screen.getByRole('button', { name: 'Do Not Warn Me On This Device Again' })
).toBeDisabled();
});
it('does not show when bots are disabled', () => {
render(<SecurityWarningModal health={{ ...baseHealth, bots_disabled: true }} />);
expect(screen.queryByText('Unprotected bot execution is enabled')).not.toBeInTheDocument();
});
it('does not show when basic auth is enabled', () => {
render(<SecurityWarningModal health={{ ...baseHealth, basic_auth_enabled: true }} />);
expect(screen.queryByText('Unprotected bot execution is enabled')).not.toBeInTheDocument();
});
it('persists dismissal only after the checkbox is acknowledged', async () => {
const user = userEvent.setup();
render(<SecurityWarningModal health={baseHealth} />);
const dismissButton = screen.getByRole('button', {
name: 'Do Not Warn Me On This Device Again',
});
await user.click(screen.getByLabelText('Acknowledge bot security risk'));
expect(dismissButton).toBeEnabled();
await user.click(dismissButton);
expect(window.localStorage.getItem('meshcore_security_warning_acknowledged')).toBe('true');
expect(screen.queryByText('Unprotected bot execution is enabled')).not.toBeInTheDocument();
});
it('disables bots until restart from the warning modal', async () => {
const user = userEvent.setup();
render(<SecurityWarningModal health={baseHealth} />);
await user.click(screen.getByRole('button', { name: 'Disable Bots Until Server Restart' }));
expect(mocks.disableBotsUntilRestart).toHaveBeenCalledTimes(1);
expect(mocks.toast.success).toHaveBeenCalledWith('Bots disabled until restart');
expect(screen.queryByText('Unprotected bot execution is enabled')).not.toBeInTheDocument();
});
it('shows the warning again after temporary bot disable disappears on a later health update', async () => {
const user = userEvent.setup();
const { rerender } = render(<SecurityWarningModal health={baseHealth} />);
await user.click(screen.getByRole('button', { name: 'Disable Bots Until Server Restart' }));
expect(screen.queryByText('Unprotected bot execution is enabled')).not.toBeInTheDocument();
rerender(
<SecurityWarningModal
health={{ ...baseHealth, bots_disabled: true, bots_disabled_source: 'until_restart' }}
/>
);
expect(screen.queryByText('Unprotected bot execution is enabled')).not.toBeInTheDocument();
rerender(<SecurityWarningModal health={baseHealth} />);
await waitFor(() => {
expect(screen.getByText('Unprotected bot execution is enabled')).toBeInTheDocument();
});
});
});

View File

@@ -557,6 +557,10 @@ describe('SettingsModal', () => {
renderModal();
openDatabaseSection();
expect(
screen.getByText(/remove packet-analysis availability for those historical messages/i)
).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: 'Purge Archival Raw Packets' }));
await waitFor(() => {
@@ -580,6 +584,7 @@ describe('SettingsModal', () => {
total_outgoing: 30,
contacts_heard: { last_hour: 2, last_24_hours: 7, last_week: 10 },
repeaters_heard: { last_hour: 1, last_24_hours: 3, last_week: 3 },
known_channels_active: { last_hour: 1, last_24_hours: 4, last_week: 6 },
path_hash_width_24h: {
total_packets: 120,
single_byte: 60,
@@ -626,6 +631,7 @@ describe('SettingsModal', () => {
expect(screen.getByText('24 (20.0%)')).toBeInTheDocument();
expect(screen.getByText('Contacts heard')).toBeInTheDocument();
expect(screen.getByText('Repeaters heard')).toBeInTheDocument();
expect(screen.getByText('Known-channels active')).toBeInTheDocument();
// Busiest channels
expect(screen.getByText('general')).toBeInTheDocument();
@@ -646,6 +652,7 @@ describe('SettingsModal', () => {
total_outgoing: 30,
contacts_heard: { last_hour: 2, last_24_hours: 7, last_week: 10 },
repeaters_heard: { last_hour: 1, last_24_hours: 3, last_week: 3 },
known_channels_active: { last_hour: 1, last_24_hours: 4, last_week: 6 },
path_hash_width_24h: {
total_packets: 120,
single_byte: 60,

View File

@@ -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 () => {
@@ -148,4 +160,81 @@ describe('useBrowserNotifications', () => {
expect(focusSpy).toHaveBeenCalledTimes(1);
expect(notificationInstance.close).toHaveBeenCalledTimes(1);
});
it('shows the browser guidance toast when notifications are blocked', async () => {
Object.assign(window.Notification, {
permission: 'denied',
});
const { result } = renderHook(() => useBrowserNotifications());
await act(async () => {
await result.current.toggleConversationNotifications(
'channel',
incomingChannelMessage.conversation_key,
'#flightless'
);
});
expect(mocks.toast.error).toHaveBeenCalledWith('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.',
});
});
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.',
});
});
});

View 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');
});
});

View File

@@ -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);
});
});

View File

@@ -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.',

View File

@@ -136,7 +136,7 @@ describe('useWebSocket dispatch', () => {
expect(onContactResolved).toHaveBeenCalledWith('abc123def456', contact);
});
it('routes message_acked to onMessageAcked with (messageId, ackCount, paths)', () => {
it('routes message_acked to onMessageAcked with (messageId, ackCount, paths, packetId)', () => {
const onMessageAcked = vi.fn();
renderHook(() => useWebSocket({ onMessageAcked }));
@@ -144,7 +144,7 @@ describe('useWebSocket dispatch', () => {
fireMessage({ type, data });
expect(onMessageAcked).toHaveBeenCalledOnce();
expect(onMessageAcked).toHaveBeenCalledWith(42, 1, undefined);
expect(onMessageAcked).toHaveBeenCalledWith(42, 1, undefined, undefined);
});
it('routes message_acked with paths', () => {
@@ -154,7 +154,16 @@ describe('useWebSocket dispatch', () => {
const paths = [{ path: 'aabb', received_at: 1700000000 }];
fireMessage({ type: 'message_acked', data: { message_id: 7, ack_count: 2, paths } });
expect(onMessageAcked).toHaveBeenCalledWith(7, 2, paths);
expect(onMessageAcked).toHaveBeenCalledWith(7, 2, paths, undefined);
});
it('routes message_acked with packet_id', () => {
const onMessageAcked = vi.fn();
renderHook(() => useWebSocket({ onMessageAcked }));
fireMessage({ type: 'message_acked', data: { message_id: 7, ack_count: 2, packet_id: 99 } });
expect(onMessageAcked).toHaveBeenCalledWith(7, 2, undefined, 99);
});
it('routes error event to onError', () => {

View File

@@ -78,6 +78,8 @@ export interface HealthStatus {
oldest_undecrypted_timestamp: number | null;
fanout_statuses: Record<string, FanoutStatusEntry>;
bots_disabled: boolean;
bots_disabled_source?: 'env' | 'until_restart' | null;
basic_auth_enabled?: boolean;
}
export interface FanoutConfig {
@@ -267,6 +269,7 @@ export interface Message {
acked: number;
sender_name: string | null;
channel_name?: string | null;
packet_id?: number | null;
}
export interface MessagesAroundResponse {
@@ -513,6 +516,7 @@ export interface StatisticsResponse {
total_outgoing: number;
contacts_heard: ContactActivityCounts;
repeaters_heard: ContactActivityCounts;
known_channels_active: ContactActivityCounts;
path_hash_width_24h: {
total_packets: number;
single_byte: number;

View File

@@ -21,7 +21,12 @@ export interface UseWebSocketOptions {
onChannel?: (channel: Channel) => void;
onChannelDeleted?: (key: string) => void;
onRawPacket?: (packet: RawPacket) => void;
onMessageAcked?: (messageId: number, ackCount: number, paths?: MessagePath[]) => void;
onMessageAcked?: (
messageId: number,
ackCount: number,
paths?: MessagePath[],
packetId?: number | null
) => void;
onError?: (error: ErrorEvent) => void;
onSuccess?: (success: SuccessEvent) => void;
onReconnect?: () => void;
@@ -128,8 +133,14 @@ export function useWebSocket(options: UseWebSocketOptions) {
message_id: number;
ack_count: number;
paths?: MessagePath[];
packet_id?: number | null;
};
handlers.onMessageAcked?.(ackData.message_id, ackData.ack_count, ackData.paths);
handlers.onMessageAcked?.(
ackData.message_id,
ackData.ack_count,
ackData.paths,
ackData.packet_id
);
break;
}
case 'error':

View File

@@ -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

View File

@@ -163,10 +163,17 @@ function getSourceInfo(
case PayloadType.TextMessage:
case PayloadType.Request:
case PayloadType.Response: {
const contactKey = packet.decrypted_info?.contact_key?.toUpperCase() ?? null;
if (contactKey) {
return {
sourceKey: contactKey,
sourceLabel: packet.decrypted_info?.sender || toSourceLabel(contactKey),
};
}
const sourceHash = (decoded.payload.decoded as { sourceHash?: string }).sourceHash;
if (!sourceHash) return { sourceKey: null, sourceLabel: null };
return {
sourceKey: sourceHash.toUpperCase(),
sourceKey: `hash1:${sourceHash.toUpperCase()}`,
sourceLabel: sourceHash.toUpperCase(),
};
}

View 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(),
};
}

View File

@@ -4,6 +4,7 @@ export interface MessageAckedPayload {
message_id: number;
ack_count: number;
paths?: MessagePath[];
packet_id?: number | null;
}
export interface ContactDeletedPayload {

View File

@@ -1,6 +1,6 @@
[project]
name = "remoteterm-meshcore"
version = "3.4.1"
version = "3.6.1"
description = "RemoteTerm - Web interface for MeshCore radio mesh networks"
readme = "README.md"
requires-python = ">=3.10"
@@ -12,7 +12,7 @@ dependencies = [
"httpx>=0.28.1",
"pycryptodome>=3.20.0",
"pynacl>=1.5.0",
"meshcore==2.3.1",
"meshcore==2.3.2",
"aiomqtt>=2.0",
"apprise>=1.9.7",
"boto3>=1.38.0",

View 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
View 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}"

View File

@@ -23,6 +23,9 @@ export interface HealthStatus {
radio_connected: boolean;
radio_initializing: boolean;
connection_info: string | null;
bots_disabled?: boolean;
bots_disabled_source?: 'env' | 'until_restart' | null;
basic_auth_enabled?: boolean;
}
export function getHealth(): Promise<HealthStatus> {

View File

@@ -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

View File

@@ -12,8 +12,8 @@ export default defineConfig({
timeout: 60_000,
expect: { timeout: 15_000 },
// Don't retry — failures likely indicate real hardware/app issues
retries: 0,
// Give hardware-backed flows one automatic retry before marking the test failed.
retries: 1,
// Run tests serially — single radio means no parallelism
fullyParallel: false,
@@ -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: [

Some files were not shown because too many files have changed in this diff Show More