15 Commits

Author SHA1 Message Date
Jack Kingsman
7d825a07f8 Updating changelog + build for 1.8.0 2026-02-07 21:45:46 -08:00
Jack Kingsman
c5fec61123 Updating changelog + build for 1.8.0 2026-02-07 21:43:39 -08:00
Jack Kingsman
9437959568 Swap legend columns (oops) 2026-02-07 21:31:12 -08:00
Jack Kingsman
d1846b102d Patch up changes -- fix up docs, fix react hooks issue 2026-02-07 21:25:19 -08:00
Jack Kingsman
5cd28bd1d9 Clear errors on settings tab switch, streamline message mark-as-read, don't resort on render 2026-02-07 21:15:47 -08:00
Jack Kingsman
d48595e082 Just flood advertise when we do 2026-02-07 21:15:46 -08:00
Jack Kingsman
1f3a1e5b3f Clarify garbage comment about outgoing decryption 2026-02-07 21:15:46 -08:00
Jack Kingsman
c2bcfbf646 Strengthen README warning about getting pwnd 2026-02-07 21:15:46 -08:00
jkingsman
28d57924ee Don't keep pacakge-lock.json 2026-02-06 19:34:10 -08:00
Jack Kingsman
f83681188c Merge pull request #10 from rgregg/iphone-pwa-fix
Fix display issues when running as a PWA
2026-02-06 19:30:50 -08:00
jkingsman
3acb0efc62 Remove compiled files 2026-02-06 19:30:26 -08:00
Jack Kingsman
1961b4c9e2 Move to manual FE builds 2026-02-06 18:44:08 -08:00
Ryan Gregg
de47c7c228 Fix for running as PWA 2026-02-04 21:45:30 -08:00
Ryan Gregg
4f610a329a Adjust how app dispalys as PWA to enable safe areas 2026-02-04 21:24:43 -08:00
Jack Kingsman
019092ed7d Add there-and-back node trace. Closes #4.
Single node trace
2026-02-04 19:54:01 -08:00
38 changed files with 137 additions and 6225 deletions

4
.gitignore vendored
View File

@@ -10,6 +10,10 @@ wheels/
frontend/node_modules/
frontend/test-results/
# Frontend build output (built from source by end users)
frontend/dist/
frontend/package-lock.json
# reference librarys
references/

View File

@@ -152,10 +152,10 @@ Terminal 2: `cd frontend && npm run dev`
### Production
In production, the FastAPI backend serves the compiled frontend:
In production, the FastAPI backend serves the compiled frontend. You must build the frontend first:
```bash
cd frontend && npm run build && cd ..
cd frontend && npm install && npm run build && cd ..
uv run uvicorn app.main:app --host 0.0.0.0 --port 8000
```

View File

@@ -1,3 +1,37 @@
## [1.8.0] - 2026-02-07
Feature: Single hop ping
Feature: PWA viewport fixes(thanks @rgregg)
Feature (?): No frontend distribution; build it yourself ;P
Bugfix: Fix channel message send race condition (concurrent sends could corrupt shared radio slot)
Bugfix: Fix TOCTOU race in radio reconnect (duplicate connections under contention)
Bugfix: Better guarding around reconnection
Bugfix: Duplicate websocket connection fixes
Bugfix: Settings tab error cleanliness on tab swap
Bugfix: Fix path traversal vuln
UI: Swap visualizer legend ordering (yay prettier)
Misc: Perf and locking improvements
Misc: Always flood advertisements
Misc: Better packet dupe handling
Misc: Dead code cleanup, test improvements
## [1.8.0] - 2026-02-07
Feature: Single hop ping
Feature: PWA viewport fixes(thanks @rgregg)
Feature (?): No frontend distribution; build it yourself ;P
Bugfix: Fix channel message send race condition (concurrent sends could corrupt shared radio slot)
Bugfix: Fix TOCTOU race in radio reconnect (duplicate connections under contention)
Bugfix: Better guarding around reconnection
Bugfix: Duplicate websocket connection fixes
Bugfix: Settings tab error cleanliness on tab swap
Bugfix: Fix path traversal vuln
UI: Swap visualizer legend ordering (yay prettier)
Misc: Perf and locking improvements
Misc: Always flood advertisements
Misc: Better packet dupe handling
Misc: Dead code cleanup, test improvements
## [1.7.1] - 2026-02-03
Feature: Clickable hyperlinks

View File

@@ -8,7 +8,7 @@ Backend server + browser interface for MeshCore mesh radio networks. Attach your
* Access your radio remotely over your network or VPN
* Brute force hashtag room names for GroupTexts you don't have keys for yet
**Warning:** This app has no authentication. Run it on a private network only -- do not expose to the internet unless you want strangers sending traffic as you.
**Warning:** This app has no auth, and is for trusted environments only. _Do not put this on an untrusted network, or open it to the public._ The bots can execute arbitrary Python code which means anyone on your network can, too. If you need access control, consider using a reverse proxy like Nginx, or extending FastAPI.
![Screenshot of the application's web interface](screenshot.png)
@@ -57,13 +57,17 @@ usbipd bind --busid 3-8
**This approach is recommended over Docker due to intermittent serial communications issues I've seen on \*nix systems.**
The frontend is pre-built -- just run the backend:
```bash
git clone https://github.com/jkingsman/Remote-Terminal-for-MeshCore.git
cd Remote-Terminal-for-MeshCore
# Install backend dependencies
uv sync
# Build frontend
cd frontend && npm install && npm run build && cd ..
# Run server
uv run uvicorn app.main:app --host 0.0.0.0 --port 8000
```
@@ -193,7 +197,7 @@ cd /opt/remoteterm
sudo -u remoteterm uv venv
sudo -u remoteterm uv sync
# Build frontend (optional -- already built in repo and served by backend)
# Build frontend (required for the backend to serve the web UI)
cd /opt/remoteterm/frontend
sudo -u remoteterm npm install
sudo -u remoteterm npm run build

View File

@@ -517,7 +517,8 @@ All endpoints are prefixed with `/api`.
- `WS /api/ws` - Real-time updates (health, contacts, channels, messages, raw packets)
### Static Files (Production)
In production, the backend also serves the frontend:
In production, the backend serves the frontend if `frontend/dist` exists. Users must build the
frontend first (`cd frontend && npm install && npm run build`):
- `/` - Serves `frontend/dist/index.html`
- `/assets/*` - Serves compiled JS/CSS from `frontend/dist/assets/`
- `/*` - Falls back to `index.html` for SPA routing

View File

@@ -354,7 +354,11 @@ async def run_historical_dm_decryption(
our_public_key_bytes = derive_public_key(private_key_bytes)
for packet_id, packet_data, packet_timestamp in packets:
# Don't pass our_public_key - we want to decrypt both incoming AND outgoing messages.
# Note: passing our_public_key=None means outgoing DMs won't be matched
# by try_decrypt_dm (the inbound check requires src_hash == their_first_byte,
# which fails for our outgoing packets). This is acceptable because outgoing
# DMs are stored directly by the send endpoint. Historical decryption only
# recovers incoming messages.
result = try_decrypt_dm(
packet_data,
private_key_bytes,

View File

@@ -183,6 +183,11 @@ class ContactRepository:
await db.conn.commit()
return cursor.rowcount > 0
@staticmethod
async def mark_all_read(timestamp: int) -> None:
"""Mark all contacts as read at the given timestamp."""
await db.conn.execute("UPDATE contacts SET last_read_at = ?", (timestamp,))
@staticmethod
async def get_by_pubkey_first_byte(hex_byte: str) -> list[Contact]:
"""Get contacts whose public key starts with the given hex byte (2 chars)."""
@@ -269,6 +274,11 @@ class ChannelRepository:
await db.conn.commit()
return cursor.rowcount > 0
@staticmethod
async def mark_all_read(timestamp: int) -> None:
"""Mark all channels as read at the given timestamp."""
await db.conn.execute("UPDATE channels SET last_read_at = ?", (timestamp,))
class MessageRepository:
@staticmethod

View File

@@ -134,29 +134,25 @@ async def set_private_key(update: PrivateKeyUpdate) -> dict:
@router.post("/advertise")
async def send_advertisement(flood: bool = True) -> dict:
"""Send a radio advertisement to announce presence on the mesh.
async def send_advertisement() -> dict:
"""Send a flood advertisement to announce presence on the mesh.
Manual advertisement requests always send immediately, updating the
last_advert_time which affects when the next periodic/startup advert
can occur.
Args:
flood: Whether to flood the advertisement (default True).
Returns:
status: "ok" if sent successfully
"""
require_connected()
# Manual requests always send (force=True), but still update last_advert_time
logger.info("Sending advertisement (flood=%s)", flood)
logger.info("Sending flood advertisement")
success = await do_send_advertisement(force=True)
if not success:
raise HTTPException(status_code=500, detail="Failed to send advertisement")
return {"status": "ok", "flood": flood}
return {"status": "ok"}
@router.post("/reboot")

View File

@@ -7,7 +7,7 @@ from fastapi import APIRouter, Query
from app.database import db
from app.models import UnreadCounts
from app.repository import MessageRepository
from app.repository import ChannelRepository, ContactRepository, MessageRepository
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/read-state", tags=["read-state"])
@@ -35,9 +35,8 @@ async def mark_all_read() -> dict:
"""
now = int(time.time())
# Update all contacts and channels in one transaction
await db.conn.execute("UPDATE contacts SET last_read_at = ?", (now,))
await db.conn.execute("UPDATE channels SET last_read_at = ?", (now,))
await ContactRepository.mark_all_read(now)
await ChannelRepository.mark_all_read(now)
await db.conn.commit()
logger.info("Marked all contacts and channels as read at %d", now)

View File

@@ -148,7 +148,7 @@ await api.getHealth();
// Radio
await api.getRadioConfig();
await api.updateRadioConfig({ name: 'MyRadio' });
await api.sendAdvertisement(true);
await api.sendAdvertisement();
// Contacts/Channels
await api.getContacts();

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 128 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 96 KiB

View File

@@ -1,8 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="700pt" height="700pt" version="1.1" viewBox="0 0 700 700" xmlns="http://www.w3.org/2000/svg">
<path d="m405.89 352.77c0 30.797-25.02 55.762-55.887 55.762s-55.887-24.965-55.887-55.762c0-30.797 25.02-55.762 55.887-55.762s55.887 24.965 55.887 55.762z"/>
<path d="m333.07 352.77h33.871v297.37h-33.871z"/>
<path d="m412.71 495.07-13.648-30.926c44.27-19.438 72.879-63.152 72.879-111.37 0-67.082-54.699-121.65-121.94-121.65-67.242 0-121.94 54.57-121.94 121.65 0 48.215 28.609 91.93 72.879 111.37l-13.648 30.926c-56.547-24.844-93.094-80.695-93.094-142.3 0-85.715 69.887-155.44 155.8-155.44 85.918 0 155.8 69.727 155.8 155.44-0.003906 61.594-36.551 117.46-93.094 142.3z"/>
<path d="m410.17 581.6-8.5742-32.691c89.277-23.309 151.63-103.96 151.63-196.15 0-111.8-91.168-202.75-203.22-202.75-112.06 0.003907-203.23 90.961-203.23 202.77 0 92.184 62.348 172.83 151.63 196.15l-8.5742 32.691c-104.18-27.195-176.93-121.3-176.93-228.83 0-130.43 106.36-236.54 237.1-236.54 130.73 0 237.1 106.12 237.1 236.54-0.003906 107.52-72.754 201.62-176.93 228.82z"/>
<path d="m409.05 661.5-6.3125-33.199c132.34-25.047 228.39-140.93 228.39-275.53 0-154.66-126.12-280.48-281.13-280.48-155 0-281.13 125.82-281.13 280.48 0 134.6 96.055 250.48 228.39 275.53l-6.3164 33.199c-148.3-28.07-255.95-157.91-255.95-308.73 0-173.29 141.32-314.27 315-314.27s315 140.98 315 314.27c0 150.81-107.65 280.66-255.95 308.73z"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

View File

@@ -1,22 +0,0 @@
<!DOCTYPE html>
<html lang="en" class="dark">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<meta name="mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<meta name="apple-mobile-web-app-title" content="MCTerm" />
<meta name="theme-color" content="#0a0a0a" />
<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="shortcut icon" href="/favicon.ico" />
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
<link rel="manifest" href="/site.webmanifest" />
<script type="module" crossorigin src="/assets/index-AkYO4-QT.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-DJA5wYVF.css">
</head>
<body>
<div id="root"></div>
</body>
</html>

View File

@@ -1,21 +0,0 @@
{
"name": "RemoteTerm for MeshCore",
"short_name": "RemoteTerm",
"icons": [
{
"src": "/web-app-manifest-192x192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "maskable"
},
{
"src": "/web-app-manifest-512x512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable"
}
],
"theme_color": "#ffffff",
"background_color": "#ffffff",
"display": "standalone"
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 46 KiB

File diff suppressed because it is too large Load Diff

View File

@@ -1,7 +1,7 @@
{
"name": "remoteterm-meshcore-frontend",
"private": true,
"version": "1.7.1",
"version": "1.8.0",
"type": "module",
"scripts": {
"dev": "vite",

View File

@@ -543,7 +543,7 @@ export function App() {
// Send flood advertisement handler
const handleAdvertise = useCallback(async () => {
try {
await api.sendAdvertisement(true);
await api.sendAdvertisement();
toast.success('Advertisement sent');
} catch (err) {
console.error('Failed to send advertisement:', err);
@@ -758,7 +758,7 @@ export function App() {
);
return (
<div className="flex flex-col h-dvh">
<div className="flex flex-col h-full">
<StatusBar
health={health}
config={config}

View File

@@ -76,8 +76,8 @@ export const api = {
method: 'PUT',
body: JSON.stringify({ private_key: privateKey }),
}),
sendAdvertisement: (flood = true) =>
fetchJson<{ status: string; flood: boolean }>(`/radio/advertise?flood=${flood}`, {
sendAdvertisement: () =>
fetchJson<{ status: string }>('/radio/advertise', {
method: 'POST',
}),
rebootRadio: () =>

View File

@@ -1,4 +1,12 @@
import { useEffect, useLayoutEffect, useRef, useCallback, useState, type ReactNode } from 'react';
import {
useEffect,
useLayoutEffect,
useRef,
useCallback,
useMemo,
useState,
type ReactNode,
} from 'react';
import type { Contact, Message, MessagePath, RadioConfig } from '../types';
import { CONTACT_TYPE_REPEATER } from '../types';
import { formatTime, parseSenderFromText } from '../utils/messageParser';
@@ -231,6 +239,14 @@ export function MessageList({
}
}, []);
// Sort messages by received_at ascending (oldest first)
// Note: Deduplication is handled by useConversationMessages.addMessageIfNew()
// and the database UNIQUE constraint on (type, conversation_key, text, sender_timestamp)
const sortedMessages = useMemo(
() => [...messages].sort((a, b) => a.received_at - b.received_at),
[messages]
);
// Look up contact by public key
const getContact = (conversationKey: string | null): Contact | null => {
if (!conversationKey) return null;
@@ -293,11 +309,6 @@ export function MessageList({
);
}
// Sort messages by received_at ascending (oldest first)
// Note: Deduplication is handled by useConversationMessages.addMessageIfNew()
// and the database UNIQUE constraint on (type, conversation_key, text, sender_timestamp)
const sortedMessages = [...messages].sort((a, b) => a.received_at - b.received_at);
// Helper to get a unique sender key for grouping messages
const getSenderKey = (msg: Message, sender: string | null): string => {
if (msg.outgoing) return '__outgoing__';

View File

@@ -1521,15 +1521,6 @@ export function PacketVisualizer({
{!hideUI && (
<div className="absolute bottom-4 left-4 bg-background/80 backdrop-blur-sm rounded-lg p-3 text-xs border border-border">
<div className="flex gap-6">
<div className="flex flex-col gap-1.5">
<div className="text-muted-foreground font-medium mb-1">Nodes</div>
{LEGEND_ITEMS.map((item) => (
<div key={item.label} className="flex items-center gap-2">
<span className={item.size}>{item.emoji}</span>
<span>{item.label}</span>
</div>
))}
</div>
<div className="flex flex-col gap-1.5">
<div className="text-muted-foreground font-medium mb-1">Packets</div>
{PACKET_LEGEND_ITEMS.map((item) => (
@@ -1544,6 +1535,15 @@ export function PacketVisualizer({
</div>
))}
</div>
<div className="flex flex-col gap-1.5">
<div className="text-muted-foreground font-medium mb-1">Nodes</div>
{LEGEND_ITEMS.map((item) => (
<div key={item.label} className="flex items-center gap-2">
<span className={item.size}>{item.emoji}</span>
<span>{item.label}</span>
</div>
))}
</div>
</div>
</div>
)}

View File

@@ -485,7 +485,10 @@ export function SettingsModal({
) : (
<Tabs
value={activeTab}
onValueChange={(v) => setActiveTab(v as SettingsTab)}
onValueChange={(v) => {
setActiveTab(v as SettingsTab);
setError('');
}}
className="w-full"
>
<TabsList className="grid w-full grid-cols-5">

View File

@@ -12,10 +12,37 @@ body,
height: 100%;
}
/* iOS PWA safe-area support */
:root {
--safe-area-top: env(safe-area-inset-top, 0px);
--safe-area-right: env(safe-area-inset-right, 0px);
--safe-area-bottom: env(safe-area-inset-bottom, 0px);
--safe-area-left: env(safe-area-inset-left, 0px);
--safe-area-bottom-capped: min(var(--safe-area-bottom), 12px);
}
@supports (padding: constant(safe-area-inset-top)) {
:root {
--safe-area-top: constant(safe-area-inset-top);
--safe-area-right: constant(safe-area-inset-right);
--safe-area-bottom: constant(safe-area-inset-bottom);
--safe-area-left: constant(safe-area-inset-left);
--safe-area-bottom-capped: min(var(--safe-area-bottom), 12px);
}
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
/* Prevent overscroll/bounce on mobile */
overscroll-behavior: none;
padding: var(--safe-area-top) var(--safe-area-right) var(--safe-area-bottom-capped)
var(--safe-area-left);
box-sizing: border-box;
}
#root {
height: 100%;
box-sizing: border-box;
}
/* Fallback for browsers without dvh support */

View File

@@ -1,6 +1,6 @@
[project]
name = "remoteterm-meshcore"
version = "1.7.1"
version = "1.8.0"
description = "RemoteTerm - Web interface for MeshCore radio mesh networks"
readme = "README.md"
requires-python = ">=3.10"

2
uv.lock generated
View File

@@ -854,7 +854,7 @@ wheels = [
[[package]]
name = "remoteterm-meshcore"
version = "1.7.1"
version = "1.8.0"
source = { virtual = "." }
dependencies = [
{ name = "aiosqlite" },