Compare commits

...

18 Commits

Author SHA1 Message Date
Jack Kingsman a351c86ccb Add favorites as contact field (dug) 2026-04-05 20:50:27 -07:00
Jack Kingsman c2e1a3cbe6 Import radio favorites as favorites 2026-04-05 18:15:04 -07:00
jkingsman c2d1339256 Default stale node pruning for visualizer to ON 2026-04-05 15:55:47 -07:00
jkingsman cb7139a7e1 Always offer basic auth, move docker-not-found warning to the top 2026-04-05 15:41:02 -07:00
Jack Kingsman 6332387704 Define a better y domain for repeater battery voltage 2026-04-05 12:45:52 -07:00
Jack Kingsman 3f2b8e2a1f Refocus CLI textbox after command completion. Closes #164. 2026-04-05 11:55:52 -07:00
Jack Kingsman 40c37745b6 Massage the Readme a bit more 2026-04-05 11:55:31 -07:00
Jack Kingsman 9edac47aa2 Add clearer warning about RemoteTerm taking over the radio and governing contacts/channels loading. Closes #163. 2026-04-05 11:49:57 -07:00
Jack Kingsman 44f8aafb66 Retain recent traces and make them click-to-trace. Closes #160. 2026-04-04 16:43:12 -07:00
Jack Kingsman 9e3805f5d0 Use receipt time not sender time for display 2026-04-04 16:24:36 -07:00
Jack Kingsman 457799d8df Calm down clock skew loggings 2026-04-04 15:31:30 -07:00
Jack Kingsman de3ad2d51f Calm it down on sync logs 2026-04-04 15:10:45 -07:00
Jack Kingsman ad83bc7979 Show telemetry inline 2026-04-04 14:29:31 -07:00
Jack Kingsman 9ebf63491c Have tests use prod regexes 2026-04-04 13:13:37 -07:00
Jack Kingsman b19585db6d Go crazy style on systemd escaping. Closes #159. 2026-04-04 12:24:36 -07:00
Jack Kingsman c28d22379e Be a little gentler; call it a room finder rather than a cracker 2026-04-04 12:06:28 -07:00
Jack Kingsman 1e5ccf6c29 Add clearer issue identification for missing HTTPS context for channel finder 2026-04-04 12:03:07 -07:00
Jack Kingsman 81f5bde287 Add hop counts to width selection 2026-04-03 22:06:00 -07:00
72 changed files with 1116 additions and 574 deletions
+8 -15
View File
@@ -12,21 +12,14 @@ Backend server + browser interface for MeshCore mesh radio networks. Connect you
* 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!
For advanced setup and troubleshooting see [README_ADVANCED.md](README_ADVANCED.md). If you plan to contribute, read [CONTRIBUTING.md](CONTRIBUTING.md).
**Warning:** This app is for trusted environments only. _Do not put this on an untrusted network, or open it to the public._ You can optionally set `MESHCORE_BASIC_AUTH_USERNAME` and `MESHCORE_BASIC_AUTH_PASSWORD` for app-wide HTTP Basic auth, but that is only a coarse gate and must be paired with HTTPS. The bots can execute arbitrary Python code which means anyone who gets access to the app can, too. To completely disable the bot system, start the server with `MESHCORE_DISABLE_BOTS=true` — this prevents all bot execution and blocks bot configuration changes via the API. If you need stronger access control, consider using a reverse proxy like Nginx, or extending FastAPI; full access control and user management are outside the scope of this app.
![Screenshot of the application's web interface](app_screenshot.png)
## Start Here
Most users should choose one of these paths:
1. Clone and build from source.
2. Download the prebuilt release zip if you are on a resource-constrained system and do not want to build the frontend locally.
3. Use Docker if that better matches how you deploy.
For advanced setup, troubleshooting, HTTPS, systemd service setup, and remediation environment variables, see [README_ADVANCED.md](README_ADVANCED.md).
If you plan to contribute, read [CONTRIBUTING.md](CONTRIBUTING.md).
> [!WARNING]
> RemoteTerm does *full* management of the radio, meaning that once a radio is connected to RemoteTerm, all contacts/channels will be imported and offloaded to RemoteTerm and the contacts actually synced to the device will be governed by RemoteTerm. This means that RemoteTerm can be a poor fit for users who are looking to swap radios in and out, maintaining radio state (favorites, channels, etc.) irrespective of app usage.
## Requirements
@@ -71,7 +64,7 @@ usbipd attach --wsl --busid 3-8
```
</details>
## Path 1: Clone And Build
## Install Path 1: Clone And Build
**This approach is recommended over Docker due to intermittent serial communications issues I've seen on \*nix systems.**
@@ -89,10 +82,10 @@ Access the app at http://localhost:8000.
Source checkouts expect a normal frontend build in `frontend/dist`.
> [!NOTE]
> [!TIP]
> Running on lightweight hardware, or just do not want to build the frontend locally? From a cloned checkout, run `python3 scripts/setup/fetch_prebuilt_frontend.py` to fetch and unpack a prebuilt frontend into `frontend/prebuilt`, then start the app normally with `uv run uvicorn app.main:app --host 0.0.0.0 --port 8000`.
> [!TIP]
> [!NOTE]
> On Linux, you can also install RemoteTerm as a persistent `systemd` service that starts on boot and restarts automatically on failure:
>
> ```bash
@@ -101,7 +94,7 @@ Source checkouts expect a normal frontend build in `frontend/dist`.
>
> For the full service workflow and post-install operations, see [README_ADVANCED.md](README_ADVANCED.md).
## Path 2: Docker
## Install Path 2: Docker
> **Warning:** Docker has had reports intermittent issues with serial event subscriptions. The native method above is more reliable.
+4 -2
View File
@@ -27,7 +27,8 @@ CREATE TABLE IF NOT EXISTS contacts (
on_radio INTEGER DEFAULT 0,
last_contacted INTEGER,
first_seen INTEGER,
last_read_at INTEGER
last_read_at INTEGER,
favorite INTEGER DEFAULT 0
);
CREATE TABLE IF NOT EXISTS channels (
@@ -37,7 +38,8 @@ CREATE TABLE IF NOT EXISTS channels (
on_radio INTEGER DEFAULT 0,
flood_scope_override TEXT,
path_hash_mode_override INTEGER,
last_read_at INTEGER
last_read_at INTEGER,
favorite INTEGER DEFAULT 0
);
CREATE TABLE IF NOT EXISTS messages (
+94
View File
@@ -413,6 +413,12 @@ async def run_migrations(conn: aiosqlite.Connection) -> int:
await set_version(conn, 54)
applied += 1
if version < 55:
logger.info("Applying migration 55: move favorites to per-entity columns")
await _migrate_055_favorites_to_columns(conn)
await set_version(conn, 55)
applied += 1
if applied > 0:
logger.info(
"Applied %d migration(s), schema now at version %d", applied, await get_version(conn)
@@ -3213,3 +3219,91 @@ async def _migrate_054_auto_resend_channel(conn: aiosqlite.Connection) -> None:
"ALTER TABLE app_settings ADD COLUMN auto_resend_channel INTEGER DEFAULT 0"
)
await conn.commit()
async def _migrate_055_favorites_to_columns(conn: aiosqlite.Connection) -> None:
"""Move favorites from app_settings JSON blob to per-entity boolean columns.
1. Add ``favorite`` column to contacts and channels tables.
2. Backfill from the ``app_settings.favorites`` JSON array.
3. Drop the ``favorites`` column from app_settings.
"""
import json as _json
# --- Add columns ---
tables_cursor = await conn.execute("SELECT name FROM sqlite_master WHERE type='table'")
existing_tables = {row[0] for row in await tables_cursor.fetchall()}
for table in ("contacts", "channels"):
if table not in existing_tables:
continue
col_cursor = await conn.execute(f"PRAGMA table_info({table})")
columns = {row[1] for row in await col_cursor.fetchall()}
if "favorite" not in columns:
await conn.execute(f"ALTER TABLE {table} ADD COLUMN favorite INTEGER DEFAULT 0")
await conn.commit()
# --- Backfill from JSON ---
tables_cursor = await conn.execute("SELECT name FROM sqlite_master WHERE type='table'")
if "app_settings" not in {row[0] for row in await tables_cursor.fetchall()}:
await conn.commit()
return
col_cursor = await conn.execute("PRAGMA table_info(app_settings)")
settings_columns = {row[1] for row in await col_cursor.fetchall()}
if "favorites" not in settings_columns:
await conn.commit()
return
cursor = await conn.execute("SELECT favorites FROM app_settings WHERE id = 1")
row = await cursor.fetchone()
if row and row[0]:
try:
favorites = _json.loads(row[0])
except (ValueError, TypeError):
favorites = []
contact_keys = []
channel_keys = []
for fav in favorites:
if not isinstance(fav, dict):
continue
fav_type = fav.get("type")
fav_id = fav.get("id")
if not fav_id:
continue
if fav_type == "contact":
contact_keys.append(fav_id)
elif fav_type == "channel":
channel_keys.append(fav_id)
if contact_keys:
placeholders = ",".join("?" for _ in contact_keys)
await conn.execute(
f"UPDATE contacts SET favorite = 1 WHERE public_key IN ({placeholders})",
contact_keys,
)
if channel_keys:
placeholders = ",".join("?" for _ in channel_keys)
await conn.execute(
f"UPDATE channels SET favorite = 1 WHERE key IN ({placeholders})",
channel_keys,
)
if contact_keys or channel_keys:
logger.info(
"Backfilled %d contact favorite(s) and %d channel favorite(s) from app_settings",
len(contact_keys),
len(channel_keys),
)
await conn.commit()
# --- Drop the JSON column ---
try:
await conn.execute("ALTER TABLE app_settings DROP COLUMN favorites")
await conn.commit()
except Exception as e:
error_msg = str(e).lower()
if "syntax error" in error_msg or "drop column" in error_msg:
logger.debug("SQLite doesn't support DROP COLUMN; favorites column will remain unused")
await conn.commit()
else:
raise
+2 -10
View File
@@ -91,6 +91,7 @@ class Contact(BaseModel):
lon: float | None = None
last_seen: int | None = None
on_radio: bool = False
favorite: bool = False
last_contacted: int | None = None # Last time we sent/received a message
last_read_at: int | None = None # Server-side read state tracking
first_seen: int | None = None
@@ -326,6 +327,7 @@ class Channel(BaseModel):
description="Per-channel path hash mode override (0=1-byte, 1=2-byte, 2=3-byte, null = use radio default)",
)
last_read_at: int | None = None # Server-side read state tracking
favorite: bool = False
class ChannelMessageCounts(BaseModel):
@@ -756,13 +758,6 @@ class RadioDiscoveryResponse(BaseModel):
)
class Favorite(BaseModel):
"""A favorite conversation."""
type: Literal["channel", "contact"] = Field(description="'channel' or 'contact'")
id: str = Field(description="Channel key or contact public key")
class UnreadCounts(BaseModel):
"""Aggregated unread counts, mention flags, and last message times for all conversations."""
@@ -790,9 +785,6 @@ class AppSettings(BaseModel):
"favorites reload first, then background fill targets about 80% of this value"
),
)
favorites: list[Favorite] = Field(
default_factory=list, description="List of favorited conversations"
)
auto_decrypt_dm_on_advert: bool = Field(
default=True,
description="Whether to attempt historical DM decryption on new contact advertisement",
+23 -27
View File
@@ -307,7 +307,7 @@ async def sync_and_offload_channels(mc: MeshCore, max_channels: int | None = Non
except Exception as e:
logger.warning("Error clearing channel %d: %s", idx, e)
logger.info("Synced %d channels, cleared %d from radio", synced, cleared)
logger.debug("Synced %d channels, cleared %d from radio", synced, cleared)
except Exception as e:
logger.error("Error during channel sync: %s", e)
@@ -428,7 +428,6 @@ async def ensure_default_channels() -> None:
async def sync_and_offload_all(mc: MeshCore) -> dict:
"""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.
@@ -944,10 +943,8 @@ async def sync_radio_time(mc: MeshCore) -> bool:
except Exception:
logger.warning("Reboot command failed", exc_info=True)
elif _clock_reboot_attempted:
logger.warning(
"Clock skew persists after reboot — the radio likely has a "
"hardware RTC that preserved the wrong time. A manual "
"'clkreboot' CLI command is needed to reset it."
logger.debug(
"Clock skew persists after reboot (hardware RTC); ignoring until next session."
)
return False
@@ -1057,7 +1054,7 @@ async def sync_contacts_from_radio(mc: MeshCore) -> dict:
return {"synced": 0, "radio_contacts": {}, "error": str(result)}
contacts = _normalize_radio_contacts_payload(result.payload)
logger.info("Found %d contacts on radio", len(contacts))
logger.debug("Found %d contacts on radio", len(contacts))
for public_key, contact_data in contacts.items():
await ContactRepository.upsert(
@@ -1071,7 +1068,23 @@ async def sync_contacts_from_radio(mc: MeshCore) -> dict:
)
synced += 1
logger.info("Synced %d contacts from radio snapshot", synced)
logger.debug("Synced %d contacts from radio snapshot", synced)
# Import radio-favorited contacts into app favorites
radio_fav_keys = [pk for pk, data in contacts.items() if data.get("flags", 0) & 0x01]
if radio_fav_keys:
try:
imported = 0
for pk in radio_fav_keys:
existing = await ContactRepository.get_by_key(pk)
if existing and not existing.favorite:
await ContactRepository.set_favorite(pk, True)
imported += 1
if imported:
logger.info("Imported %d radio favorite(s) into app favorites", imported)
except Exception as e:
logger.warning("Failed to import radio favorites: %s", e)
return {"synced": synced, "radio_contacts": contacts}
except Exception as e:
logger.error("Error during contact snapshot sync: %s", e)
@@ -1283,26 +1296,9 @@ async def get_contacts_selected_for_radio_sync() -> list[Contact]:
selected_contacts: list[Contact] = []
selected_keys: set[str] = set()
# Favorites first — always loaded up to max_contacts
favorite_contacts_loaded = 0
for favorite in app_settings.favorites:
if favorite.type != "contact":
continue
try:
contact = await ContactRepository.get_by_key_or_prefix(favorite.id)
except AmbiguousPublicKeyPrefixError:
logger.warning(
"Skipping favorite contact '%s': ambiguous key prefix; use full key",
favorite.id,
)
continue
if not contact:
continue
if len(contact.public_key) < 64:
logger.debug(
"Skipping unresolved prefix-only favorite contact '%s' for radio sync",
favorite.id,
)
continue
for contact in await ContactRepository.get_favorites():
key = contact.public_key.lower()
if key in selected_keys:
continue
+14 -2
View File
@@ -26,7 +26,7 @@ class ChannelRepository:
"""Get a channel by its key (32-char hex string)."""
cursor = await db.conn.execute(
"""
SELECT key, name, is_hashtag, on_radio, flood_scope_override, path_hash_mode_override, last_read_at
SELECT key, name, is_hashtag, on_radio, flood_scope_override, path_hash_mode_override, last_read_at, favorite
FROM channels
WHERE key = ?
""",
@@ -42,6 +42,7 @@ class ChannelRepository:
flood_scope_override=row["flood_scope_override"],
path_hash_mode_override=row["path_hash_mode_override"],
last_read_at=row["last_read_at"],
favorite=bool(row["favorite"]),
)
return None
@@ -49,7 +50,7 @@ class ChannelRepository:
async def get_all() -> list[Channel]:
cursor = await db.conn.execute(
"""
SELECT key, name, is_hashtag, on_radio, flood_scope_override, path_hash_mode_override, last_read_at
SELECT key, name, is_hashtag, on_radio, flood_scope_override, path_hash_mode_override, last_read_at, favorite
FROM channels
ORDER BY name
"""
@@ -64,10 +65,21 @@ class ChannelRepository:
flood_scope_override=row["flood_scope_override"],
path_hash_mode_override=row["path_hash_mode_override"],
last_read_at=row["last_read_at"],
favorite=bool(row["favorite"]),
)
for row in rows
]
@staticmethod
async def set_favorite(key: str, value: bool) -> bool:
"""Set or clear the favorite flag for a channel. Returns True if row was found."""
cursor = await db.conn.execute(
"UPDATE channels SET favorite = ? WHERE key = ?",
(1 if value else 0, key.upper()),
)
await db.conn.commit()
return cursor.rowcount > 0
@staticmethod
async def delete(key: str) -> None:
"""Delete a channel by key."""
+19
View File
@@ -170,6 +170,7 @@ class ContactRepository:
lon=row["lon"],
last_seen=row["last_seen"],
on_radio=bool(row["on_radio"]),
favorite=bool(row["favorite"]) if "favorite" in available_columns else False,
last_contacted=row["last_contacted"],
last_read_at=row["last_read_at"],
first_seen=row["first_seen"],
@@ -392,6 +393,24 @@ class ContactRepository:
)
await db.conn.commit()
@staticmethod
async def get_favorites() -> list[Contact]:
"""Return all contacts marked as favorite."""
cursor = await db.conn.execute(
"SELECT * FROM contacts WHERE favorite = 1 AND LENGTH(public_key) = 64"
)
rows = await cursor.fetchall()
return [ContactRepository._row_to_contact(row) for row in rows]
@staticmethod
async def set_favorite(public_key: str, value: bool) -> None:
"""Set or clear the favorite flag for a contact."""
await db.conn.execute(
"UPDATE contacts SET favorite = ? WHERE public_key = ?",
(1 if value else 0, public_key.lower()),
)
await db.conn.commit()
@staticmethod
async def delete(public_key: str) -> None:
normalized = public_key.lower()
+3 -45
View File
@@ -1,10 +1,10 @@
import json
import logging
import time
from typing import Any, Literal
from typing import Any
from app.database import db
from app.models import AppSettings, Favorite
from app.models import AppSettings
from app.path_utils import bucket_path_hash_widths
logger = logging.getLogger(__name__)
@@ -26,7 +26,7 @@ class AppSettingsRepository:
"""
cursor = await db.conn.execute(
"""
SELECT max_radio_contacts, favorites, auto_decrypt_dm_on_advert,
SELECT max_radio_contacts, auto_decrypt_dm_on_advert,
last_message_times,
advert_interval, last_advert_time, flood_scope,
blocked_keys, blocked_names, discovery_blocked_types,
@@ -40,20 +40,6 @@ class AppSettingsRepository:
# Should not happen after migration, but handle gracefully
return AppSettings()
# Parse favorites JSON
favorites = []
if row["favorites"]:
try:
favorites_data = json.loads(row["favorites"])
favorites = [Favorite(**f) for f in favorites_data]
except (json.JSONDecodeError, TypeError, KeyError) as e:
logger.warning(
"Failed to parse favorites JSON, using empty list: %s (data=%r)",
e,
row["favorites"][:100] if row["favorites"] else None,
)
favorites = []
# Parse last_message_times JSON
last_message_times: dict[str, int] = {}
if row["last_message_times"]:
@@ -107,7 +93,6 @@ class AppSettingsRepository:
return AppSettings(
max_radio_contacts=row["max_radio_contacts"],
favorites=favorites,
auto_decrypt_dm_on_advert=bool(row["auto_decrypt_dm_on_advert"]),
last_message_times=last_message_times,
advert_interval=row["advert_interval"] or 0,
@@ -123,7 +108,6 @@ class AppSettingsRepository:
@staticmethod
async def update(
max_radio_contacts: int | None = None,
favorites: list[Favorite] | None = None,
auto_decrypt_dm_on_advert: bool | None = None,
last_message_times: dict[str, int] | None = None,
advert_interval: int | None = None,
@@ -143,11 +127,6 @@ class AppSettingsRepository:
updates.append("max_radio_contacts = ?")
params.append(max_radio_contacts)
if favorites is not None:
updates.append("favorites = ?")
favorites_json = json.dumps([f.model_dump() for f in favorites])
params.append(favorites_json)
if auto_decrypt_dm_on_advert is not None:
updates.append("auto_decrypt_dm_on_advert = ?")
params.append(1 if auto_decrypt_dm_on_advert else 0)
@@ -195,27 +174,6 @@ class AppSettingsRepository:
return await AppSettingsRepository.get()
@staticmethod
async def add_favorite(fav_type: Literal["channel", "contact"], fav_id: str) -> AppSettings:
"""Add a favorite, avoiding duplicates."""
settings = await AppSettingsRepository.get()
# Check if already favorited
if any(f.type == fav_type and f.id == fav_id for f in settings.favorites):
return settings
new_favorites = settings.favorites + [Favorite(type=fav_type, id=fav_id)]
return await AppSettingsRepository.update(favorites=new_favorites)
@staticmethod
async def remove_favorite(fav_type: Literal["channel", "contact"], fav_id: str) -> AppSettings:
"""Remove a favorite."""
settings = await AppSettingsRepository.get()
new_favorites = [
f for f in settings.favorites if not (f.type == fav_type and f.id == fav_id)
]
return await AppSettingsRepository.update(favorites=new_favorites)
@staticmethod
async def toggle_blocked_key(key: str) -> AppSettings:
"""Toggle a public key in the blocked list. Keys are normalized to lowercase."""
+27 -18
View File
@@ -7,7 +7,7 @@ from pydantic import BaseModel, Field
from app.models import CONTACT_TYPE_REPEATER, AppSettings
from app.region_scope import normalize_region_scope
from app.repository import AppSettingsRepository, ContactRepository
from app.repository import AppSettingsRepository, ChannelRepository, ContactRepository
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/settings", tags=["settings"])
@@ -72,6 +72,12 @@ class FavoriteRequest(BaseModel):
id: str = Field(description="Channel key or contact public key")
class FavoriteToggleResponse(BaseModel):
type: Literal["channel", "contact"]
id: str
favorite: bool
class TrackedTelemetryRequest(BaseModel):
public_key: str = Field(description="Public key of the repeater to toggle tracking")
@@ -157,27 +163,30 @@ async def update_settings(update: AppSettingsUpdate) -> AppSettings:
return await AppSettingsRepository.get()
@router.post("/favorites/toggle", response_model=AppSettings)
async def toggle_favorite(request: FavoriteRequest) -> AppSettings:
@router.post("/favorites/toggle", response_model=FavoriteToggleResponse)
async def toggle_favorite(request: FavoriteRequest) -> FavoriteToggleResponse:
"""Toggle a conversation's favorite status."""
settings = await AppSettingsRepository.get()
is_favorited = any(f.type == request.type and f.id == request.id for f in settings.favorites)
if request.type == "contact":
contact = await ContactRepository.get_by_key(request.id)
if not contact:
raise HTTPException(status_code=404, detail="Contact not found")
new_value = not contact.favorite
await ContactRepository.set_favorite(request.id, new_value)
logger.info("%s contact favorite: %s", "Added" if new_value else "Removed", request.id[:12])
# When newly favorited, load to radio immediately for DM ACK support
if new_value:
from app.radio_sync import ensure_contact_on_radio
if is_favorited:
logger.info("Removing favorite: %s %s", request.type, request.id[:12])
result = await AppSettingsRepository.remove_favorite(request.type, request.id)
asyncio.create_task(ensure_contact_on_radio(request.id, force=True))
else:
logger.info("Adding favorite: %s %s", request.type, request.id[:12])
result = await AppSettingsRepository.add_favorite(request.type, request.id)
channel = await ChannelRepository.get_by_key(request.id)
if not channel:
raise HTTPException(status_code=404, detail="Channel not found")
new_value = not channel.favorite
await ChannelRepository.set_favorite(request.id, new_value)
logger.info("%s channel favorite: %s", "Added" if new_value else "Removed", request.id[:12])
# When a contact is newly favorited, load just that contact to the radio
# immediately so DM ACK support does not wait for the next maintenance cycle.
if request.type == "contact" and not is_favorited:
from app.radio_sync import ensure_contact_on_radio
asyncio.create_task(ensure_contact_on_radio(request.id, force=True))
return result
return FavoriteToggleResponse(type=request.type, id=request.id, favorite=new_value)
@router.post("/blocked-keys/toggle", response_model=AppSettings)
+8 -1
View File
@@ -215,7 +215,14 @@ async def run_post_connect_setup(radio_manager) -> None:
# Sync contacts/channels from radio to DB and clear radio
logger.info("Syncing and offloading radio data...")
result = await sync_and_offload_all(mc)
logger.info("Sync complete: %s", result)
c = result.get("contacts", {})
ch = result.get("channels", {})
logger.info(
"Sync complete: %d contacts synced, %d channels synced, %d channels cleared",
c.get("synced", 0),
ch.get("synced", 0),
ch.get("cleared", 0),
)
# Send advertisement to announce our presence (if enabled and not throttled)
if await send_advertisement(mc):
+35 -8
View File
@@ -18,6 +18,7 @@ import {
useUnreadTitle,
useRawPacketStatsSession,
} from './hooks';
import { toast } from './components/ui/sonner';
import { AppShell } from './components/AppShell';
import type { MessageInputHandle } from './components/MessageInput';
import { DistanceUnitProvider } from './contexts/DistanceUnitContext';
@@ -150,10 +151,8 @@ export function App() {
const {
appSettings,
favorites,
fetchAppSettings,
handleSaveAppSettings,
handleToggleFavorite,
handleToggleBlockedKey,
handleToggleBlockedName,
handleToggleTrackedTelemetry,
@@ -204,6 +203,38 @@ export function App() {
removeConversationMessagesRef.current(conversationId),
});
const handleToggleFavorite = useCallback(
async (type: 'channel' | 'contact', id: string) => {
// Optimistically toggle the favorite flag
if (type === 'contact') {
setContacts((prev) =>
prev.map((c) => (c.public_key === id ? { ...c, favorite: !c.favorite } : c))
);
} else {
setChannels((prev) =>
prev.map((c) => (c.key === id ? { ...c, favorite: !c.favorite } : c))
);
}
try {
await api.toggleFavorite(type, id);
} catch {
// Revert on failure
if (type === 'contact') {
setContacts((prev) =>
prev.map((c) => (c.public_key === id ? { ...c, favorite: !c.favorite } : c))
);
} else {
setChannels((prev) =>
prev.map((c) => (c.key === id ? { ...c, favorite: !c.favorite } : c))
);
}
toast.error('Failed to update favorite');
}
},
[setContacts, setChannels]
);
// useConversationRouter is called second — it receives channels/contacts as inputs
const {
activeConversation,
@@ -290,8 +321,8 @@ export function App() {
markAllRead,
refreshUnreads,
} = useUnreadCounts(channels, contacts, activeConversation);
useFaviconBadge(unreadCounts, mentions, favorites);
useUnreadTitle(unreadCounts, favorites);
useFaviconBadge(unreadCounts, mentions, channels);
useUnreadTitle(unreadCounts, contacts, channels);
useEffect(() => {
if (activeConversation?.type !== 'channel') {
@@ -491,7 +522,6 @@ export function App() {
onMarkAllRead: () => {
void markAllRead();
},
favorites,
isConversationNotificationsEnabled,
blockedKeys: appSettings?.blocked_keys ?? [],
blockedNames: appSettings?.blocked_names ?? [],
@@ -507,7 +537,6 @@ export function App() {
rawPacketStatsSession,
config,
health,
favorites,
messages: sortedMessages,
preSorted: activeContactIsRoom,
messagesLoading,
@@ -614,7 +643,6 @@ export function App() {
onClose: handleCloseContactInfo,
contacts,
config,
favorites,
onToggleFavorite: handleToggleFavorite,
onNavigateToChannel: handleNavigateToChannel,
onSearchMessagesByKey: (publicKey: string) => {
@@ -632,7 +660,6 @@ export function App() {
channelKey: infoPaneChannelKey,
onClose: handleCloseChannelInfo,
channels,
favorites,
onToggleFavorite: handleToggleFavorite,
};
+2 -3
View File
@@ -9,7 +9,6 @@ import type {
ContactAnalytics,
ContactAdvertPathSummary,
FanoutConfig,
Favorite,
HealthStatus,
MaintenanceResult,
Message,
@@ -334,8 +333,8 @@ export const api = {
}),
// Favorites
toggleFavorite: (type: Favorite['type'], id: string) =>
fetchJson<AppSettings>('/settings/favorites/toggle', {
toggleFavorite: (type: 'channel' | 'contact', id: string) =>
fetchJson<{ type: string; id: string; favorite: boolean }>('/settings/favorites/toggle', {
method: 'POST',
body: JSON.stringify({ type, id }),
}),
+1 -1
View File
@@ -299,7 +299,7 @@ export function AppShell({
<Suspense
fallback={
<div className="flex items-center justify-center h-full text-muted-foreground">
Loading cracker...
Loading channel finder...
</div>
}
>
+2 -5
View File
@@ -3,17 +3,15 @@ import { PieChart, Pie, Cell, ResponsiveContainer, Tooltip as RechartsTooltip }
import { Star } from 'lucide-react';
import { api } from '../api';
import { formatTime } from '../utils/messageParser';
import { isFavorite } from '../utils/favorites';
import { handleKeyboardActivate } from '../utils/a11y';
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from './ui/sheet';
import { toast } from './ui/sonner';
import type { Channel, ChannelDetail, Favorite, PathHashWidthStats } from '../types';
import type { Channel, ChannelDetail, PathHashWidthStats } from '../types';
interface ChannelInfoPaneProps {
channelKey: string | null;
onClose: () => void;
channels: Channel[];
favorites: Favorite[];
onToggleFavorite: (type: 'channel' | 'contact', id: string) => void;
}
@@ -21,7 +19,6 @@ export function ChannelInfoPane({
channelKey,
onClose,
channels,
favorites,
onToggleFavorite,
}: ChannelInfoPaneProps) {
const [detail, setDetail] = useState<ChannelDetail | null>(null);
@@ -125,7 +122,7 @@ export function ChannelInfoPane({
className="text-sm flex items-center gap-2 hover:text-primary transition-colors"
onClick={() => onToggleFavorite('channel', channel.key)}
>
{isFavorite(favorites, 'channel', channel.key) ? (
{channel.favorite ? (
<>
<Star className="h-4.5 w-4.5 fill-current text-favorite" aria-hidden="true" />
<span>Remove from favorites</span>
@@ -53,17 +53,17 @@ export function ChannelPathHashModeOverrideModal({
{
value: 0,
label: '1-byte hop identifiers',
description: 'Shortest paths, least repeater disambiguation',
description: 'Least repeater disambiguation, up to 63 hops',
},
{
value: 1,
label: '2-byte hop identifiers',
description: 'Better repeater disambiguation',
description: 'Better repeater disambiguation, up to 32 hops',
},
{
value: 2,
label: '3-byte hop identifiers',
description: 'Best repeater disambiguation, longest paths',
description: 'Best repeater disambiguation, up to 21 hops',
},
];
+11 -19
View File
@@ -5,7 +5,6 @@ import { DirectTraceIcon } from './DirectTraceIcon';
import { ContactPathDiscoveryModal } from './ContactPathDiscoveryModal';
import { ChannelFloodScopeOverrideModal } from './ChannelFloodScopeOverrideModal';
import { ChannelPathHashModeOverrideModal } from './ChannelPathHashModeOverrideModal';
import { isFavorite } from '../utils/favorites';
import { handleKeyboardActivate } from '../utils/a11y';
import { isPublicChannelKey } from '../utils/publicChannel';
import { stripRegionScopePrefix } from '../utils/regionScope';
@@ -13,14 +12,7 @@ import { isPrefixOnlyContact } from '../utils/pubkey';
import { cn } from '../lib/utils';
import { ContactAvatar } from './ContactAvatar';
import { ContactStatusInfo } from './ContactStatusInfo';
import type {
Channel,
Contact,
Conversation,
Favorite,
PathDiscoveryResponse,
RadioConfig,
} from '../types';
import type { Channel, Contact, Conversation, PathDiscoveryResponse, RadioConfig } from '../types';
import { CONTACT_TYPE_ROOM } from '../types';
interface ChatHeaderProps {
@@ -28,7 +20,6 @@ interface ChatHeaderProps {
contacts: Contact[];
channels: Channel[];
config: RadioConfig | null;
favorites: Favorite[];
notificationsSupported: boolean;
notificationsEnabled: boolean;
notificationsPermission: NotificationPermission | 'unsupported';
@@ -49,7 +40,6 @@ export function ChatHeader({
contacts,
channels,
config,
favorites,
notificationsSupported,
notificationsEnabled,
notificationsPermission,
@@ -105,12 +95,18 @@ export function ChatHeader({
const titleClickable =
(conversation.type === 'contact' && onOpenContactInfo) ||
(conversation.type === 'channel' && onOpenChannelInfo);
const isFav =
conversation.type === 'contact'
? (activeContact?.favorite ?? false)
: conversation.type === 'channel'
? (activeChannel?.favorite ?? false)
: false;
const favoriteTitle =
conversation.type === 'contact'
? isFavorite(favorites, 'contact', conversation.id)
? isFav
? 'Remove from favorites. Favorite contacts stay loaded on the radio for ACK support.'
: 'Add to favorites. Favorite contacts stay loaded on the radio for ACK support.'
: isFavorite(favorites, conversation.type as 'channel' | 'contact', conversation.id)
: isFav
? 'Remove from favorites'
: 'Add to favorites';
@@ -359,13 +355,9 @@ export function ChatHeader({
onToggleFavorite(conversation.type as 'channel' | 'contact', conversation.id)
}
title={favoriteTitle}
aria-label={
isFavorite(favorites, conversation.type as 'channel' | 'contact', conversation.id)
? 'Remove from favorites'
: 'Add to favorites'
}
aria-label={isFav ? 'Remove from favorites' : 'Add to favorites'}
>
{isFavorite(favorites, conversation.type as 'channel' | 'contact', conversation.id) ? (
{isFav ? (
<Star className="h-4 w-4 fill-current text-favorite" aria-hidden="true" />
) : (
<Star className="h-4 w-4 text-muted-foreground" aria-hidden="true" />
+1 -5
View File
@@ -29,7 +29,6 @@ import {
} from '../utils/pathUtils';
import { isPublicChannelKey } from '../utils/publicChannel';
import { getMapFocusHash } from '../utils/urlHash';
import { isFavorite } from '../utils/favorites';
import { handleKeyboardActivate } from '../utils/a11y';
import { ContactAvatar } from './ContactAvatar';
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from './ui/sheet';
@@ -42,7 +41,6 @@ import type {
ContactAnalytics,
ContactAnalyticsHourlyBucket,
ContactAnalyticsWeeklyBucket,
Favorite,
RadioConfig,
} from '../types';
@@ -67,7 +65,6 @@ interface ContactInfoPaneProps {
onClose: () => void;
contacts: Contact[];
config: RadioConfig | null;
favorites: Favorite[];
onToggleFavorite: (type: 'channel' | 'contact', id: string) => void;
onNavigateToChannel?: (channelKey: string) => void;
onSearchMessagesByKey?: (publicKey: string) => void;
@@ -84,7 +81,6 @@ export function ContactInfoPane({
onClose,
contacts,
config,
favorites,
onToggleFavorite,
onNavigateToChannel,
onSearchMessagesByKey,
@@ -384,7 +380,7 @@ export function ContactInfoPane({
onClick={() => onToggleFavorite('contact', contact.public_key)}
title="Favorite contacts stay loaded on the radio for ACK support"
>
{isFavorite(favorites, 'contact', contact.public_key) ? (
{contact.favorite ? (
<>
<Star className="h-4.5 w-4.5 fill-current text-favorite" aria-hidden="true" />
<span>Remove from favorites</span>
@@ -10,7 +10,6 @@ import type {
Channel,
Contact,
Conversation,
Favorite,
HealthStatus,
Message,
PathDiscoveryResponse,
@@ -42,7 +41,6 @@ interface ConversationPaneProps {
notificationsSupported: boolean;
notificationsEnabled: boolean;
notificationsPermission: NotificationPermission | 'unsupported';
favorites: Favorite[];
messages: Message[];
preSorted?: boolean;
messagesLoading: boolean;
@@ -119,7 +117,6 @@ export function ConversationPane({
notificationsSupported,
notificationsEnabled,
notificationsPermission,
favorites,
messages,
preSorted,
messagesLoading,
@@ -237,7 +234,6 @@ export function ConversationPane({
key={activeConversation.id}
conversation={activeConversation}
contacts={contacts}
favorites={favorites}
notificationsSupported={notificationsSupported}
notificationsEnabled={notificationsEnabled}
notificationsPermission={notificationsPermission}
@@ -266,7 +262,6 @@ export function ConversationPane({
contacts={contacts}
channels={channels}
config={config}
favorites={favorites}
notificationsSupported={notificationsSupported}
notificationsEnabled={notificationsEnabled}
notificationsPermission={notificationsPermission}
+33 -12
View File
@@ -98,7 +98,7 @@ export function CrackerPanel({
.catch((err) => {
console.error('Failed to load wordlist:', err);
toast.error('Failed to load wordlist', {
description: 'Cracking will not be available',
description: 'Channel finder will not be available',
});
});
}, [visible, wordlistLoaded]);
@@ -356,7 +356,7 @@ export function CrackerPanel({
}
} catch (err) {
console.error('Failed to create channel or decrypt historical:', err);
toast.error('Failed to save cracked channel', {
toast.error('Failed to save found channel', {
description:
err instanceof Error ? err.message : 'Channel discovered but could not be saved',
});
@@ -409,7 +409,10 @@ export function CrackerPanel({
const handleStart = () => {
if (!gpuAvailable) {
toast.error('WebGPU not available', {
description: 'Cracking requires Chrome 113+ or Edge 113+ with WebGPU support.',
description:
typeof window !== 'undefined' && !window.isSecureContext
? 'WebGPU requires HTTPS when not on localhost. Set up a certificate or configure your browser to treat this origin as secure.'
: 'Channel finder requires Chrome 113+ or Edge 113+ with WebGPU support.',
});
return;
}
@@ -537,7 +540,7 @@ export function CrackerPanel({
Pending: <span className="text-foreground font-medium">{pendingCount}</span>
</span>
<span className="text-muted-foreground">
Cracked: <span className="text-success font-medium">{crackedCount}</span>
Found: <span className="text-success font-medium">{crackedCount}</span>
</span>
<span className="text-muted-foreground">
Failed: <span className="text-destructive font-medium">{failedCount}</span>
@@ -581,7 +584,7 @@ export function CrackerPanel({
aria-valuenow={Math.round(progress.percent)}
aria-valuemin={0}
aria-valuemax={100}
aria-label="Cracking progress"
aria-label="Channel finder progress"
>
<div
className="h-full bg-primary transition-all duration-200"
@@ -593,8 +596,26 @@ export function CrackerPanel({
{/* GPU status */}
{gpuAvailable === false && (
<div className="text-sm text-destructive" role="alert">
WebGPU not available. Cracking requires Chrome 113+ or Edge 113+.
<div className="text-sm text-destructive space-y-1.5" role="alert">
<p>WebGPU not available.</p>
{typeof window !== 'undefined' && !window.isSecureContext ? (
<div className="rounded-md border border-destructive/30 bg-destructive/5 p-2.5 text-xs text-destructive/90">
<p className="font-medium mb-1">WebGPU requires HTTPS when not on localhost.</p>
<p>To enable it:</p>
<ul className="list-disc ml-4 mt-1 space-y-0.5">
<li>
Set up a TLS certificate (see the HTTPS section of README_ADVANCED.md, or re-run
the Docker setup script which can generate one automatically)
</li>
<li>
Or configure your browser to treat this origin as secure (sometimes called
&ldquo;insecure origins treated as secure&rdquo; in browser flags)
</li>
</ul>
</div>
) : (
<p>Channel finder requires Chrome 113+ or Edge 113+ with WebGPU support.</p>
)}
</div>
)}
{!wordlistLoaded && gpuAvailable !== false && (
@@ -603,10 +624,10 @@ export function CrackerPanel({
</div>
)}
{/* Cracked channels list */}
{/* Found channels list */}
{crackedChannels.length > 0 && (
<div>
<div className="text-xs text-muted-foreground mb-1">Cracked Channels:</div>
<div className="text-xs text-muted-foreground mb-1">Found Channels:</div>
<div className="space-y-1">
{crackedChannels.map((channel, i) => (
<div
@@ -630,8 +651,8 @@ export function CrackerPanel({
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> Retry failed at n+1</strong> will return to the failed queue and pick up messages
it couldn't find a key for, 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 and also result in
@@ -639,7 +660,7 @@ export function CrackerPanel({
<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.
may allow accelerated searching and/or system instability.
</p>
</div>
);
+2 -2
View File
@@ -991,7 +991,7 @@ export function MessageList({
displaySender
)}
<span className="font-normal text-muted-foreground ml-2 text-[0.6875rem]">
{formatTime(msg.sender_timestamp || msg.received_at)}
{formatTime(msg.received_at)}
</span>
{!msg.outgoing && msg.paths && msg.paths.length > 0 && (
<HopCountBadge
@@ -1019,7 +1019,7 @@ export function MessageList({
{!showAvatar && (
<>
<span className="text-[0.625rem] text-muted-foreground ml-2">
{formatTime(msg.sender_timestamp || msg.received_at)}
{formatTime(msg.received_at)}
</span>
{!msg.outgoing && msg.paths && msg.paths.length > 0 && (
<HopCountBadge
+2 -11
View File
@@ -9,17 +9,10 @@ import { RepeaterLogin } from './RepeaterLogin';
import { ServerLoginStatusBanner } from './ServerLoginStatusBanner';
import { useRememberedServerPassword } from '../hooks/useRememberedServerPassword';
import { useRepeaterDashboard } from '../hooks/useRepeaterDashboard';
import { isFavorite } from '../utils/favorites';
import { handleKeyboardActivate } from '../utils/a11y';
import { isValidLocation } from '../utils/pathUtils';
import { ContactStatusInfo } from './ContactStatusInfo';
import type {
Contact,
Conversation,
Favorite,
PathDiscoveryResponse,
TelemetryHistoryEntry,
} from '../types';
import type { Contact, Conversation, PathDiscoveryResponse, TelemetryHistoryEntry } from '../types';
import { cn } from '../lib/utils';
import { TelemetryPane } from './repeater/RepeaterTelemetryPane';
import { NeighborsPane } from './repeater/RepeaterNeighborsPane';
@@ -41,7 +34,6 @@ export { formatDuration, formatClockDrift } from './repeater/repeaterPaneShared'
interface RepeaterDashboardProps {
conversation: Conversation;
contacts: Contact[];
favorites: Favorite[];
notificationsSupported: boolean;
notificationsEnabled: boolean;
notificationsPermission: NotificationPermission | 'unsupported';
@@ -61,7 +53,6 @@ interface RepeaterDashboardProps {
export function RepeaterDashboard({
conversation,
contacts,
favorites,
notificationsSupported,
notificationsEnabled,
notificationsPermission,
@@ -134,7 +125,7 @@ export function RepeaterDashboard({
setTelemetryHistory(liveHistory);
}, [paneData.status?.telemetry_history]);
const isFav = isFavorite(favorites, 'contact', conversation.id);
const isFav = contact?.favorite ?? false;
const handleRepeaterLogin = async (nextPassword: string) => {
await login(nextPassword);
+6 -17
View File
@@ -19,7 +19,6 @@ import {
type Contact,
type Channel,
type Conversation,
type Favorite,
} from '../types';
import {
buildSidebarSectionSortOrders,
@@ -36,7 +35,6 @@ import { isPublicChannelKey } from '../utils/publicChannel';
import { getContactDisplayName } from '../utils/pubkey';
import { handleKeyboardActivate } from '../utils/a11y';
import { ContactAvatar } from './ContactAvatar';
import { isFavorite } from '../utils/favorites';
import { Input } from './ui/input';
import { Button } from './ui/button';
import { cn } from '@/lib/utils';
@@ -106,7 +104,6 @@ interface SidebarProps {
crackerRunning: boolean;
onToggleCracker: () => void;
onMarkAllRead: () => void;
favorites: Favorite[];
isConversationNotificationsEnabled?: (type: 'channel' | 'contact', id: string) => boolean;
blockedKeys?: string[];
blockedNames?: string[];
@@ -135,7 +132,6 @@ export function Sidebar({
crackerRunning,
onToggleCracker,
onMarkAllRead,
favorites,
isConversationNotificationsEnabled,
blockedKeys = [],
blockedNames = [],
@@ -488,22 +484,16 @@ export function Sidebar({
nonFavoriteRooms,
nonFavoriteRepeaters,
} = useMemo(() => {
const favChannels = filteredChannels.filter((c) => isFavorite(favorites, 'channel', c.key));
const favChannels = filteredChannels.filter((c) => c.favorite);
const favContacts = [
...filteredNonRepeaterContacts,
...filteredRooms,
...filteredRepeaters,
].filter((c) => isFavorite(favorites, 'contact', c.public_key));
const nonFavChannels = filteredChannels.filter((c) => !isFavorite(favorites, 'channel', c.key));
const nonFavContacts = filteredNonRepeaterContacts.filter(
(c) => !isFavorite(favorites, 'contact', c.public_key)
);
const nonFavRooms = filteredRooms.filter(
(c) => !isFavorite(favorites, 'contact', c.public_key)
);
const nonFavRepeaters = filteredRepeaters.filter(
(c) => !isFavorite(favorites, 'contact', c.public_key)
);
].filter((c) => c.favorite);
const nonFavChannels = filteredChannels.filter((c) => !c.favorite);
const nonFavContacts = filteredNonRepeaterContacts.filter((c) => !c.favorite);
const nonFavRooms = filteredRooms.filter((c) => !c.favorite);
const nonFavRepeaters = filteredRepeaters.filter((c) => !c.favorite);
const items: FavoriteItem[] = [
...favChannels.map((channel) => ({ type: 'channel' as const, channel })),
@@ -522,7 +512,6 @@ export function Sidebar({
filteredNonRepeaterContacts,
filteredRooms,
filteredRepeaters,
favorites,
sectionSortOrders.favorites,
sortFavoriteItemsByOrder,
]);
+147
View File
@@ -28,6 +28,48 @@ import { cn } from '@/lib/utils';
type TraceSortMode = 'alpha' | 'recent' | 'distance';
type CustomHopBytes = 1 | 2 | 4;
const RECENT_TRACES_KEY = 'remoteterm-recent-traces';
const MAX_RECENT_TRACES = 5;
interface SavedTraceHop {
kind: 'repeater' | 'custom';
publicKey?: string;
hopHex?: string;
hopBytes?: CustomHopBytes;
displayName: string;
}
interface SavedTrace {
hops: SavedTraceHop[];
ranAt: number;
}
function loadRecentTraces(): SavedTrace[] {
try {
const raw = localStorage.getItem(RECENT_TRACES_KEY);
if (!raw) return [];
const parsed = JSON.parse(raw);
return Array.isArray(parsed) ? parsed.slice(0, MAX_RECENT_TRACES) : [];
} catch {
return [];
}
}
function saveRecentTrace(trace: SavedTrace): void {
try {
const existing = loadRecentTraces();
// Dedupe by hop signature
const sig = trace.hops.map((h) => h.publicKey ?? h.hopHex ?? '').join(',');
const deduped = existing.filter(
(t) => t.hops.map((h) => h.publicKey ?? h.hopHex ?? '').join(',') !== sig
);
const updated = [trace, ...deduped].slice(0, MAX_RECENT_TRACES);
localStorage.setItem(RECENT_TRACES_KEY, JSON.stringify(updated));
} catch {
// localStorage may be disabled
}
}
type TraceDraftHop =
| { id: string; kind: 'repeater'; publicKey: string }
| { id: string; kind: 'custom'; hopHex: string; hopBytes: CustomHopBytes };
@@ -154,6 +196,7 @@ export function TracePane({ contacts, config, onRunTracePath }: TracePaneProps)
const [customHopBytesDraft, setCustomHopBytesDraft] = useState<CustomHopBytes>(1);
const [customHopHexDraft, setCustomHopHexDraft] = useState('');
const [customHopError, setCustomHopError] = useState<string | null>(null);
const [recentTraces, setRecentTraces] = useState<SavedTrace[]>(loadRecentTraces);
const activeRunTokenRef = useRef(0);
const repeaters = useMemo(() => {
@@ -272,6 +315,56 @@ export function TracePane({ contacts, config, onRunTracePath }: TracePaneProps)
clearPendingResult();
};
const handleLoadRecentTrace = async (trace: SavedTrace) => {
const hops: TraceDraftHop[] = trace.hops.map((h, i) => {
if (h.kind === 'repeater' && h.publicKey) {
return {
id: nextDraftHopId('repeater', i),
kind: 'repeater' as const,
publicKey: h.publicKey,
};
}
return {
id: nextDraftHopId('custom', i),
kind: 'custom' as const,
hopHex: h.hopHex ?? '',
hopBytes: h.hopBytes ?? (1 as CustomHopBytes),
};
});
setDraftHops(hops);
// Determine hop hash bytes from the loaded hops
const customHop = hops.find((h) => h.kind === 'custom');
const hopHashBytes: CustomHopBytes = customHop?.hopBytes ?? 4;
// Run the trace immediately
const runToken = activeRunTokenRef.current + 1;
activeRunTokenRef.current = runToken;
setLoading(true);
setError(null);
setResult(null);
try {
const traceResult = await onRunTracePath(
hopHashBytes,
hops.map((hop) =>
hop.kind === 'repeater' ? { public_key: hop.publicKey } : { hop_hex: hop.hopHex }
)
);
if (activeRunTokenRef.current !== runToken) return;
setResult(traceResult);
// Re-save to bump this trace to the top of recents
const savedTrace: SavedTrace = { hops: trace.hops, ranAt: Date.now() };
saveRecentTrace(savedTrace);
setRecentTraces(loadRecentTraces());
} catch (err) {
if (activeRunTokenRef.current !== runToken) return;
setError(err instanceof Error ? err.message : 'Unknown error');
} finally {
if (activeRunTokenRef.current === runToken) setLoading(false);
}
};
const handleRunTrace = async () => {
if (draftHops.length === 0) {
return;
@@ -292,6 +385,27 @@ export function TracePane({ contacts, config, onRunTracePath }: TracePaneProps)
return;
}
setResult(traceResult);
// Persist to recent traces
const savedHops: SavedTraceHop[] = draftHops.map((hop) => {
if (hop.kind === 'repeater') {
const c = repeatersByKey.get(hop.publicKey);
return {
kind: 'repeater',
publicKey: hop.publicKey,
displayName: getContactDisplayName(c?.name, hop.publicKey, c?.last_advert ?? null),
};
}
return {
kind: 'custom',
hopHex: hop.hopHex,
hopBytes: hop.hopBytes,
displayName: `${hop.hopHex.toUpperCase()} (${hop.hopBytes}B)`,
};
});
const trace: SavedTrace = { hops: savedHops, ranAt: Date.now() };
saveRecentTrace(trace);
setRecentTraces(loadRecentTraces());
} catch (err) {
if (activeRunTokenRef.current !== runToken) {
return;
@@ -453,6 +567,39 @@ export function TracePane({ contacts, config, onRunTracePath }: TracePaneProps)
<p className="mt-1 text-xs text-muted-foreground">
The first node is display-only. The terminal node is the local radio.
</p>
{recentTraces.length > 0 && (
<div className="mt-2">
<div className="text-[0.625rem] uppercase tracking-wider text-muted-foreground font-medium mb-1">
Rerun a recent trace:
</div>
<div className="flex flex-wrap gap-1.5">
{recentTraces.map((trace, i) => {
const label = trace.hops
.map((h) => {
if (h.kind === 'repeater' && h.publicKey) {
const shortKey = h.publicKey.slice(0, 12);
return h.displayName !== shortKey
? `${h.displayName} (${shortKey})`
: shortKey;
}
return h.displayName;
})
.join(' → ');
return (
<button
key={i}
type="button"
className="rounded-md border border-border px-2.5 py-1.5 text-xs hover:bg-accent transition-colors truncate max-w-full disabled:opacity-50 disabled:cursor-not-allowed"
disabled={loading}
onClick={() => handleLoadRecentTrace(trace)}
>
{label}
</button>
);
})}
</div>
</div>
)}
</div>
{draftHops.length > 0 ? (
<Button
@@ -13,6 +13,8 @@ export function ConsolePane({
}) {
const [input, setInput] = useState('');
const outputRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
const prevLoadingRef = useRef(loading);
// Auto-scroll to bottom on new entries
useEffect(() => {
@@ -21,6 +23,14 @@ export function ConsolePane({
}
}, [history]);
// Refocus input after command completes
useEffect(() => {
if (prevLoadingRef.current && !loading) {
inputRef.current?.focus();
}
prevLoadingRef.current = loading;
}, [loading]);
const handleSubmit = useCallback(
async (e: FormEvent) => {
e.preventDefault();
@@ -59,6 +69,7 @@ export function ConsolePane({
</div>
<form onSubmit={handleSubmit} className="flex gap-2 p-2 border-t border-border">
<Input
ref={inputRef}
type="text"
autoComplete="off"
name="console-input"
@@ -90,6 +90,15 @@ export function TelemetryHistoryPane({
const dataKeys = metric === 'packets' ? ['packets_received', 'packets_sent'] : [metric];
const yDomain = useMemo<[number, number] | undefined>(() => {
if (metric !== 'battery_volts' || chartData.length === 0) return undefined;
const values = chartData.map((d) => d.battery_volts).filter((v) => v != null) as number[];
if (values.length === 0) return [3, 5];
const lo = Math.min(...values);
const hi = Math.max(...values);
return [Math.min(3, Math.floor(lo) - 1), Math.max(5, Math.ceil(hi) + 1)];
}, [metric, chartData]);
const handleToggle = async () => {
setToggling(true);
try {
@@ -208,6 +217,7 @@ export function TelemetryHistoryPane({
tickFormatter={formatTime}
/>
<YAxis
domain={yDomain}
tick={{ fontSize: 10, fill: 'hsl(var(--muted-foreground))' }}
tickLine={false}
axisLine={false}
@@ -1,4 +1,4 @@
import { useState, useEffect } from 'react';
import { useState, useEffect, useRef } from 'react';
import { Input } from '../ui/input';
import { Label } from '../ui/label';
import { Button } from '../ui/button';
@@ -7,7 +7,13 @@ import { toast } from '../ui/sonner';
import { api } from '../../api';
import { formatTime } from '../../utils/messageParser';
import { BulkDeleteContactsModal } from './BulkDeleteContactsModal';
import type { AppSettings, AppSettingsUpdate, Contact, HealthStatus } from '../../types';
import type {
AppSettings,
AppSettingsUpdate,
Contact,
HealthStatus,
TelemetryHistoryEntry,
} from '../../types';
export function SettingsDatabaseSection({
appSettings,
@@ -48,11 +54,35 @@ export function SettingsDatabaseSection({
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const [latestTelemetry, setLatestTelemetry] = useState<
Record<string, TelemetryHistoryEntry | null>
>({});
const telemetryFetchedRef = useRef(false);
useEffect(() => {
setAutoDecryptOnAdvert(appSettings.auto_decrypt_dm_on_advert);
setDiscoveryBlockedTypes(appSettings.discovery_blocked_types ?? []);
}, [appSettings]);
useEffect(() => {
if (trackedTelemetryRepeaters.length === 0 || telemetryFetchedRef.current) return;
telemetryFetchedRef.current = true;
let cancelled = false;
const fetches = trackedTelemetryRepeaters.map((key) =>
api.repeaterTelemetryHistory(key).then(
(history) => [key, history.length > 0 ? history[history.length - 1] : null] as const,
() => [key, null] as const
)
);
Promise.all(fetches).then((entries) => {
if (cancelled) return;
setLatestTelemetry(Object.fromEntries(entries));
});
return () => {
cancelled = true;
};
}, [trackedTelemetryRepeaters]);
const handleCleanup = async () => {
const days = parseInt(retentionDays, 10);
if (isNaN(days) || days < 1) {
@@ -242,28 +272,49 @@ export function SettingsDatabaseSection({
No repeaters are being tracked. Enable tracking from a repeater's dashboard.
</p>
) : (
<div className="space-y-1">
<div className="space-y-2">
{trackedTelemetryRepeaters.map((key) => {
const contact = contacts.find((c) => c.public_key === key);
const displayName = contact?.name ?? key.slice(0, 12);
const snap = latestTelemetry[key];
const d = snap?.data;
return (
<div key={key} className="flex items-center justify-between gap-2">
<div className="flex-1 min-w-0">
<span className="text-sm truncate block">{displayName}</span>
<span className="text-[0.625rem] text-muted-foreground font-mono">
{key.slice(0, 12)}
</span>
<div key={key} className="rounded-md border border-border px-3 py-2">
<div className="flex items-center justify-between gap-2">
<div className="flex-1 min-w-0">
<span className="text-sm truncate block">{displayName}</span>
<span className="text-[0.625rem] text-muted-foreground font-mono">
{key.slice(0, 12)}
</span>
</div>
{onToggleTrackedTelemetry && (
<Button
variant="ghost"
size="sm"
onClick={() => onToggleTrackedTelemetry(key)}
className="h-7 text-xs flex-shrink-0 text-destructive hover:text-destructive"
>
Remove
</Button>
)}
</div>
{onToggleTrackedTelemetry && (
<Button
variant="ghost"
size="sm"
onClick={() => onToggleTrackedTelemetry(key)}
className="h-7 text-xs flex-shrink-0 text-destructive hover:text-destructive"
>
Remove
</Button>
)}
{d ? (
<div className="mt-1.5 flex flex-wrap gap-x-3 gap-y-0.5 text-[0.625rem] text-muted-foreground">
<span>{d.battery_volts?.toFixed(2)}V</span>
<span>noise {d.noise_floor_dbm} dBm</span>
<span>
rx {d.packets_received != null ? d.packets_received.toLocaleString() : '?'}
</span>
<span>
tx {d.packets_sent != null ? d.packets_sent.toLocaleString() : '?'}
</span>
<span className="ml-auto">checked {formatTime(snap.timestamp)}</span>
</div>
) : snap === null ? (
<div className="mt-1 text-[0.625rem] text-muted-foreground italic">
No telemetry recorded yet
</div>
) : null}
</div>
);
})}
@@ -570,9 +570,9 @@ export function SettingsRadioSection({
onChange={(e) => setPathHashMode(e.target.value)}
className="w-full h-10 px-3 rounded-md border border-input bg-background text-sm ring-offset-background focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2"
>
<option value="0">1 byte (default)</option>
<option value="1">2 bytes</option>
<option value="2">3 bytes</option>
<option value="0">1 byte up to 63 hops (default)</option>
<option value="1">2 bytes up to 32 hops</option>
<option value="2">3 bytes up to 21 hops</option>
</select>
<div className="rounded-md border border-warning/50 bg-warning/10 p-3 text-xs text-warning">
<p className="font-semibold mb-1">Compatibility Warning</p>
+12 -42
View File
@@ -3,16 +3,11 @@ import { api } from '../api';
import { takePrefetchOrFetch } from '../prefetch';
import { toast } from '../components/ui/sonner';
import { initLastMessageTimes } from '../utils/conversationState';
import { isFavorite } from '../utils/favorites';
import type { AppSettings, AppSettingsUpdate, Favorite } from '../types';
import type { AppSettings, AppSettingsUpdate } from '../types';
export function useAppSettings() {
const [appSettings, setAppSettings] = useState<AppSettings | null>(null);
// Stable empty array prevents a new reference every render when there are none.
const emptyFavorites = useRef<Favorite[]>([]).current;
const favorites: Favorite[] = appSettings?.favorites ?? emptyFavorites;
// One-time migration guard
const hasMigratedRef = useRef(false);
@@ -85,32 +80,6 @@ export function useAppSettings() {
}
}, []);
const handleToggleFavorite = useCallback(async (type: 'channel' | 'contact', id: string) => {
setAppSettings((prev) => {
if (!prev) return prev;
const currentFavorites = prev.favorites ?? [];
const wasFavorited = isFavorite(currentFavorites, type, id);
const optimisticFavorites = wasFavorited
? currentFavorites.filter((f) => !(f.type === type && f.id === id))
: [...currentFavorites, { type, id }];
return { ...prev, favorites: optimisticFavorites };
});
try {
const updatedSettings = await api.toggleFavorite(type, id);
setAppSettings(updatedSettings);
} catch (err) {
console.error('Failed to toggle favorite:', err);
try {
const settings = await api.getSettings();
setAppSettings(settings);
} catch {
// If refetch also fails, leave optimistic state
}
toast.error('Failed to update favorite');
}
}, []);
const handleToggleTrackedTelemetry = useCallback(async (publicKey: string) => {
const key = publicKey.toLowerCase();
setAppSettings((prev) => {
@@ -151,7 +120,7 @@ export function useAppSettings() {
hasMigratedRef.current = true;
const FAVORITES_KEY = 'remoteterm-favorites';
let localFavorites: Favorite[] = [];
let localFavorites: Array<{ type: 'channel' | 'contact'; id: string }> = [];
try {
const stored = localStorage.getItem(FAVORITES_KEY);
if (stored) localFavorites = JSON.parse(stored);
@@ -161,25 +130,26 @@ export function useAppSettings() {
if (localFavorites.length === 0) return;
const migrate = async () => {
try {
for (const f of localFavorites) {
let migrated = 0;
for (const f of localFavorites) {
try {
await api.toggleFavorite(f.type, f.id);
migrated++;
} catch {
// Entity may have been deleted; skip and continue
}
localStorage.removeItem(FAVORITES_KEY);
await fetchAppSettings();
} catch (err) {
console.error('Failed to migrate legacy favorites:', err);
}
localStorage.removeItem(FAVORITES_KEY);
// Reload so contacts/channels pick up the new favorite flags
if (migrated > 0) window.location.reload();
};
migrate();
}, [appSettings, fetchAppSettings]);
}, [appSettings]);
return {
appSettings,
favorites,
fetchAppSettings,
handleSaveAppSettings,
handleToggleFavorite,
handleToggleBlockedKey,
handleToggleBlockedName,
handleToggleTrackedTelemetry,
+36 -20
View File
@@ -1,6 +1,6 @@
import { useEffect, useMemo, useRef } from 'react';
import type { Favorite } from '../types';
import type { Channel, Contact } from '../types';
import { getStateKey } from '../utils/conversationState';
const APP_TITLE = 'RemoteTerm for MeshCore';
@@ -25,12 +25,11 @@ function getUnreadDirectMessageCount(unreadCounts: Record<string, number>): numb
function getUnreadFavoriteChannelCount(
unreadCounts: Record<string, number>,
favorites: Favorite[]
channels: Channel[]
): number {
return favorites.reduce(
(sum, favorite) =>
sum +
(favorite.type === 'channel' ? unreadCounts[getStateKey('channel', favorite.id)] || 0 : 0),
return channels.reduce(
(sum, channel) =>
sum + (channel.favorite ? unreadCounts[getStateKey('channel', channel.key)] || 0 : 0),
0
);
}
@@ -41,19 +40,29 @@ export function getTotalUnreadCount(unreadCounts: Record<string, number>): numbe
export function getFavoriteUnreadCount(
unreadCounts: Record<string, number>,
favorites: Favorite[]
contacts: Contact[],
channels: Channel[]
): number {
return favorites.reduce((sum, favorite) => {
const stateKey = getStateKey(favorite.type, favorite.id);
return sum + (unreadCounts[stateKey] || 0);
}, 0);
let sum = 0;
for (const contact of contacts) {
if (contact.favorite) {
sum += unreadCounts[getStateKey('contact', contact.public_key)] || 0;
}
}
for (const channel of channels) {
if (channel.favorite) {
sum += unreadCounts[getStateKey('channel', channel.key)] || 0;
}
}
return sum;
}
export function getUnreadTitle(
unreadCounts: Record<string, number>,
favorites: Favorite[]
contacts: Contact[],
channels: Channel[]
): string {
const unreadCount = getFavoriteUnreadCount(unreadCounts, favorites);
const unreadCount = getFavoriteUnreadCount(unreadCounts, contacts, channels);
if (unreadCount <= 0) {
return APP_TITLE;
}
@@ -65,13 +74,13 @@ export function getUnreadTitle(
export function deriveFaviconBadgeState(
unreadCounts: Record<string, number>,
mentions: Record<string, boolean>,
favorites: Favorite[]
channels: Channel[]
): FaviconBadgeState {
if (Object.values(mentions).some(Boolean) || getUnreadDirectMessageCount(unreadCounts) > 0) {
return 'red';
}
if (getUnreadFavoriteChannelCount(unreadCounts, favorites) > 0) {
if (getUnreadFavoriteChannelCount(unreadCounts, channels) > 0) {
return 'green';
}
@@ -128,8 +137,15 @@ function applyFaviconHref(href: string): void {
upsertFaviconLinks('shortcut icon', href);
}
export function useUnreadTitle(unreadCounts: Record<string, number>, favorites: Favorite[]): void {
const title = useMemo(() => getUnreadTitle(unreadCounts, favorites), [favorites, unreadCounts]);
export function useUnreadTitle(
unreadCounts: Record<string, number>,
contacts: Contact[],
channels: Channel[]
): void {
const title = useMemo(
() => getUnreadTitle(unreadCounts, contacts, channels),
[contacts, channels, unreadCounts]
);
useEffect(() => {
document.title = title;
@@ -143,12 +159,12 @@ export function useUnreadTitle(unreadCounts: Record<string, number>, favorites:
export function useFaviconBadge(
unreadCounts: Record<string, number>,
mentions: Record<string, boolean>,
favorites: Favorite[]
channels: Channel[]
): void {
const objectUrlRef = useRef<string | null>(null);
const badgeState = useMemo(
() => deriveFaviconBadgeState(unreadCounts, mentions, favorites),
[favorites, mentions, unreadCounts]
() => deriveFaviconBadgeState(unreadCounts, mentions, channels),
[channels, mentions, unreadCounts]
);
useEffect(() => {
+5 -11
View File
@@ -187,7 +187,6 @@ const baseConfig = {
const baseSettings = {
max_radio_contacts: 200,
favorites: [] as Array<{ type: 'channel' | 'contact'; id: string }>,
auto_decrypt_dm_on_advert: false,
last_message_times: {},
@@ -204,6 +203,7 @@ const publicChannel = {
is_hashtag: false,
on_radio: false,
last_read_at: null,
favorite: false,
};
describe('App favorite toggle flow', () => {
@@ -216,8 +216,9 @@ describe('App favorite toggle flow', () => {
mocks.api.getChannels.mockResolvedValue([publicChannel]);
mocks.api.getContacts.mockResolvedValue([]);
mocks.api.toggleFavorite.mockResolvedValue({
...baseSettings,
favorites: [{ type: 'channel', id: publicChannel.key }],
type: 'channel',
id: publicChannel.key,
favorite: true,
});
});
@@ -239,11 +240,8 @@ describe('App favorite toggle flow', () => {
});
});
it('rolls back favorite state by refetching settings on toggle failure', async () => {
it('rolls back favorite state on toggle failure', async () => {
mocks.api.toggleFavorite.mockRejectedValue(new Error('toggle failed'));
mocks.api.getSettings
.mockResolvedValueOnce({ ...baseSettings }) // initial load
.mockResolvedValueOnce({ ...baseSettings }); // rollback refetch
render(<App />);
@@ -257,10 +255,6 @@ describe('App favorite toggle flow', () => {
expect(mocks.api.toggleFavorite).toHaveBeenCalledWith('channel', publicChannel.key);
});
await waitFor(() => {
expect(mocks.api.getSettings).toHaveBeenCalledTimes(2);
});
await waitFor(() => {
expect(mocks.toast.error).toHaveBeenCalledWith('Failed to update favorite');
});
+1 -1
View File
@@ -215,7 +215,6 @@ describe('App search jump target handling', () => {
});
mocks.api.getSettings.mockResolvedValue({
max_radio_contacts: 200,
favorites: [],
auto_decrypt_dm_on_advert: false,
last_message_times: {},
@@ -230,6 +229,7 @@ describe('App search jump target handling', () => {
is_hashtag: false,
on_radio: false,
last_read_at: null,
favorite: false,
},
]);
mocks.api.getContacts.mockResolvedValue([]);
+5 -1
View File
@@ -145,6 +145,7 @@ const publicChannel = {
is_hashtag: false,
on_radio: false,
last_read_at: null,
favorite: false,
};
describe('App startup hash resolution', () => {
@@ -166,7 +167,6 @@ describe('App startup hash resolution', () => {
});
mocks.api.getSettings.mockResolvedValue({
max_radio_contacts: 200,
favorites: [],
auto_decrypt_dm_on_advert: false,
last_message_times: {},
@@ -247,6 +247,7 @@ describe('App startup hash resolution', () => {
is_hashtag: false,
on_radio: false,
last_read_at: null,
favorite: false,
};
window.location.hash = '';
@@ -278,6 +279,7 @@ describe('App startup hash resolution', () => {
is_hashtag: false,
on_radio: false,
last_read_at: null,
favorite: false,
};
window.location.hash = '';
@@ -308,6 +310,7 @@ describe('App startup hash resolution', () => {
is_hashtag: false,
on_radio: false,
last_read_at: null,
favorite: false,
};
window.location.hash = '';
@@ -345,6 +348,7 @@ describe('App startup hash resolution', () => {
lon: null,
last_seen: null,
on_radio: false,
favorite: false,
last_contacted: null,
last_read_at: null,
first_seen: null,
@@ -17,6 +17,7 @@ describe('BulkAddChannelResultModal', () => {
is_hashtag: true,
on_radio: false,
last_read_at: null,
favorite: false,
},
{
key: 'BB'.repeat(16),
@@ -24,6 +25,7 @@ describe('BulkAddChannelResultModal', () => {
is_hashtag: true,
on_radio: false,
last_read_at: null,
favorite: false,
},
],
existing_count: 3,
@@ -2,7 +2,7 @@ import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
import { ChannelInfoPane } from '../components/ChannelInfoPane';
import type { Channel, ChannelDetail, Favorite } from '../types';
import type { Channel, ChannelDetail } from '../types';
// Mock the api module
vi.mock('../api', () => ({
@@ -15,7 +15,7 @@ import { api } from '../api';
const mockGetChannelDetail = vi.mocked(api.getChannelDetail);
function makeChannel(key: string, name: string, isHashtag: boolean): Channel {
return { key, name, is_hashtag: isHashtag, on_radio: false, last_read_at: null };
return { key, name, is_hashtag: isHashtag, on_radio: false, last_read_at: null, favorite: false };
}
function makeDetail(channel: Channel): ChannelDetail {
@@ -41,7 +41,6 @@ const noop = () => {};
const baseProps = {
onClose: noop,
favorites: [] as Favorite[],
onToggleFavorite: noop,
};
@@ -2,12 +2,12 @@ import { fireEvent, render, screen, waitFor, within } from '@testing-library/rea
import { describe, expect, it, vi } from 'vitest';
import { ChatHeader } from '../components/ChatHeader';
import type { Channel, Contact, Conversation, Favorite, PathDiscoveryResponse } from '../types';
import type { Channel, Contact, Conversation, PathDiscoveryResponse } from '../types';
import { CONTACT_TYPE_ROOM } from '../types';
import { PUBLIC_CHANNEL_KEY } from '../utils/publicChannel';
function makeChannel(key: string, name: string, isHashtag: boolean): Channel {
return { key, name, is_hashtag: isHashtag, on_radio: false, last_read_at: null };
return { key, name, is_hashtag: isHashtag, on_radio: false, last_read_at: null, favorite: false };
}
const noop = () => {};
@@ -15,7 +15,6 @@ const noop = () => {};
const baseProps = {
contacts: [],
config: null,
favorites: [] as Favorite[],
notificationsSupported: true,
notificationsEnabled: false,
notificationsPermission: 'granted' as const,
@@ -186,6 +185,7 @@ describe('ChatHeader key visibility', () => {
lon: null,
last_seen: null,
on_radio: false,
favorite: false,
last_contacted: null,
last_read_at: null,
first_seen: null,
@@ -237,6 +237,7 @@ describe('ChatHeader key visibility', () => {
lon: null,
last_seen: null,
on_radio: false,
favorite: false,
last_contacted: null,
last_read_at: null,
first_seen: null,
@@ -286,6 +287,7 @@ describe('ChatHeader key visibility', () => {
lon: null,
last_seen: null,
on_radio: false,
favorite: false,
last_contacted: null,
last_read_at: null,
first_seen: null,
+1 -1
View File
@@ -47,6 +47,7 @@ function createContact(overrides: Partial<Contact> = {}): Contact {
lon: null,
last_seen: 1700000000,
on_radio: false,
favorite: false,
last_contacted: null,
last_read_at: null,
first_seen: 1699990000,
@@ -90,7 +91,6 @@ const baseProps = {
onClose: () => {},
contacts: [] as Contact[],
config: null,
favorites: [],
onToggleFavorite: () => {},
onSearchMessagesByKey: vi.fn(),
onSearchMessagesByName: vi.fn(),
+6 -10
View File
@@ -3,15 +3,7 @@ import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { ConversationPane } from '../components/ConversationPane';
import type {
Channel,
Contact,
Conversation,
Favorite,
HealthStatus,
Message,
RadioConfig,
} from '../types';
import type { Channel, Contact, Conversation, HealthStatus, Message, RadioConfig } from '../types';
import type { RawPacketStatsSessionState } from '../utils/rawPacketStats';
const mocks = vi.hoisted(() => ({
@@ -97,6 +89,7 @@ const channel: Channel = {
is_hashtag: false,
on_radio: false,
last_read_at: null,
favorite: false,
};
const message: Message = {
@@ -134,7 +127,6 @@ function createProps(overrides: Partial<React.ComponentProps<typeof Conversation
notificationsSupported: true,
notificationsEnabled: false,
notificationsPermission: 'granted' as const,
favorites: [] as Favorite[],
messages: [message],
messagesLoading: false,
loadingOlder: false,
@@ -205,6 +197,7 @@ describe('ConversationPane', () => {
lon: null,
last_seen: null,
on_radio: false,
favorite: false,
last_contacted: null,
last_read_at: null,
first_seen: null,
@@ -278,6 +271,7 @@ describe('ConversationPane', () => {
lon: null,
last_seen: null,
on_radio: false,
favorite: false,
last_contacted: null,
last_read_at: null,
first_seen: null,
@@ -372,6 +366,7 @@ describe('ConversationPane', () => {
lon: null,
last_seen: 1700000000,
on_radio: false,
favorite: false,
last_contacted: 1700000000,
last_read_at: null,
first_seen: 1700000000,
@@ -408,6 +403,7 @@ describe('ConversationPane', () => {
lon: null,
last_seen: 1700000000,
on_radio: false,
favorite: false,
last_contacted: 1700000000,
last_read_at: null,
first_seen: 1700000000,
+1
View File
@@ -278,6 +278,7 @@ function makeContact(overrides: Partial<Contact> = {}): Contact {
lon: null,
last_seen: null,
on_radio: true,
favorite: false,
last_contacted: null,
last_read_at: null,
first_seen: null,
+2
View File
@@ -40,6 +40,7 @@ describe('MapView', () => {
lon: -74,
last_seen: null,
on_radio: false,
favorite: false,
last_contacted: null,
last_read_at: null,
first_seen: null,
@@ -74,6 +75,7 @@ describe('MapView', () => {
lon: -73,
last_seen: Math.floor(Date.now() / 1000) - 7 * 24 * 60 * 60 + 60,
on_radio: false,
favorite: false,
last_contacted: null,
last_read_at: null,
first_seen: null,
+1
View File
@@ -95,6 +95,7 @@ describe('MessageList channel sender rendering', () => {
lon: null,
last_seen: null,
on_radio: false,
favorite: false,
last_contacted: null,
last_read_at: null,
first_seen: null,
+8 -8
View File
@@ -9,7 +9,7 @@ import { describe, it, expect } from 'vitest';
import {
findLinkedChannelReferences,
formatTime,
isValidLinkedChannelName,
HASHTAG_CHANNEL_NAME_PATTERN,
parseSenderFromText,
} from '../utils/messageParser';
@@ -103,16 +103,16 @@ describe('formatTime', () => {
describe('linked channel references', () => {
it('accepts lowercase alphanumeric names with single dashes', () => {
expect(isValidLinkedChannelName('ops')).toBe(true);
expect(isValidLinkedChannelName('ops-1')).toBe(true);
expect(isValidLinkedChannelName('1-2-3')).toBe(true);
expect(HASHTAG_CHANNEL_NAME_PATTERN.test('ops')).toBe(true);
expect(HASHTAG_CHANNEL_NAME_PATTERN.test('ops-1')).toBe(true);
expect(HASHTAG_CHANNEL_NAME_PATTERN.test('1-2-3')).toBe(true);
});
it('rejects uppercase, leading or trailing dashes, and repeated dashes', () => {
expect(isValidLinkedChannelName('Ops')).toBe(false);
expect(isValidLinkedChannelName('-ops')).toBe(false);
expect(isValidLinkedChannelName('ops-')).toBe(false);
expect(isValidLinkedChannelName('ops--room')).toBe(false);
expect(HASHTAG_CHANNEL_NAME_PATTERN.test('Ops')).toBe(false);
expect(HASHTAG_CHANNEL_NAME_PATTERN.test('-ops')).toBe(false);
expect(HASHTAG_CHANNEL_NAME_PATTERN.test('ops-')).toBe(false);
expect(HASHTAG_CHANNEL_NAME_PATTERN.test('ops--room')).toBe(false);
});
it('finds standalone linked channel references in message text', () => {
@@ -62,6 +62,7 @@ function createContact(publicKey: string, name: string, type = 1): Contact {
lon: null,
last_seen: null,
on_radio: false,
favorite: false,
last_contacted: null,
last_read_at: null,
first_seen: null,
+1
View File
@@ -30,6 +30,7 @@ function createContact(overrides: Partial<Contact> = {}): Contact {
lon: null,
last_seen: null,
on_radio: false,
favorite: false,
last_contacted: null,
last_read_at: null,
first_seen: null,
@@ -23,6 +23,7 @@ const BOT_CHANNEL: Channel = {
is_hashtag: true,
on_radio: false,
last_read_at: null,
favorite: false,
};
const BOT_PACKET: RawPacket = {
@@ -14,6 +14,7 @@ const TEST_CHANNEL: Channel = {
is_hashtag: true,
on_radio: false,
last_read_at: null,
favorite: false,
};
const COLLIDING_TEST_CHANNEL: Channel = {
@@ -87,6 +88,7 @@ function createContact(overrides: Partial<Contact> = {}): Contact {
lon: null,
last_seen: null,
on_radio: false,
favorite: false,
last_contacted: null,
last_read_at: null,
first_seen: null,
+4 -4
View File
@@ -2,7 +2,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { RepeaterDashboard } from '../components/RepeaterDashboard';
import type { UseRepeaterDashboardResult } from '../hooks/useRepeaterDashboard';
import type { Contact, Conversation, Favorite } from '../types';
import type { Contact, Conversation } from '../types';
// Mock the hook — typed as mutable version of the return type
const mockHook: {
@@ -99,18 +99,16 @@ const contacts: Contact[] = [
lon: null,
last_seen: null,
on_radio: false,
favorite: false,
last_contacted: null,
last_read_at: null,
first_seen: null,
},
];
const favorites: Favorite[] = [];
const defaultProps = {
conversation,
contacts,
favorites,
notificationsSupported: true,
notificationsEnabled: false,
notificationsPermission: 'granted' as const,
@@ -337,6 +335,7 @@ describe('RepeaterDashboard', () => {
lon: 115.87,
last_seen: null,
on_radio: false,
favorite: false,
last_contacted: null,
last_read_at: null,
first_seen: null,
@@ -397,6 +396,7 @@ describe('RepeaterDashboard', () => {
lon: 115.87,
last_seen: null,
on_radio: false,
favorite: false,
last_contacted: null,
last_read_at: null,
first_seen: null,
@@ -40,6 +40,7 @@ const roomContact: Contact = {
lon: null,
last_seen: null,
on_radio: false,
favorite: false,
last_contacted: null,
last_read_at: null,
first_seen: null,
+9 -1
View File
@@ -35,7 +35,14 @@ function createSearchResult(overrides: Partial<Message> = {}): Message {
const defaultProps = {
contacts: [],
channels: [
{ key: 'ABC123', name: 'Public', is_hashtag: true, on_radio: false, last_read_at: null },
{
key: 'ABC123',
name: 'Public',
is_hashtag: true,
on_radio: false,
last_read_at: null,
favorite: false,
},
],
onNavigateToMessage: vi.fn(),
};
@@ -239,6 +246,7 @@ describe('SearchView', () => {
lon: null,
last_seen: null,
on_radio: false,
favorite: false,
last_contacted: null,
first_seen: null,
last_read_at: null,
-1
View File
@@ -59,7 +59,6 @@ const baseHealth: HealthStatus = {
const baseSettings: AppSettings = {
max_radio_contacts: 200,
favorites: [],
auto_decrypt_dm_on_advert: false,
last_message_times: {},
+27 -34
View File
@@ -2,13 +2,7 @@ import { fireEvent, render, screen, waitFor, within } from '@testing-library/rea
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { Sidebar } from '../components/Sidebar';
import {
CONTACT_TYPE_REPEATER,
CONTACT_TYPE_ROOM,
type Channel,
type Contact,
type Favorite,
} from '../types';
import { CONTACT_TYPE_REPEATER, CONTACT_TYPE_ROOM, type Channel, type Contact } from '../types';
import { getStateKey, type ConversationTimes } from '../utils/conversationState';
import { PUBLIC_CHANNEL_KEY } from '../utils/publicChannel';
@@ -19,6 +13,7 @@ function makeChannel(key: string, name: string): Channel {
is_hashtag: false,
on_radio: false,
last_read_at: null,
favorite: false,
};
}
@@ -41,6 +36,7 @@ function makeContact(
lon: null,
last_seen: null,
on_radio: false,
favorite: false,
last_contacted: null,
last_read_at: null,
first_seen: null,
@@ -51,7 +47,6 @@ function makeContact(
function renderSidebar(overrides?: {
unreadCounts?: Record<string, number>;
mentions?: Record<string, boolean>;
favorites?: Favorite[];
lastMessageTimes?: ConversationTimes;
channels?: Channel[];
isConversationNotificationsEnabled?: (type: 'channel' | 'contact', id: string) => boolean;
@@ -59,7 +54,7 @@ function renderSidebar(overrides?: {
const aliceName = 'Alice';
const roomName = 'Ops Board';
const publicChannel = makeChannel('AA'.repeat(16), 'Public');
const flightChannel = makeChannel('BB'.repeat(16), '#flight');
const flightChannel = { ...makeChannel('BB'.repeat(16), '#flight'), favorite: true };
const opsChannel = makeChannel('CC'.repeat(16), '#ops');
const alice = makeContact('11'.repeat(32), aliceName);
const board = makeContact('33'.repeat(32), roomName, CONTACT_TYPE_ROOM);
@@ -73,7 +68,6 @@ function renderSidebar(overrides?: {
[getStateKey('contact', relay.public_key)]: 4,
};
const favorites = overrides?.favorites ?? [{ type: 'channel', id: flightChannel.key }];
const channels = overrides?.channels ?? [publicChannel, flightChannel, opsChannel];
const onSelectConversation = vi.fn();
@@ -91,7 +85,6 @@ function renderSidebar(overrides?: {
crackerRunning={false}
onToggleCracker={vi.fn()}
onMarkAllRead={vi.fn()}
favorites={favorites}
isConversationNotificationsEnabled={overrides?.isConversationNotificationsEnabled}
/>
);
@@ -138,7 +131,6 @@ describe('Sidebar section summaries', () => {
crackerRunning={false}
onToggleCracker={vi.fn()}
onMarkAllRead={vi.fn()}
favorites={[]}
/>
);
@@ -195,11 +187,26 @@ describe('Sidebar section summaries', () => {
});
it('turns favorite contact row badges red', () => {
const { aliceName } = renderSidebar({
favorites: [{ type: 'contact', id: '11'.repeat(32) }],
});
const alice = makeContact('11'.repeat(32), 'Alice', 1, { favorite: true });
const aliceRow = screen.getByText(aliceName).closest('div');
render(
<Sidebar
contacts={[alice]}
channels={[makeChannel(PUBLIC_CHANNEL_KEY, 'Public')]}
activeConversation={null}
onSelectConversation={vi.fn()}
onNewMessage={vi.fn()}
lastMessageTimes={{}}
unreadCounts={{ [getStateKey('contact', alice.public_key)]: 3 }}
mentions={{}}
showCracker={false}
crackerRunning={false}
onToggleCracker={vi.fn()}
onMarkAllRead={vi.fn()}
/>
);
const aliceRow = screen.getByText('Alice').closest('div');
if (!aliceRow) throw new Error('Missing Alice row');
expect(within(aliceRow).getByText('3')).toHaveClass(
'bg-badge-mention',
@@ -297,7 +304,6 @@ describe('Sidebar section summaries', () => {
crackerRunning={false}
onToggleCracker={vi.fn()}
onMarkAllRead={vi.fn()}
favorites={[]}
/>
);
@@ -393,7 +399,6 @@ describe('Sidebar section summaries', () => {
crackerRunning: false,
onToggleCracker: vi.fn(),
onMarkAllRead: vi.fn(),
favorites: [],
};
const getChannelsOrder = () => screen.getAllByText(/^#/).map((node) => node.textContent);
@@ -464,7 +469,6 @@ describe('Sidebar section summaries', () => {
crackerRunning={false}
onToggleCracker={vi.fn()}
onMarkAllRead={vi.fn()}
favorites={[]}
/>
);
@@ -498,7 +502,6 @@ describe('Sidebar section summaries', () => {
crackerRunning={false}
onToggleCracker={vi.fn()}
onMarkAllRead={vi.fn()}
favorites={[]}
/>
);
@@ -546,7 +549,6 @@ describe('Sidebar section summaries', () => {
crackerRunning={false}
onToggleCracker={vi.fn()}
onMarkAllRead={vi.fn()}
favorites={[]}
/>
);
@@ -578,7 +580,6 @@ describe('Sidebar section summaries', () => {
crackerRunning={false}
onToggleCracker={vi.fn()}
onMarkAllRead={vi.fn()}
favorites={[]}
/>
);
@@ -593,8 +594,8 @@ describe('Sidebar section summaries', () => {
it('sorts favorites independently and persists the favorites sort preference', () => {
const publicChannel = makeChannel(PUBLIC_CHANNEL_KEY, 'Public');
const zed = makeContact('11'.repeat(32), 'Zed', 1, { last_advert: 150 });
const amy = makeContact('22'.repeat(32), 'Amy');
const zed = makeContact('11'.repeat(32), 'Zed', 1, { last_advert: 150, favorite: true });
const amy = makeContact('22'.repeat(32), 'Amy', 1, { favorite: true });
const props = {
contacts: [zed, amy],
@@ -611,10 +612,6 @@ describe('Sidebar section summaries', () => {
crackerRunning: false,
onToggleCracker: vi.fn(),
onMarkAllRead: vi.fn(),
favorites: [
{ type: 'contact', id: zed.public_key },
{ type: 'contact', id: amy.public_key },
] satisfies Favorite[],
};
const getFavoritesOrder = () =>
@@ -641,8 +638,8 @@ describe('Sidebar section summaries', () => {
localStorage.setItem('remoteterm-sortOrder', 'alpha');
const publicChannel = makeChannel(PUBLIC_CHANNEL_KEY, 'Public');
const zed = makeContact('11'.repeat(32), 'Zed', 1, { last_advert: 150 });
const amy = makeContact('22'.repeat(32), 'Amy');
const zed = makeContact('11'.repeat(32), 'Zed', 1, { last_advert: 150, favorite: true });
const amy = makeContact('22'.repeat(32), 'Amy', 1, { favorite: true });
render(
<Sidebar
@@ -660,10 +657,6 @@ describe('Sidebar section summaries', () => {
crackerRunning={false}
onToggleCracker={vi.fn()}
onMarkAllRead={vi.fn()}
favorites={[
{ type: 'contact', id: zed.public_key },
{ type: 'contact', id: amy.public_key },
]}
/>
);
+1
View File
@@ -24,6 +24,7 @@ function makeContact(
lon: null,
last_seen: null,
on_radio: false,
favorite: false,
last_contacted: null,
last_read_at: null,
first_seen: null,
+6
View File
@@ -193,6 +193,7 @@ describe('resolveChannelFromHashToken', () => {
is_hashtag: false,
on_radio: true,
last_read_at: null,
favorite: false,
},
{
key: '11111111111111111111111111111111',
@@ -200,6 +201,7 @@ describe('resolveChannelFromHashToken', () => {
is_hashtag: true,
on_radio: false,
last_read_at: null,
favorite: false,
},
{
key: '22222222222222222222222222222222',
@@ -207,6 +209,7 @@ describe('resolveChannelFromHashToken', () => {
is_hashtag: false,
on_radio: false,
last_read_at: null,
favorite: false,
},
];
@@ -241,6 +244,7 @@ describe('resolveContactFromHashToken', () => {
lon: null,
last_seen: null,
on_radio: false,
favorite: false,
last_contacted: null,
last_read_at: null,
first_seen: null,
@@ -258,6 +262,7 @@ describe('resolveContactFromHashToken', () => {
lon: null,
last_seen: null,
on_radio: false,
favorite: false,
last_contacted: null,
last_read_at: null,
first_seen: null,
@@ -275,6 +280,7 @@ describe('resolveContactFromHashToken', () => {
lon: null,
last_seen: null,
on_radio: false,
favorite: false,
last_contacted: null,
last_read_at: null,
first_seen: null,
@@ -51,6 +51,7 @@ function makeContact(suffix: string): Contact {
lon: null,
last_seen: null,
on_radio: false,
favorite: false,
last_contacted: null,
last_read_at: null,
first_seen: null,
@@ -184,6 +185,7 @@ describe('useContactsAndChannels', () => {
is_hashtag: true,
on_radio: false,
last_read_at: null,
favorite: false,
},
],
existing_count: 1,
@@ -33,6 +33,7 @@ const publicChannel: Channel = {
is_hashtag: false,
on_radio: false,
last_read_at: null,
favorite: false,
};
const sentMessage: Message = {
@@ -208,6 +209,7 @@ describe('useConversationActions', () => {
lon: null,
last_seen: null,
on_radio: false,
favorite: false,
last_contacted: null,
last_read_at: null,
first_seen: null,
@@ -10,6 +10,7 @@ const publicChannel: Channel = {
is_hashtag: false,
on_radio: false,
last_read_at: null,
favorite: false,
};
function createArgs(overrides: Partial<Parameters<typeof useConversationNavigation>[0]> = {}) {
+57 -27
View File
@@ -10,9 +10,34 @@ import {
useFaviconBadge,
useUnreadTitle,
} from '../hooks/useFaviconBadge';
import type { Favorite } from '../types';
import type { Channel, Contact } from '../types';
import { getStateKey } from '../utils/conversationState';
function makeChannel(key: string, favorite = false): Channel {
return { key, name: key, is_hashtag: false, on_radio: false, last_read_at: null, favorite };
}
function makeContact(publicKey: string, favorite = false): Contact {
return {
public_key: publicKey,
name: publicKey,
type: 1,
flags: 0,
direct_path: null,
direct_path_len: -1,
direct_path_hash_mode: -1,
last_advert: null,
lat: null,
lon: null,
last_seen: null,
on_radio: false,
favorite,
last_contacted: null,
last_read_at: null,
first_seen: null,
};
}
function getIconHref(rel: 'icon' | 'shortcut icon'): string | null {
return (
document.head.querySelector<HTMLLinkElement>(`link[rel="${rel}"]`)?.getAttribute('href') ?? null
@@ -71,16 +96,16 @@ describe('useFaviconBadge', () => {
});
it('derives badge priority from unread counts, mentions, and favorites', () => {
const favorites: Favorite[] = [{ type: 'channel', id: 'fav-chan' }];
const channels = [makeChannel('fav-chan', true)];
expect(deriveFaviconBadgeState({}, {}, favorites)).toBe('none');
expect(deriveFaviconBadgeState({}, {}, channels)).toBe('none');
expect(
deriveFaviconBadgeState(
{
[getStateKey('channel', 'fav-chan')]: 3,
},
{},
favorites
channels
)
).toBe('green');
expect(
@@ -89,7 +114,7 @@ describe('useFaviconBadge', () => {
[getStateKey('contact', 'abc')]: 12,
},
{},
favorites
channels
)
).toBe('red');
expect(
@@ -100,7 +125,7 @@ describe('useFaviconBadge', () => {
{
[getStateKey('channel', 'fav-chan')]: true,
},
favorites
channels
)
).toBe('red');
});
@@ -116,7 +141,7 @@ describe('useFaviconBadge', () => {
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({}, [], [])).toBe(0);
expect(
getFavoriteUnreadCount(
{
@@ -124,20 +149,19 @@ describe('useFaviconBadge', () => {
[getStateKey('contact', 'fav-contact')]: 3,
[getStateKey('channel', 'other-chan')]: 9,
},
[
{ type: 'channel', id: 'fav-chan' },
{ type: 'contact', id: 'fav-contact' },
]
[makeContact('fav-contact', true)],
[makeChannel('fav-chan', true)]
)
).toBe(10);
expect(getUnreadTitle({}, [])).toBe('RemoteTerm for MeshCore');
expect(getUnreadTitle({}, [], [])).toBe('RemoteTerm for MeshCore');
expect(
getUnreadTitle(
{
[getStateKey('channel', 'fav-chan')]: 7,
[getStateKey('channel', 'other-chan')]: 9,
},
[{ type: 'channel', id: 'fav-chan' }]
[],
[makeChannel('fav-chan', true)]
)
).toBe('(7) RemoteTerm');
expect(
@@ -145,28 +169,29 @@ describe('useFaviconBadge', () => {
{
[getStateKey('channel', 'fav-chan')]: 120,
},
[{ type: 'channel', id: 'fav-chan' }]
[],
[makeChannel('fav-chan', true)]
)
).toBe('(99+) RemoteTerm');
});
it('switches between the base favicon and generated blob badges', async () => {
const favorites: Favorite[] = [{ type: 'channel', id: 'fav-chan' }];
const channels = [makeChannel('fav-chan', true)];
const { rerender } = renderHook(
({
unreadCounts,
mentions,
currentFavorites,
currentChannels,
}: {
unreadCounts: Record<string, number>;
mentions: Record<string, boolean>;
currentFavorites: Favorite[];
}) => useFaviconBadge(unreadCounts, mentions, currentFavorites),
currentChannels: Channel[];
}) => useFaviconBadge(unreadCounts, mentions, currentChannels),
{
initialProps: {
unreadCounts: {},
mentions: {},
currentFavorites: favorites,
currentChannels: channels,
},
}
);
@@ -181,7 +206,7 @@ describe('useFaviconBadge', () => {
[getStateKey('channel', 'fav-chan')]: 1,
},
mentions: {},
currentFavorites: favorites,
currentChannels: channels,
});
await waitFor(() => {
@@ -194,7 +219,7 @@ describe('useFaviconBadge', () => {
[getStateKey('contact', 'dm-key')]: 12,
},
mentions: {},
currentFavorites: favorites,
currentChannels: channels,
});
await waitFor(() => {
@@ -205,7 +230,7 @@ describe('useFaviconBadge', () => {
rerender({
unreadCounts: {},
mentions: {},
currentFavorites: favorites,
currentChannels: channels,
});
await waitFor(() => {
@@ -220,18 +245,22 @@ describe('useFaviconBadge', () => {
});
it('writes unread counts into the page title', () => {
const channels = [makeChannel('fav-chan', true)];
const { rerender, unmount } = renderHook(
({
unreadCounts,
favorites,
contacts,
currentChannels,
}: {
unreadCounts: Record<string, number>;
favorites: Favorite[];
}) => useUnreadTitle(unreadCounts, favorites),
contacts: Contact[];
currentChannels: Channel[];
}) => useUnreadTitle(unreadCounts, contacts, currentChannels),
{
initialProps: {
unreadCounts: {},
favorites: [{ type: 'channel', id: 'fav-chan' }],
contacts: [],
currentChannels: channels,
},
}
);
@@ -243,7 +272,8 @@ describe('useFaviconBadge', () => {
[getStateKey('channel', 'fav-chan')]: 4,
[getStateKey('contact', 'dm-key')]: 2,
},
favorites: [{ type: 'channel', id: 'fav-chan' }],
contacts: [],
currentChannels: channels,
});
expect(document.title).toBe('(4) RemoteTerm');
@@ -28,6 +28,7 @@ const publicChannel: Channel = {
is_hashtag: false,
on_radio: false,
last_read_at: null,
favorite: false,
};
const incomingDm: Message = {
@@ -109,6 +110,7 @@ describe('useRealtimeAppState', () => {
lon: null,
last_seen: null,
on_radio: false,
favorite: false,
last_contacted: null,
last_read_at: null,
first_seen: null,
@@ -151,6 +153,7 @@ describe('useRealtimeAppState', () => {
lon: null,
last_seen: null,
on_radio: false,
favorite: false,
last_contacted: null,
last_read_at: null,
first_seen: null,
@@ -241,6 +244,7 @@ describe('useRealtimeAppState', () => {
lon: null,
last_seen: null,
on_radio: false,
favorite: false,
last_contacted: 1700000000,
last_read_at: null,
first_seen: 1700000000,
@@ -35,6 +35,7 @@ function makeChannel(key: string, name: string): Channel {
is_hashtag: false,
on_radio: false,
last_read_at: null,
favorite: false,
};
}
@@ -52,6 +53,7 @@ function makeContact(pubkey: string): Contact {
lon: null,
last_seen: null,
on_radio: false,
favorite: false,
last_contacted: null,
last_read_at: null,
first_seen: null,
@@ -55,6 +55,7 @@ function createContact(publicKey: string, name: string, type = 1): Contact {
lon: null,
last_seen: null,
on_radio: false,
favorite: false,
last_contacted: null,
last_read_at: null,
first_seen: null,
+2 -6
View File
@@ -121,6 +121,7 @@ export interface Contact {
lon: number | null;
last_seen: number | null;
on_radio: boolean;
favorite: boolean;
last_contacted: number | null;
last_read_at: number | null;
first_seen: number | null;
@@ -203,6 +204,7 @@ export interface Channel {
flood_scope_override?: string | null;
path_hash_mode_override?: number | null;
last_read_at: number | null;
favorite: boolean;
}
export interface ChannelMessageCounts {
@@ -323,14 +325,8 @@ export interface RawPacket {
} | null;
}
export interface Favorite {
type: 'channel' | 'contact';
id: string; // channel key or contact public key
}
export interface AppSettings {
max_radio_contacts: number;
favorites: Favorite[];
auto_decrypt_dm_on_advert: boolean;
last_message_times: Record<string, number>;
advert_interval: number;
-18
View File
@@ -1,18 +0,0 @@
/**
* Favorites utilities.
*
* Favorites are stored server-side in the database.
*/
import type { Favorite } from '../types';
/**
* Check if a conversation is favorited (from provided favorites array)
*/
export function isFavorite(
favorites: Favorite[],
type: 'channel' | 'contact',
id: string
): boolean {
return favorites.some((f) => f.type === type && f.id === id);
}
+1 -5
View File
@@ -2,7 +2,7 @@
* Parse sender from channel message text.
* Channel messages have format "sender: message".
*/
const HASHTAG_CHANNEL_NAME_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
export const HASHTAG_CHANNEL_NAME_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
const HASHTAG_CHANNEL_REFERENCE_PATTERN = /(^|\s)(#[a-z0-9]+(?:-[a-z0-9]+)*)(?=$|[\s.,;:])/g;
export function parseSenderFromText(text: string): { sender: string | null; content: string } {
@@ -26,10 +26,6 @@ export interface HashtagChannelReference {
end: number;
}
export function isValidLinkedChannelName(name: string): boolean {
return HASHTAG_CHANNEL_NAME_PATTERN.test(name);
}
export function findLinkedChannelReferences(text: string): HashtagChannelReference[] {
const references: HashtagChannelReference[] = [];
let match: RegExpExecArray | null;
+1 -1
View File
@@ -27,7 +27,7 @@ const VISUALIZER_DEFAULTS: VisualizerSettings = {
observationWindowSec: 15,
letEmDrift: true,
particleSpeedMultiplier: 2,
pruneStaleNodes: false,
pruneStaleNodes: true,
pruneStaleMinutes: 5,
autoOrbit: false,
showControls: true,
+35 -34
View File
@@ -211,6 +211,12 @@ EOF
chmod 644 "$SNAKEOIL_CERT_HOST_PATH"
}
if ! command -v docker &>/dev/null; then
echo -e "${RED}Warning: docker was not found in PATH. A compose file will still be generated, but you will need Docker installed to run it.${NC}"
elif ! docker compose version &>/dev/null; then
echo -e "${RED}Warning: docker compose is not available. A compose file will still be generated, but you will need the Docker Compose plugin to run it.${NC}"
fi
echo -e "${BOLD}=== RemoteTerm for MeshCore — Docker Setup ===${NC}"
echo
echo -e " Repo directory : ${CYAN}${REPO_DIR}${NC}"
@@ -218,16 +224,6 @@ echo -e " Example compose : ${CYAN}${EXAMPLE_FILE}${NC}"
echo -e " Output compose : ${CYAN}${COMPOSE_FILE}${NC}"
echo
if ! command -v docker &>/dev/null; then
echo -e "${RED}Error: docker was not found in PATH.${NC}"
exit 1
fi
if ! docker compose version &>/dev/null; then
echo -e "${RED}Error: docker compose is required but was not available.${NC}"
exit 1
fi
if [ -f "$COMPOSE_FILE" ]; then
echo -e "${YELLOW}A local docker-compose.yml already exists.${NC}"
read -r -p "Overwrite it? [y/N]: " OVERWRITE
@@ -360,35 +356,40 @@ echo
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 reachable beyond your local machine."
echo
read -r -p "Set up HTTP Basic Auth? [Y/n]: " ENABLE_AUTH
ENABLE_AUTH="${ENABLE_AUTH:-Y}"
echo
if [[ "$ENABLE_AUTH" =~ ^[Yy]$ ]]; then
read -r -p "Username: " AUTH_USERNAME
while [ -z "$AUTH_USERNAME" ]; do
echo -e "${RED}Username cannot be empty.${NC}"
read -r -p "Username: " AUTH_USERNAME
done
read -r -s -p "Password: " AUTH_PASSWORD
echo
while [ -z "$AUTH_PASSWORD" ]; do
echo -e "${RED}Password cannot be empty.${NC}"
read -r -s -p "Password: " AUTH_PASSWORD
echo
done
echo -e "${GREEN}Basic Auth configured for user '${AUTH_USERNAME}'.${NC}"
fi
else
echo -e "${GREEN}Bots disabled.${NC}"
fi
echo
echo -e "${BOLD}─── HTTP Basic Auth ─────────────────────────────────────────────────${NC}"
if [[ "$ENABLE_BOTS" =~ ^[Yy]$ ]]; then
echo "With bots enabled, HTTP Basic Auth is strongly recommended if this"
echo "service will be reachable beyond your local machine."
else
echo "HTTP Basic Auth adds a coarse access gate to the service."
fi
echo
read -r -p "Set up HTTP Basic Auth? [y/N]: " ENABLE_AUTH
ENABLE_AUTH="${ENABLE_AUTH:-N}"
echo
if [[ "$ENABLE_AUTH" =~ ^[Yy]$ ]]; then
read -r -p "Username: " AUTH_USERNAME
while [ -z "$AUTH_USERNAME" ]; do
echo -e "${RED}Username cannot be empty.${NC}"
read -r -p "Username: " AUTH_USERNAME
done
read -r -s -p "Password: " AUTH_PASSWORD
echo
while [ -z "$AUTH_PASSWORD" ]; do
echo -e "${RED}Password cannot be empty.${NC}"
read -r -s -p "Password: " AUTH_PASSWORD
echo
done
echo -e "${GREEN}Basic Auth configured for user '${AUTH_USERNAME}'.${NC}"
fi
echo
echo -e "${BOLD}─── HTTPS / Snakeoil TLS ────────────────────────────────────────────${NC}"
echo "Generating a local self-signed certificate enables HTTPS-only browser features"
echo "such as the channel key finder and, in some browsers, notifications."
+19 -7
View File
@@ -285,6 +285,18 @@ fi
echo -e "${YELLOW}Writing systemd service file to ${SERVICE_FILE}...${NC}"
# Escape a value for use in a systemd Environment= directive.
# Must handle: % (specifier expansion), " and \ (systemd.syntax unquoting),
# and trailing backslash (line continuation). Wraps in double quotes so
# spaces are preserved.
systemd_escape_env_value() {
local v="$1"
v="${v//\\/\\\\}" # \ → \\ (must be first)
v="${v//\"/\\\"}" # " → \"
v="${v//%/%%}" # % → %%
printf '"%s"' "$v"
}
generate_service_file() {
echo "[Unit]"
echo "Description=RemoteTerm for MeshCore"
@@ -301,14 +313,14 @@ generate_service_file() {
# Transport
case "$TRANSPORT_CHOICE" in
2) echo "Environment=MESHCORE_SERIAL_PORT=${SERIAL_PORT}" ;;
2) echo "Environment=MESHCORE_SERIAL_PORT=$(systemd_escape_env_value "$SERIAL_PORT")" ;;
3)
echo "Environment=MESHCORE_TCP_HOST=${TCP_HOST}"
echo "Environment=MESHCORE_TCP_PORT=${TCP_PORT}"
echo "Environment=MESHCORE_TCP_HOST=$(systemd_escape_env_value "$TCP_HOST")"
echo "Environment=MESHCORE_TCP_PORT=$(systemd_escape_env_value "$TCP_PORT")"
;;
4)
echo "Environment=MESHCORE_BLE_ADDRESS=${BLE_ADDRESS}"
echo "Environment=MESHCORE_BLE_PIN=${BLE_PIN}"
echo "Environment=MESHCORE_BLE_ADDRESS=$(systemd_escape_env_value "$BLE_ADDRESS")"
echo "Environment=MESHCORE_BLE_PIN=$(systemd_escape_env_value "$BLE_PIN")"
;;
esac
@@ -319,8 +331,8 @@ generate_service_file() {
# 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}"
echo "Environment=MESHCORE_BASIC_AUTH_USERNAME=$(systemd_escape_env_value "$AUTH_USERNAME")"
echo "Environment=MESHCORE_BASIC_AUTH_PASSWORD=$(systemd_escape_env_value "$AUTH_PASSWORD")"
fi
# Serial group access
+1 -3
View File
@@ -77,6 +77,7 @@ export interface Channel {
name: string;
is_hashtag: boolean;
on_radio: boolean;
favorite: boolean;
flood_scope_override?: string | null;
}
@@ -216,11 +217,8 @@ export function markAllRead(): Promise<{ status: string; timestamp: number }> {
// --- Settings ---
export type Favorite = { type: string; id: string };
export interface AppSettings {
max_radio_contacts: number;
favorites: Favorite[];
auto_decrypt_dm_on_advert: boolean;
last_message_times: Record<string, number>;
advert_interval: number;
+5 -23
View File
@@ -1,24 +1,11 @@
import { test, expect } from '@playwright/test';
import {
createChannel,
deleteChannel,
getSettings,
updateSettings,
type Favorite,
} from '../helpers/api';
import { createChannel, deleteChannel, getChannels } from '../helpers/api';
test.describe('Favorites persistence', () => {
let originalFavorites: Favorite[] = [];
let channelName = '';
let channelKey = '';
test.beforeAll(async () => {
const settings = await getSettings();
originalFavorites = settings.favorites ?? [];
// Start deterministic: no favorites
await updateSettings({ favorites: [] });
channelName = `#e2efav${Date.now().toString().slice(-6)}`;
const channel = await createChannel(channelName);
channelKey = channel.key;
@@ -30,11 +17,6 @@ test.describe('Favorites persistence', () => {
} catch {
// Best-effort cleanup
}
try {
await updateSettings({ favorites: originalFavorites });
} catch {
// Best-effort cleanup
}
});
test('add and remove favorite channel with persistence across reload', async ({ page }) => {
@@ -51,8 +33,8 @@ test.describe('Favorites persistence', () => {
await expect(page.getByText('Favorites')).toBeVisible();
await expect
.poll(async () => {
const settings = await getSettings();
return settings.favorites.some((f) => f.type === 'channel' && f.id === channelKey);
const channels = await getChannels();
return channels.some((c) => c.key === channelKey && c.favorite);
})
.toBe(true);
@@ -66,8 +48,8 @@ test.describe('Favorites persistence', () => {
await expect(page.getByTitle('Add to favorites')).toBeVisible();
await expect
.poll(async () => {
const settings = await getSettings();
return settings.favorites.some((f) => f.type === 'channel' && f.id === channelKey);
const channels = await getChannels();
return channels.some((c) => c.key === channelKey && c.favorite);
})
.toBe(false);
await expect(page.getByText('Favorites')).not.toBeVisible();
+16 -16
View File
@@ -1224,8 +1224,8 @@ class TestMigration039:
applied = await run_migrations(conn)
assert applied == 16
assert await get_version(conn) == 54
assert applied == 17
assert await get_version(conn) == 55
cursor = await conn.execute(
"""
@@ -1296,8 +1296,8 @@ class TestMigration039:
applied = await run_migrations(conn)
assert applied == 16
assert await get_version(conn) == 54
assert applied == 17
assert await get_version(conn) == 55
cursor = await conn.execute(
"""
@@ -1363,8 +1363,8 @@ class TestMigration039:
applied = await run_migrations(conn)
assert applied == 10
assert await get_version(conn) == 54
assert applied == 11
assert await get_version(conn) == 55
cursor = await conn.execute(
"""
@@ -1416,8 +1416,8 @@ class TestMigration040:
applied = await run_migrations(conn)
assert applied == 15
assert await get_version(conn) == 54
assert applied == 16
assert await get_version(conn) == 55
await conn.execute(
"""
@@ -1478,8 +1478,8 @@ class TestMigration041:
applied = await run_migrations(conn)
assert applied == 14
assert await get_version(conn) == 54
assert applied == 15
assert await get_version(conn) == 55
await conn.execute(
"""
@@ -1531,8 +1531,8 @@ class TestMigration042:
applied = await run_migrations(conn)
assert applied == 13
assert await get_version(conn) == 54
assert applied == 14
assert await get_version(conn) == 55
await conn.execute(
"""
@@ -1671,8 +1671,8 @@ class TestMigration046:
applied = await run_migrations(conn)
assert applied == 9
assert await get_version(conn) == 54
assert applied == 10
assert await get_version(conn) == 55
cursor = await conn.execute(
"""
@@ -1765,8 +1765,8 @@ class TestMigration047:
applied = await run_migrations(conn)
assert applied == 8
assert await get_version(conn) == 54
assert applied == 9
assert await get_version(conn) == 55
cursor = await conn.execute(
"""
+16 -28
View File
@@ -13,7 +13,6 @@ from meshcore import EventType
from meshcore.events import Event
import app.radio_sync as radio_sync
from app.models import Favorite
from app.radio import RadioManager, radio_manager
from app.radio_sync import (
_message_poll_loop,
@@ -363,12 +362,8 @@ class TestSyncRecentContactsToRadio:
"""Favorite contacts not on radio are added via add_contact."""
await _insert_contact(KEY_A, "Alice", last_contacted=2000)
await _insert_contact(KEY_B, "Bob", last_contacted=1000)
await AppSettingsRepository.update(
favorites=[
Favorite(type="contact", id=KEY_A),
Favorite(type="contact", id=KEY_B),
]
)
await ContactRepository.set_favorite(KEY_A, True)
await ContactRepository.set_favorite(KEY_B, True)
mock_mc = MagicMock()
mock_mc.get_contact_by_key_prefix = MagicMock(return_value=None)
@@ -390,9 +385,8 @@ class TestSyncRecentContactsToRadio:
await _insert_contact("dd" * 32, "Dave", last_advert=3000)
await _insert_contact("ee" * 32, "Eve", last_advert=2500)
await AppSettingsRepository.update(
max_radio_contacts=5, favorites=[Favorite(type="contact", id=KEY_A)]
)
await AppSettingsRepository.update(max_radio_contacts=5)
await ContactRepository.set_favorite(KEY_A, True)
mock_mc = MagicMock()
mock_mc.get_contact_by_key_prefix = MagicMock(return_value=None)
@@ -416,10 +410,9 @@ class TestSyncRecentContactsToRadio:
for index, key in enumerate(favorite_keys):
await _insert_contact(key, f"Favorite{index}", last_contacted=2000 - index)
await AppSettingsRepository.update(
max_radio_contacts=4,
favorites=[Favorite(type="contact", id=key) for key in favorite_keys],
)
await AppSettingsRepository.update(max_radio_contacts=4)
for key in favorite_keys:
await ContactRepository.set_favorite(key, True)
mock_mc = MagicMock()
mock_mc.get_contact_by_key_prefix = MagicMock(return_value=None)
@@ -498,7 +491,7 @@ class TestSyncAndOffloadAll:
await _insert_contact(KEY_A, "Alice", last_advert=3000, contact_type=2)
await _insert_contact(KEY_B, "Bob", last_advert=2000, contact_type=1)
await AppSettingsRepository.update(max_radio_contacts=1, favorites=[])
await AppSettingsRepository.update(max_radio_contacts=1)
mock_mc = MagicMock()
mock_mc.get_contact_by_key_prefix = MagicMock(return_value=None)
@@ -519,13 +512,8 @@ class TestSyncAndOffloadAll:
await _insert_contact(KEY_A, "Alice", last_contacted=2000)
await _insert_contact(KEY_B, "Bob", last_contacted=1000)
await AppSettingsRepository.update(
max_radio_contacts=2,
favorites=[
Favorite(type="contact", id=KEY_A),
Favorite(type="contact", id=KEY_A),
],
)
await AppSettingsRepository.update(max_radio_contacts=2)
await ContactRepository.set_favorite(KEY_A, True)
mock_mc = MagicMock()
mock_mc.get_contact_by_key_prefix = MagicMock(return_value=None)
@@ -546,7 +534,7 @@ class TestSyncAndOffloadAll:
async def test_skips_contacts_already_on_radio(self, test_db):
"""Contacts already on radio are counted but not re-added."""
await _insert_contact(KEY_A, "Alice", on_radio=False)
await AppSettingsRepository.update(favorites=[Favorite(type="contact", id=KEY_A)])
await ContactRepository.set_favorite(KEY_A, True)
mock_mc = MagicMock()
mock_mc.get_contact_by_key_prefix = MagicMock(return_value=MagicMock()) # Found
@@ -606,7 +594,7 @@ class TestSyncAndOffloadAll:
async def test_handles_add_failure(self, test_db):
"""Failed add_contact increments the failed counter."""
await _insert_contact(KEY_A, "Alice")
await AppSettingsRepository.update(favorites=[Favorite(type="contact", id=KEY_A)])
await ContactRepository.set_favorite(KEY_A, True)
mock_mc = MagicMock()
mock_mc.get_contact_by_key_prefix = MagicMock(return_value=None)
@@ -632,7 +620,7 @@ class TestSyncAndOffloadAll:
direct_path_len=2,
direct_path_hash_mode=1,
)
await AppSettingsRepository.update(favorites=[Favorite(type="contact", id=KEY_A)])
await ContactRepository.set_favorite(KEY_A, True)
mock_mc = MagicMock()
mock_mc.get_contact_by_key_prefix = MagicMock(return_value=None)
@@ -661,7 +649,7 @@ class TestSyncAndOffloadAll:
direct_path_len=-125,
direct_path_hash_mode=2,
)
await AppSettingsRepository.update(favorites=[Favorite(type="contact", id=KEY_A)])
await ContactRepository.set_favorite(KEY_A, True)
mock_mc = MagicMock()
mock_mc.get_contact_by_key_prefix = MagicMock(return_value=None)
@@ -686,7 +674,7 @@ class TestSyncAndOffloadAll:
so it passes mc directly to avoid deadlock (asyncio.Lock is not reentrant).
"""
await _insert_contact(KEY_A, "Alice", last_contacted=2000)
await AppSettingsRepository.update(favorites=[Favorite(type="contact", id=KEY_A)])
await ContactRepository.set_favorite(KEY_A, True)
mock_mc = MagicMock()
mock_mc.get_contact_by_key_prefix = MagicMock(return_value=None)
@@ -726,7 +714,7 @@ class TestSyncAndOffloadAll:
"""If _meshcore is swapped between pre-check and lock acquisition,
the function uses the new (post-lock) instance, not the stale one."""
await _insert_contact(KEY_A, "Alice", last_contacted=2000)
await AppSettingsRepository.update(favorites=[Favorite(type="contact", id=KEY_A)])
await ContactRepository.set_favorite(KEY_A, True)
old_mc = MagicMock(name="old_mc")
new_mc = MagicMock(name="new_mc")
-27
View File
@@ -620,7 +620,6 @@ class TestAppSettingsRepository:
mock_cursor.fetchone = AsyncMock(
return_value={
"max_radio_contacts": 250,
"favorites": "{not-json",
"auto_decrypt_dm_on_advert": 1,
"last_message_times": "{also-not-json",
"advert_interval": None,
@@ -641,36 +640,10 @@ class TestAppSettingsRepository:
settings = await AppSettingsRepository.get()
assert settings.max_radio_contacts == 250
assert settings.favorites == []
assert settings.last_message_times == {}
assert settings.advert_interval == 0
assert settings.last_advert_time == 0
@pytest.mark.asyncio
async def test_add_favorite_is_idempotent(self):
"""Adding an existing favorite does not write duplicate entries."""
from app.models import AppSettings, Favorite
existing = AppSettings(favorites=[Favorite(type="contact", id="aa" * 32)])
with (
patch(
"app.repository.AppSettingsRepository.get",
new_callable=AsyncMock,
return_value=existing,
),
patch(
"app.repository.AppSettingsRepository.update",
new_callable=AsyncMock,
) as mock_update,
):
from app.repository import AppSettingsRepository
result = await AppSettingsRepository.add_favorite("contact", "aa" * 32)
assert result == existing
mock_update.assert_not_awaited()
class TestMessageRepositoryGetById:
"""Test MessageRepository.get_by_id method."""
+263
View File
@@ -0,0 +1,263 @@
"""Tests for the installer scripts' environment-value escaping.
Covers both:
- systemd_escape_env_value() in install_service.sh (systemd unit files)
- yaml_quote() in install_docker.sh (docker-compose YAML)
Each function is called via bash subprocess, then round-tripped through a
Python re-implementation of the target format's unquoting rules.
Dangerous characters by format:
systemd: % (specifier expansion), " and \\ (unquoting), spaces (field split)
YAML single-quoted: ' (only special char; doubled to escape)
"""
import re
import subprocess
SERVICE_SCRIPT = "scripts/setup/install_service.sh"
DOCKER_SCRIPT = "scripts/setup/install_docker.sh"
# ---------------------------------------------------------------------------
# Brutal test strings — shared across both formats
# ---------------------------------------------------------------------------
BRUTAL_STRINGS = [
# Basic
("simple", "hello"),
("with_space", "hello world"),
("with_spaces", " hello world "),
# Dollar signs (the original bug report, issue #159)
("dollar_mid", "p@ss$word"),
("dollar_end", "password$"),
("double_dollar", "pa$$word"),
("dollar_brace", "pa${HOME}ss"),
("dollar_paren", "pa$(whoami)ss"),
# Percent specifiers (systemd expansion)
("percent_n", "pass%nword"),
("percent_u", "pass%uword"),
("percent_H", "pass%Hword"),
("double_percent", "pass%%word"),
("percent_at_end", "password%"),
# Backslashes
("single_backslash", r"pass\word"),
("double_backslash", "pass\\\\word"),
("trailing_backslash", "password\\"),
("backslash_n", "pass\\nword"),
# Quotes
("double_quote", 'pass"word'),
("single_quote", "pass'word"),
("mixed_quotes", """pass"wo'rd"""),
("all_quotes", """he said "it's done" """),
# Combined chaos
("kitchen_sink", r"""p@ss$w%ord"with\special'chars"""),
("systemd_nightmare", r"%n$HOME\"%u"),
# Unicode and emoji
("emoji", "p@ss\U0001f512word"),
("unicode_accents", "p\u00e4ssw\u00f6rd"),
("cjk", "\u5bc6\u7801"),
("emoji_pile", "\U0001f680\U0001f525\U0001f4a5"),
# Edge cases
("empty", ""),
("only_spaces", " "),
("only_percent", "%"),
("only_backslash", "\\"),
("only_double_quote", '"'),
("only_single_quote", "'"),
("tab_embedded", "pass\tword"),
("very_long", "A" * 1000),
("glob_chars", "p*ss?[w]ord"),
("shell_pipe", "pass|word&bg"),
("semicolon", "pass;word"),
("backtick", "pass`whoami`word"),
("exclamation", "pass!word"),
("hash", "pass#word"),
("tilde", "~pass"),
("equals", "pass=word"),
("colon", "user:pass"),
# Device paths (serial ports, by-id paths with colons)
("serial_simple", "/dev/ttyUSB0"),
("serial_acm", "/dev/ttyACM0"),
("serial_by_id", "/dev/serial/by-id/usb-Heltec_HT-n5262_F423934AA2AB2A5E-if00"),
("serial_colon_in_id", "/dev/serial/by-id/usb-vendor:product-0:0"),
("tcp_host", "192.168.1.100"),
("ble_address", "AA:BB:CC:DD:EE:FF"),
]
# ---------------------------------------------------------------------------
# systemd helpers
# ---------------------------------------------------------------------------
def _bash_systemd_escape(value: str) -> str:
"""Call systemd_escape_env_value() via bash."""
result = subprocess.run(
[
"bash",
"-c",
r"""
systemd_escape_env_value() {
local v="$1"
v="${v//\\/\\\\}"
v="${v//\"/\\\"}"
v="${v//%/%%}"
printf '"%s"' "$v"
}
systemd_escape_env_value "$1"
""",
"--",
value,
],
capture_output=True,
text=True,
check=True,
)
return result.stdout
def _systemd_unquote(raw: str) -> str:
"""Re-implement systemd.syntax(7) double-quote unquoting."""
raw = raw.strip()
if not raw.startswith('"') or not raw.endswith('"') or len(raw) < 2:
return raw
inner = raw[1:-1]
out: list[str] = []
i = 0
while i < len(inner):
if inner[i] == "\\" and i + 1 < len(inner) and inner[i + 1] in ('"', "\\"):
out.append(inner[i + 1])
i += 2
else:
out.append(inner[i])
i += 1
return "".join(out)
def _systemd_expand_specifiers(value: str) -> str:
"""Expand %%% and detect leaked single-% specifiers."""
stripped = value.replace("%%", "")
if re.search(r"%[a-zA-Z]", stripped):
return "SPECIFIER_LEAKED"
return value.replace("%%", "%")
def _systemd_round_trip(value: str) -> str:
return _systemd_expand_specifiers(_systemd_unquote(_bash_systemd_escape(value)))
# ---------------------------------------------------------------------------
# YAML helpers
# ---------------------------------------------------------------------------
def _bash_yaml_quote(value: str) -> str:
"""Call yaml_quote() via bash."""
result = subprocess.run(
[
"bash",
"-c",
r"""
yaml_quote() {
local value="$1"
value=${value//\'/\'\'}
printf "'%s'" "$value"
}
yaml_quote "$1"
""",
"--",
value,
],
capture_output=True,
text=True,
check=True,
)
return result.stdout
def _yaml_unquote_single(raw: str) -> str:
"""YAML single-quoted scalar unquoting: ''' inside single quotes."""
raw = raw.strip()
if not raw.startswith("'") or not raw.endswith("'") or len(raw) < 2:
return raw
return raw[1:-1].replace("''", "'")
def _yaml_round_trip(value: str) -> str:
return _yaml_unquote_single(_bash_yaml_quote(value))
# ---------------------------------------------------------------------------
# Tests
# ---------------------------------------------------------------------------
class TestSystemdEscape:
"""All brutal strings survive systemd escape → unquote → specifier round trip."""
def test_all_strings_round_trip(self):
failures = []
for name, value in BRUTAL_STRINGS:
recovered = _systemd_round_trip(value)
if recovered != value:
escaped = _bash_systemd_escape(value)
failures.append(
f" {name}: input={value!r} recovered={recovered!r} escaped={escaped!r}"
)
assert not failures, "Systemd round-trip failures:\n" + "\n".join(failures)
def test_no_specifier_leaks(self):
failures = []
for name, value in BRUTAL_STRINGS:
escaped = _bash_systemd_escape(value)
unquoted = _systemd_unquote(escaped)
stripped = unquoted.replace("%%", "")
leaked = re.findall(r"%[a-zA-Z]", stripped)
if leaked:
failures.append(f" {name}: {leaked} in unquoted={unquoted!r}")
assert not failures, "Specifier leaks:\n" + "\n".join(failures)
def test_output_always_double_quoted(self):
failures = []
for name, value in BRUTAL_STRINGS:
escaped = _bash_systemd_escape(value)
if not (escaped.startswith('"') and escaped.endswith('"')):
failures.append(f" {name}: {escaped!r}")
assert not failures, "Not double-quoted:\n" + "\n".join(failures)
def test_function_present_in_installer(self):
with open(SERVICE_SCRIPT) as f:
content = f.read()
assert "systemd_escape_env_value()" in content
assert 'systemd_escape_env_value "$AUTH_USERNAME"' in content
assert 'systemd_escape_env_value "$AUTH_PASSWORD"' in content
class TestYamlQuote:
"""All brutal strings survive YAML single-quote escape → unquote round trip."""
def test_all_strings_round_trip(self):
failures = []
for name, value in BRUTAL_STRINGS:
recovered = _yaml_round_trip(value)
if recovered != value:
escaped = _bash_yaml_quote(value)
failures.append(
f" {name}: input={value!r} recovered={recovered!r} escaped={escaped!r}"
)
assert not failures, "YAML round-trip failures:\n" + "\n".join(failures)
def test_output_always_single_quoted(self):
failures = []
for name, value in BRUTAL_STRINGS:
escaped = _bash_yaml_quote(value)
if not (escaped.startswith("'") and escaped.endswith("'")):
failures.append(f" {name}: {escaped!r}")
assert not failures, "Not single-quoted:\n" + "\n".join(failures)
def test_function_present_in_installer(self):
with open(DOCKER_SCRIPT) as f:
content = f.read()
assert "yaml_quote()" in content
assert 'yaml_quote "$AUTH_USERNAME"' in content
assert 'yaml_quote "$AUTH_PASSWORD"' in content
+7 -6
View File
@@ -133,6 +133,7 @@ class TestUpdateSettings:
class TestToggleFavorite:
@pytest.mark.asyncio
async def test_adds_when_not_favorited(self, test_db):
await ContactRepository.upsert(ContactUpsert(public_key="aa" * 32, name="Alice"))
request = FavoriteRequest(type="contact", id="aa" * 32)
with (
patch("app.radio_sync.ensure_contact_on_radio", new_callable=AsyncMock) as mock_sync,
@@ -141,16 +142,16 @@ class TestToggleFavorite:
mock_create_task.side_effect = lambda coro: coro.close()
result = await toggle_favorite(request)
assert len(result.favorites) == 1
assert result.favorites[0].type == "contact"
assert result.favorites[0].id == "aa" * 32
assert result.favorite is True
assert result.type == "contact"
assert result.id == "aa" * 32
mock_sync.assert_called_once_with("aa" * 32, force=True)
mock_create_task.assert_called_once()
@pytest.mark.asyncio
async def test_removes_when_already_favorited(self, test_db):
# Pre-add a favorite
await AppSettingsRepository.add_favorite("contact", "aa" * 32)
await ContactRepository.upsert(ContactUpsert(public_key="aa" * 32, name="Alice"))
await ContactRepository.set_favorite("aa" * 32, True)
request = FavoriteRequest(type="contact", id="aa" * 32)
with (
@@ -160,7 +161,7 @@ class TestToggleFavorite:
mock_create_task.side_effect = lambda coro: coro.close()
result = await toggle_favorite(request)
assert result.favorites == []
assert result.favorite is False
mock_sync.assert_not_called()
mock_create_task.assert_not_called()