27 Commits

Author SHA1 Message Date
jkingsman
498770bd88 More content-paint patchy patchy bs 2026-03-26 17:30:40 -07:00
Jack Kingsman
bf0533807a Rich install script. Closes #111 2026-03-26 17:04:12 -07:00
jkingsman
094058bad7 Tweak install script 2026-03-26 16:59:53 -07:00
jkingsman
88c99e0983 Add note in readme 2026-03-26 16:50:48 -07:00
jkingsman
983a37f68f Idempotentify and remove the explicit setup instructions in the advanced readme 2026-03-26 16:46:27 -07:00
jkingsman
bea3495b79 Improve coverage around desktop notifications. Closes #115. 2026-03-26 16:39:38 -07:00
jkingsman
54c24c50d3 Clarify MQTT error logs when persistent 2026-03-26 13:39:08 -07:00
jkingsman
26b740fe3c Fix lint 2026-03-25 08:57:43 -07:00
jkingsman
b0f5930e01 Swipe away 2026-03-25 08:46:50 -07:00
jkingsman
5b05fdefa1 Change room finder to be channels not rooms 2026-03-25 08:34:21 -07:00
jkingsman
b63153b3a1 Initial swipe work 2026-03-25 08:32:06 -07:00
Jack Kingsman
3c5a832bef Merge pull request #113 from an0key/main
Update Sidebar.tsx
2026-03-25 08:19:04 -07:00
jkingsman
fd8bc4b56a First draft of install script 2026-03-25 08:09:55 -07:00
Luke
2d943dedc5 Update Sidebar.tsx 2026-03-25 15:09:32 +00:00
Jack Kingsman
137f41970d Fix some places where we used vh instead of dvh for modal sizing 2026-03-24 21:07:20 -07:00
Jack Kingsman
c833f1036b Test scroll fix for mobile browsers 2026-03-24 21:05:29 -07:00
jkingsman
4ead2ffcde Add prebuilt frontend fetch script. Closes #110. 2026-03-24 16:42:49 -07:00
jkingsman
caf4bf4eff Fix linting 2026-03-24 16:32:19 -07:00
jkingsman
74e1f49db8 Show hop map in a larger modal. Closes #102. 2026-03-24 16:14:43 -07:00
Jack Kingsman
3b28ebfa49 Fix e2e tests 2026-03-24 14:51:29 -07:00
jkingsman
d36c63f6b1 Complete room -> channel rename 2026-03-24 14:02:43 -07:00
jkingsman
e8a4f5c349 Make a better integration/fanout selector 2026-03-24 13:48:50 -07:00
jkingsman
b022aea71f Adjust phrasing on new-chat modal, and remove the unusable existing-contact scren. Closes #105. 2026-03-24 10:02:39 -07:00
jkingsman
5225a1c766 Don't be so eager on the quality gate 2026-03-24 09:59:37 -07:00
Jack Kingsman
41400c0528 Change page title and favicon for unreads. Green for favorite group chats, red for unread mentions or DMs. Closes #100 WOOOO 2026-03-23 21:36:54 -07:00
Jack Kingsman
07928d930c Clarify phrasing around bot system 2026-03-23 19:32:45 -07:00
Jack Kingsman
26742d0c88 Merge pull request #103 from jkingsman/bot-safety
Bot safety
2026-03-23 18:44:50 -07:00
42 changed files with 1879 additions and 584 deletions

View File

@@ -296,9 +296,9 @@ cd frontend
npm run test:run
```
### Before Completing Changes
### Before Completing Major Changes
**Always run `./scripts/all_quality.sh` before finishing any changes that have modified code or tests.** It is the standard repo gate: autofix first, then type checks, tests, and the standard frontend build. This is not necessary for docs-only changes.
**Run `./scripts/all_quality.sh` before finishing major changes that have modified code or tests.** It is the standard repo gate: autofix first, then type checks, tests, and the standard frontend build. This is not necessary for docs-only changes. For minor changes (like wording, color, spacing, etc.), wait until prompted to run the quality gate.
## API Summary

View File

@@ -7,7 +7,7 @@ Backend server + browser interface for MeshCore mesh radio networks. Connect you
* Run multiple Python bots that can analyze messages and respond to DMs and channels
* Monitor unlimited contacts and channels (radio limits don't apply -- packets are decrypted server-side)
* Access your radio remotely over your network or VPN
* Search for hashtag room names for channels you don't have keys for yet
* Search for hashtag channel names for channels you don't have keys for yet
* Forward packets to MQTT, LetsMesh, MeshRank, SQS, Apprise, etc.
* Use the more recent 1.14 firmwares which support multibyte pathing
* Visualize the mesh as a map or node set, view repeater stats, and more!
@@ -41,8 +41,6 @@ If you plan to contribute, read [CONTRIBUTING.md](CONTRIBUTING.md).
- [UV](https://astral.sh/uv) package manager: `curl -LsSf https://astral.sh/uv/install.sh | sh`
- MeshCore radio connected via USB serial, TCP, or BLE
If you are on a low-resource system and do not want to build the frontend locally, download the release zip named `remoteterm-prebuilt-frontend-vX.X.X-<short hash>.zip`. That bundle includes `frontend/prebuilt`, so you can run the app without doing a frontend build from source.
<details>
<summary>Finding your serial port</summary>
@@ -97,6 +95,8 @@ Access the app at http://localhost:8000.
Source checkouts expect a normal frontend build in `frontend/dist`.
On Linux, if you want this installed as a persistent `systemd` service that starts on boot and restarts automatically on failure, run `bash scripts/install_service.sh` from the repo root.
## Path 1.5: Use The Prebuilt Release Zip
Release zips can be found as an asset within the [releases listed here](https://github.com/jkingsman/Remote-Terminal-for-MeshCore/releases). This can be beneficial on resource constrained systems that cannot cope with the RAM-hungry frontend build process.
@@ -111,6 +111,8 @@ uv run uvicorn app.main:app --host 0.0.0.0 --port 8000
The release bundle includes `frontend/prebuilt`, so it does not require a local frontend build.
Alternatively, if you have already cloned the repo, you can fetch just the prebuilt frontend into your working tree without downloading the full release zip via `python3 scripts/fetch_prebuilt_frontend.py`.
## Path 2: Docker
> **Warning:** Docker has had reports intermittent issues with serial event subscriptions. The native method above is more reliable.

View File

@@ -21,7 +21,7 @@ If the audit finds a mismatch, you'll see an error in the application UI and you
## HTTPS
WebGPU room-finding requires a secure context when you are not on `localhost`.
WebGPU channel-finding requires a secure context when you are not on `localhost`.
Generate a local cert and start the backend with TLS:
@@ -46,59 +46,37 @@ Accept the browser warning, or use [mkcert](https://github.com/FiloSottile/mkcer
## Systemd Service
Assumes you are running from `/opt/remoteterm`; adjust paths if you deploy elsewhere.
Two paths are available depending on your comfort level with Linux system administration.
### Simple install (recommended for most users)
On Linux systems, this is the recommended installation method if you want RemoteTerm set up as a persistent systemd service that starts automatically on boot and restarts automatically if it crashes. Run the installer script from the repo root. It runs as your current user, installs from wherever you cloned the repo, and prints a quick-reference cheatsheet when done — no separate service account or path juggling required.
```bash
# Create service user
sudo useradd -r -m -s /bin/false remoteterm
# Install to /opt/remoteterm
sudo mkdir -p /opt/remoteterm
sudo cp -r . /opt/remoteterm/
sudo chown -R remoteterm:remoteterm /opt/remoteterm
# Install dependencies
cd /opt/remoteterm
sudo -u remoteterm uv venv
sudo -u remoteterm uv sync
# If deploying from a source checkout, build the frontend first
sudo -u remoteterm bash -lc 'cd /opt/remoteterm/frontend && npm install && npm run build'
# If deploying from the release zip artifact, frontend/prebuilt is already present
bash scripts/install_service.sh
```
Create `/etc/systemd/system/remoteterm.service` with:
The script interactively asks which transport to use (serial auto-detect, serial with explicit port, TCP, or BLE), whether to build the frontend locally or download a prebuilt copy, whether to enable the bot system, and whether to set up HTTP Basic Auth. It handles dependency installation (`uv sync`), validates `node`/`npm` for local builds, adds your user to the `dialout` group if needed, writes the systemd unit file, and enables the service. After installation, normal operations work without any `sudo -u` gymnastics:
```ini
[Unit]
Description=RemoteTerm for MeshCore
After=network.target
[Service]
Type=simple
User=remoteterm
Group=remoteterm
WorkingDirectory=/opt/remoteterm
ExecStart=/opt/remoteterm/.venv/bin/uvicorn app.main:app --host 0.0.0.0 --port 8000
Restart=always
RestartSec=5
Environment=MESHCORE_DATABASE_PATH=/opt/remoteterm/data/meshcore.db
# Uncomment and set if auto-detection doesn't work:
# Environment=MESHCORE_SERIAL_PORT=/dev/ttyUSB0
SupplementaryGroups=dialout
[Install]
WantedBy=multi-user.target
```
Then install and start it:
You can also rerun the script later to change transport, bot, or auth settings. If the service is already running, the installer stops it, rewrites the unit file, reloads systemd, and starts it again with the new configuration.
```bash
sudo systemctl daemon-reload
sudo systemctl enable --now remoteterm
sudo systemctl status remoteterm
# Update to latest and restart
cd /path/to/repo
git pull
uv sync
cd frontend && npm install && npm run build && cd ..
sudo systemctl restart remoteterm
# Refresh prebuilt frontend only (skips local build)
python3 scripts/fetch_prebuilt_frontend.py
sudo systemctl restart remoteterm
# View live logs
sudo journalctl -u remoteterm -f
# Service control
sudo systemctl start|stop|restart|status remoteterm
```
## Debug Logging And Bug Reports

View File

@@ -101,7 +101,7 @@ app/
- Packet `path_len` values are hop counts, not byte counts.
- Hop width comes from the packet or radio `path_hash_mode`: `0` = 1-byte, `1` = 2-byte, `2` = 3-byte.
- Channel slot count comes from firmware-reported `DEVICE_INFO.max_channels`; do not hardcode `40` when scanning/offloading channel slots.
- Channel sends use a session-local LRU slot cache after startup channel offload clears the radio. Repeated sends to the same room reuse the loaded slot; new rooms fill free slots up to the discovered channel capacity, then evict the least recently used cached room.
- Channel sends use a session-local LRU slot cache after startup channel offload clears the radio. Repeated sends to the same channel reuse the loaded slot; new channels fill free slots up to the discovered channel capacity, then evict the least recently used cached channel.
- TCP radios do not reuse cached slot contents. For TCP, channel sends still force `set_channel(...)` before every send because this backend does not have exclusive device access.
- `MESHCORE_FORCE_CHANNEL_SLOT_RECONFIGURE=true` disables slot reuse on all transports and forces the old always-`set_channel(...)` behavior before every channel send.
- Contacts persist canonical direct-route fields (`direct_path`, `direct_path_len`, `direct_path_hash_mode`) so contact sync and outbound DM routing reuse the exact stored hop width instead of inferring from path bytes.

View File

@@ -102,7 +102,7 @@ class BaseMqttPublisher(ABC):
except Exception as e:
logger.warning(
"%s publish failed on %s. This is usually transient network noise; "
"if it self-resolves and reconnects, it is generally not a concern: %s",
"if it self-resolves and reconnects, it is generally not a concern. Persistent errors may indicate a problem with your network connection or MQTT broker. Original error: %s",
self._integration_label(),
topic,
e,
@@ -239,7 +239,7 @@ class BaseMqttPublisher(ABC):
logger.warning(
"%s connection error. This is usually transient network noise; "
"if it self-resolves, it is generally not a concern: %s "
"(reconnecting in %ds)",
"(reconnecting in %ds). If this error persists, check your network connection and MQTT broker status.",
self._integration_label(),
e,
backoff,

View File

@@ -266,7 +266,7 @@ class ContactNameHistory(BaseModel):
class ContactActiveRoom(BaseModel):
"""A channel/room where a contact has been active."""
"""A channel where a contact has been active."""
channel_key: str
channel_name: str

View File

@@ -71,7 +71,7 @@ async def create_channel(request: CreateChannelRequest) -> Channel:
requested_name = request.name
is_hashtag = requested_name.startswith("#")
# Reserve the canonical Public room so it cannot drift to another key,
# Reserve the canonical Public channel so it cannot drift to another key,
# and the well-known Public key cannot be renamed to something else.
if is_public_channel_name(requested_name):
if request.key:

View File

@@ -9,8 +9,8 @@
<meta name="theme-color" content="#111419" />
<meta name="description" content="Web interface for MeshCore mesh radio networks. Send and receive messages, manage contacts and channels, and configure your radio." />
<title>RemoteTerm for MeshCore</title>
<link rel="icon" type="image/png" href="/favicon-96x96.png" sizes="96x96" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="icon" type="image/png" href="/favicon-96x96.png" sizes="96x96" />
<link rel="shortcut icon" href="/favicon.ico" />
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
<link rel="manifest" href="/site.webmanifest" />

View File

@@ -1,12 +1,12 @@
{
"name": "remoteterm-meshcore-frontend",
"version": "2.7.9",
"version": "3.6.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "remoteterm-meshcore-frontend",
"version": "2.7.9",
"version": "3.6.0",
"dependencies": {
"@codemirror/lang-python": "^6.2.1",
"@codemirror/theme-one-dark": "^6.1.3",
@@ -29,6 +29,7 @@
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-leaflet": "^4.2.1",
"react-swipeable": "^7.0.2",
"sonner": "^2.0.7",
"tailwind-merge": "^3.4.0",
"tailwindcss-animate": "^1.0.7",
@@ -5695,6 +5696,15 @@
}
}
},
"node_modules/react-swipeable": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/react-swipeable/-/react-swipeable-7.0.2.tgz",
"integrity": "sha512-v1Qx1l+aC2fdxKa9aKJiaU/ZxmJ5o98RMoFwUqAAzVWUcxgfHFXDDruCKXhw6zIYXm6V64JiHgP9f6mlME5l8w==",
"license": "MIT",
"peerDependencies": {
"react": "^16.8.3 || ^17 || ^18 || ^19.0.0 || ^19.0.0-rc"
}
},
"node_modules/read-cache": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz",

View File

@@ -37,6 +37,7 @@
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-leaflet": "^4.2.1",
"react-swipeable": "^7.0.2",
"sonner": "^2.0.7",
"tailwind-merge": "^3.4.0",
"tailwindcss-animate": "^1.0.7",

View File

@@ -14,6 +14,8 @@ import {
useConversationNavigation,
useRealtimeAppState,
useBrowserNotifications,
useFaviconBadge,
useUnreadTitle,
useRawPacketStatsSession,
} from './hooks';
import { AppShell } from './components/AppShell';
@@ -259,6 +261,8 @@ export function App() {
markAllRead,
refreshUnreads,
} = useUnreadCounts(channels, contacts, activeConversation);
useFaviconBadge(unreadCounts, mentions, favorites);
useUnreadTitle(unreadCounts, favorites);
useEffect(() => {
if (activeConversation?.type !== 'channel') {
@@ -502,9 +506,7 @@ export function App() {
onChannelCreate: handleCreateCrackedChannel,
};
const newMessageModalProps = {
contacts,
undecryptedCount,
onSelectConversation: handleSelectConversationWithTargetReset,
onCreateContact: handleCreateContact,
onCreateChannel: handleCreateChannel,
onCreateHashtagChannel: handleCreateHashtagChannel,

View File

@@ -1,4 +1,5 @@
import { lazy, Suspense, useRef, type ComponentProps } from 'react';
import { useSwipeable } from 'react-swipeable';
import { StatusBar } from './StatusBar';
import { Sidebar } from './Sidebar';
@@ -89,6 +90,24 @@ export function AppShell({
contactInfoPaneProps,
channelInfoPaneProps,
}: AppShellProps) {
const swipeHandlers = useSwipeable({
onSwipedRight: ({ initial }) => {
if (initial[0] < 30 && !sidebarOpen && window.innerWidth < 768) {
onSidebarOpenChange(true);
}
},
trackTouch: true,
trackMouse: false,
preventScrollOnSwipe: true,
});
const closeSwipeHandlers = useSwipeable({
onSwipedLeft: () => onSidebarOpenChange(false),
trackTouch: true,
trackMouse: false,
preventScrollOnSwipe: false,
});
const searchMounted = useRef(false);
if (conversationPaneProps.activeConversation?.type === 'search') {
searchMounted.current = true;
@@ -153,7 +172,7 @@ export function AppShell({
);
return (
<div className="flex flex-col h-full">
<div className="flex flex-col h-full" {...swipeHandlers}>
<a
href="#main-content"
className="sr-only focus:not-sr-only focus:absolute focus:z-50 focus:p-2 focus:bg-primary focus:text-primary-foreground"
@@ -196,7 +215,9 @@ export function AppShell({
<SheetTitle>Navigation</SheetTitle>
<SheetDescription>Sidebar navigation</SheetDescription>
</SheetHeader>
<div className="flex-1 overflow-hidden">{activeSidebarContent}</div>
<div className="flex-1 overflow-hidden" {...closeSwipeHandlers}>
{activeSidebarContent}
</div>
</SheetContent>
</Sheet>
@@ -284,10 +305,6 @@ export function AppShell({
{...newMessageModalProps}
open={showNewMessage}
onClose={onCloseNewMessage}
onSelectConversation={(conv) => {
newMessageModalProps.onSelectConversation(conv);
onCloseNewMessage();
}}
/>
<SecurityWarningModal health={statusProps.health} />

View File

@@ -45,8 +45,8 @@ export function ChannelFloodScopeOverrideModal({
<DialogHeader>
<DialogTitle>Regional Override</DialogTitle>
<DialogDescription>
Room-level regional routing temporarily changes the radio flood scope before send and
restores it after. This can noticeably slow room sends.
Channel-level regional routing temporarily changes the radio flood scope before send and
restores it after. This can noticeably slow channel sends.
</DialogDescription>
</DialogHeader>

View File

@@ -201,7 +201,9 @@ export function ChatHeader({
e.stopPropagation();
navigator.clipboard.writeText(conversation.id);
toast.success(
conversation.type === 'channel' ? 'Room key copied!' : 'Contact key copied!'
conversation.type === 'channel'
? 'Channel key copied!'
: 'Contact key copied!'
);
}}
title="Click to copy"

View File

@@ -242,8 +242,8 @@ export function ContactInfoPane({
<ActivityChartsSection analytics={analytics} />
<MostActiveRoomsSection
rooms={analytics?.most_active_rooms ?? []}
<MostActiveChannelsSection
channels={analytics?.most_active_rooms ?? []}
onNavigateToChannel={onNavigateToChannel}
/>
</div>
@@ -515,8 +515,8 @@ export function ContactInfoPane({
<ActivityChartsSection analytics={analytics} />
<MostActiveRoomsSection
rooms={analytics?.most_active_rooms ?? []}
<MostActiveChannelsSection
channels={analytics?.most_active_rooms ?? []}
onNavigateToChannel={onNavigateToChannel}
/>
</div>
@@ -588,23 +588,23 @@ function MessageStatsSection({
);
}
function MostActiveRoomsSection({
rooms,
function MostActiveChannelsSection({
channels,
onNavigateToChannel,
}: {
rooms: ContactActiveRoom[];
channels: ContactActiveRoom[];
onNavigateToChannel?: (channelKey: string) => void;
}) {
if (rooms.length === 0) {
if (channels.length === 0) {
return null;
}
return (
<div className="px-5 py-3 border-b border-border">
<SectionLabel>Most Active Rooms</SectionLabel>
<SectionLabel>Most Active Channels</SectionLabel>
<div className="space-y-1">
{rooms.map((room) => (
<div key={room.channel_key} className="flex justify-between items-center text-sm">
{channels.map((channel) => (
<div key={channel.channel_key} className="flex justify-between items-center text-sm">
<span
className={
onNavigateToChannel
@@ -614,15 +614,15 @@ function MostActiveRoomsSection({
role={onNavigateToChannel ? 'button' : undefined}
tabIndex={onNavigateToChannel ? 0 : undefined}
onKeyDown={onNavigateToChannel ? handleKeyboardActivate : undefined}
onClick={() => onNavigateToChannel?.(room.channel_key)}
onClick={() => onNavigateToChannel?.(channel.channel_key)}
>
{room.channel_name.startsWith('#') || isPublicChannelKey(room.channel_key)
? room.channel_name
: `#${room.channel_name}`}
{channel.channel_name.startsWith('#') || isPublicChannelKey(channel.channel_key)
? channel.channel_name
: `#${channel.channel_name}`}
</span>
<span className="text-xs text-muted-foreground flex-shrink-0 ml-2">
{room.message_count.toLocaleString()} msg
{room.message_count !== 1 ? 's' : ''}
{channel.message_count.toLocaleString()} msg
{channel.message_count !== 1 ? 's' : ''}
</span>
</div>
))}

View File

@@ -7,8 +7,8 @@ import { toast } from './ui/sonner';
import { cn } from '@/lib/utils';
import { extractPacketPayloadHex } from '../utils/pathUtils';
interface CrackedRoom {
roomName: string;
interface CrackedChannel {
channelName: string;
key: string;
packetId: number;
message: string;
@@ -45,7 +45,7 @@ export function CrackerPanel({
const [twoWordMode, setTwoWordMode] = useState(false);
const [progress, setProgress] = useState<ProgressReport | null>(null);
const [queue, setQueue] = useState<Map<number, QueueItem>>(new Map());
const [crackedRooms, setCrackedRooms] = useState<CrackedRoom[]>([]);
const [crackedChannels, setCrackedChannels] = useState<CrackedChannel[]>([]);
const [wordlistLoaded, setWordlistLoaded] = useState(false);
const [gpuAvailable, setGpuAvailable] = useState<boolean | null>(null);
const [undecryptedPacketCount, setUndecryptedPacketCount] = useState<number | null>(null);
@@ -325,14 +325,14 @@ export function CrackerPanel({
return updated;
});
const newRoom: CrackedRoom = {
roomName: result.roomName,
const newCracked: CrackedChannel = {
channelName: result.roomName,
key: result.key,
packetId: nextId!,
message: result.decryptedMessage || '',
crackedAt: Date.now(),
};
setCrackedRooms((prev) => [...prev, newRoom]);
setCrackedChannels((prev) => [...prev, newCracked]);
// Auto-add channel if not already exists
const keyUpper = result.key.toUpperCase();
@@ -505,7 +505,7 @@ export function CrackerPanel({
? 'GPU Not Available'
: !wordlistLoaded
? 'Loading dictionary...'
: 'Find Rooms'}
: 'Find Channels'}
</button>
{/* Status */}
@@ -580,20 +580,20 @@ export function CrackerPanel({
</div>
)}
{/* Cracked rooms list */}
{crackedRooms.length > 0 && (
{/* Cracked channels list */}
{crackedChannels.length > 0 && (
<div>
<div className="text-xs text-muted-foreground mb-1">Cracked Rooms:</div>
<div className="text-xs text-muted-foreground mb-1">Cracked Channels:</div>
<div className="space-y-1">
{crackedRooms.map((room, i) => (
{crackedChannels.map((channel, i) => (
<div
key={i}
className="text-sm bg-success/10 border border-success/20 rounded px-2 py-1"
>
<span className="text-success font-medium">#{room.roomName}</span>
<span className="text-success font-medium">#{channel.channelName}</span>
<span className="text-muted-foreground ml-2 text-xs">
"{room.message.slice(0, 50)}
{room.message.length > 50 ? '...' : ''}"
"{channel.message.slice(0, 50)}
{channel.message.length > 50 ? '...' : ''}"
</span>
</div>
))}
@@ -604,8 +604,8 @@ export function CrackerPanel({
<hr className="border-border" />
<p className="text-sm text-muted-foreground leading-relaxed">
For unknown-keyed GroupText packets, this will attempt to dictionary attack, then brute
force payloads as they arrive, testing room names up to the specified length to discover
active rooms on the local mesh (GroupText packets may not be hashtag messages; we have no
force payloads as they arrive, testing channel names up to the specified length to discover
active channels on the local mesh (GroupText packets may not be hashtag messages; we have no
way of knowing but try as if they are).
<strong> Retry failed at n+1</strong> will let the cracker return to the failed queue and
pick up messages it couldn't crack, attempting them at one longer length.
@@ -613,8 +613,8 @@ export function CrackerPanel({
concatenated together (e.g. "hello" + "world" = "#helloworld") after the single-word
dictionary pass; this can substantially increase search time and also result in
false-positives.
<strong> Decrypt historical</strong> will run an async job on any room name it finds to see
if any historically captured packets will decrypt with that key.
<strong> Decrypt historical</strong> will run an async job on any channel name it finds to
see if any historically captured packets will decrypt with that key.
<strong> Turbo mode</strong> will push your GPU to the max (target dispatch time of 10s) and
may allow accelerated cracking and/or system instability.
</p>

View File

@@ -1,7 +1,5 @@
import { useState, useRef } from 'react';
import { Dice5 } from 'lucide-react';
import type { Contact, Conversation } from '../types';
import { getContactDisplayName } from '../utils/pubkey';
import {
Dialog,
DialogContent,
@@ -17,14 +15,12 @@ import { Checkbox } from './ui/checkbox';
import { Button } from './ui/button';
import { toast } from './ui/sonner';
type Tab = 'existing' | 'new-contact' | 'new-room' | 'hashtag';
type Tab = 'new-contact' | 'new-channel' | 'hashtag';
interface NewMessageModalProps {
open: boolean;
contacts: Contact[];
undecryptedCount: number;
onClose: () => void;
onSelectConversation: (conversation: Conversation) => void;
onCreateContact: (name: string, publicKey: string, tryHistorical: boolean) => Promise<void>;
onCreateChannel: (name: string, key: string, tryHistorical: boolean) => Promise<void>;
onCreateHashtagChannel: (name: string, tryHistorical: boolean) => Promise<void>;
@@ -32,18 +28,16 @@ interface NewMessageModalProps {
export function NewMessageModal({
open,
contacts,
undecryptedCount,
onClose,
onSelectConversation,
onCreateContact,
onCreateChannel,
onCreateHashtagChannel,
}: NewMessageModalProps) {
const [tab, setTab] = useState<Tab>('existing');
const [tab, setTab] = useState<Tab>('new-contact');
const [name, setName] = useState('');
const [contactKey, setContactKey] = useState('');
const [roomKey, setRoomKey] = useState('');
const [channelKey, setChannelKey] = useState('');
const [tryHistorical, setTryHistorical] = useState(false);
const [permitCapitals, setPermitCapitals] = useState(false);
const [error, setError] = useState('');
@@ -53,7 +47,7 @@ export function NewMessageModal({
const resetForm = () => {
setName('');
setContactKey('');
setRoomKey('');
setChannelKey('');
setTryHistorical(false);
setPermitCapitals(false);
setError('');
@@ -71,12 +65,12 @@ export function NewMessageModal({
}
// handleCreateContact sets activeConversation with the backend-normalized key
await onCreateContact(name.trim(), contactKey.trim(), tryHistorical);
} else if (tab === 'new-room') {
if (!name.trim() || !roomKey.trim()) {
setError('Room name and key are required');
} else if (tab === 'new-channel') {
if (!name.trim() || !channelKey.trim()) {
setError('Channel name and key are required');
return;
}
await onCreateChannel(name.trim(), roomKey.trim(), tryHistorical);
await onCreateChannel(name.trim(), channelKey.trim(), tryHistorical);
} else if (tab === 'hashtag') {
const channelName = name.trim();
const validationError = validateHashtagName(channelName);
@@ -136,7 +130,7 @@ export function NewMessageModal({
}
};
const showHistoricalOption = tab !== 'existing' && undecryptedCount > 0;
const showHistoricalOption = undecryptedCount > 0;
return (
<Dialog
@@ -152,9 +146,8 @@ export function NewMessageModal({
<DialogHeader>
<DialogTitle>New Conversation</DialogTitle>
<DialogDescription className="sr-only">
{tab === 'existing' && 'Select an existing contact to start a conversation'}
{tab === 'new-contact' && 'Add a new contact by entering their name and public key'}
{tab === 'new-room' && 'Create a private room with a shared encryption key'}
{tab === 'new-channel' && 'Create a private channel with a shared encryption key'}
{tab === 'hashtag' && 'Join a public hashtag channel'}
</DialogDescription>
</DialogHeader>
@@ -167,53 +160,12 @@ export function NewMessageModal({
}}
className="w-full"
>
<TabsList className="grid w-full grid-cols-4">
<TabsTrigger value="existing">Existing</TabsTrigger>
<TabsList className="grid w-full grid-cols-3">
<TabsTrigger value="new-contact">Contact</TabsTrigger>
<TabsTrigger value="new-room">Room</TabsTrigger>
<TabsTrigger value="hashtag">Hashtag</TabsTrigger>
<TabsTrigger value="new-channel">Private Channel</TabsTrigger>
<TabsTrigger value="hashtag">Hashtag Channel</TabsTrigger>
</TabsList>
<TabsContent value="existing" className="mt-4">
<div className="max-h-[300px] overflow-y-auto rounded-md border">
{contacts.filter((contact) => contact.public_key.length === 64).length === 0 ? (
<div className="p-4 text-center text-muted-foreground">No contacts available</div>
) : (
contacts
.filter((contact) => contact.public_key.length === 64)
.map((contact) => (
<div
key={contact.public_key}
className="cursor-pointer px-4 py-2 hover:bg-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset"
role="button"
tabIndex={0}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
(e.currentTarget as HTMLElement).click();
}
}}
onClick={() => {
onSelectConversation({
type: 'contact',
id: contact.public_key,
name: getContactDisplayName(
contact.name,
contact.public_key,
contact.last_advert
),
});
resetForm();
onClose();
}}
>
{getContactDisplayName(contact.name, contact.public_key, contact.last_advert)}
</div>
))
)}
</div>
</TabsContent>
<TabsContent value="new-contact" className="mt-4 space-y-4">
<div className="space-y-2">
<Label htmlFor="contact-name">Name</Label>
@@ -235,23 +187,23 @@ export function NewMessageModal({
</div>
</TabsContent>
<TabsContent value="new-room" className="mt-4 space-y-4">
<TabsContent value="new-channel" className="mt-4 space-y-4">
<div className="space-y-2">
<Label htmlFor="room-name">Room Name</Label>
<Label htmlFor="channel-name">Channel Name</Label>
<Input
id="room-name"
id="channel-name"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Room name"
placeholder="Channel name"
/>
</div>
<div className="space-y-2">
<Label htmlFor="room-key">Room Key</Label>
<Label htmlFor="channel-key">Channel Key</Label>
<div className="flex gap-2">
<Input
id="room-key"
value={roomKey}
onChange={(e) => setRoomKey(e.target.value)}
id="channel-key"
value={channelKey}
onChange={(e) => setChannelKey(e.target.value)}
placeholder="Pre-shared key (hex)"
className="flex-1"
/>
@@ -265,7 +217,7 @@ export function NewMessageModal({
const hex = Array.from(bytes)
.map((b) => b.toString(16).padStart(2, '0'))
.join('');
setRoomKey(hex);
setChannelKey(hex);
}}
title="Generate random key"
aria-label="Generate random key"
@@ -299,7 +251,7 @@ export function NewMessageModal({
onChange={(e) => setPermitCapitals(e.target.checked)}
className="w-4 h-4 rounded border-input accent-primary"
/>
<span className="text-sm">Permit capitals in room key derivation</span>
<span className="text-sm">Permit capitals in channel key derivation</span>
</label>
<p className="text-xs text-muted-foreground pl-7">
Not recommended; most companions normalize to lowercase
@@ -353,11 +305,9 @@ export function NewMessageModal({
{loading ? 'Creating...' : 'Create & Add Another'}
</Button>
)}
{tab !== 'existing' && (
<Button onClick={handleCreate} disabled={loading}>
{loading ? 'Creating...' : 'Create'}
</Button>
)}
<Button onClick={handleCreate} disabled={loading}>
{loading ? 'Creating...' : 'Create'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>

View File

@@ -51,7 +51,7 @@ export function PathModal({
onAnalyzePacket,
}: PathModalProps) {
const { distanceUnit } = useDistanceUnit();
const [expandedMaps, setExpandedMaps] = useState<Set<number>>(new Set());
const [mapModalIndex, setMapModalIndex] = useState<number | null>(null);
const hasResendActions = isOutgoingChan && messageId !== undefined && onResend;
const hasPaths = paths.length > 0;
const showAnalyzePacket = hasPaths && packetId != null && onAnalyzePacket;
@@ -68,7 +68,7 @@ export function PathModal({
return (
<Dialog open={open} onOpenChange={(isOpen) => !isOpen && onClose()}>
<DialogContent className="max-w-md max-h-[80vh] flex flex-col">
<DialogContent className="max-w-md max-h-[80dvh] flex flex-col">
<DialogHeader>
<DialogTitle>
{hasPaths
@@ -141,59 +141,68 @@ export function PathModal({
</div>
)}
{resolvedPaths.map((pathData, index) => {
const mapExpanded = expandedMaps.has(index);
const toggleMap = () =>
setExpandedMaps((prev) => {
const next = new Set(prev);
if (next.has(index)) next.delete(index);
else next.add(index);
return next;
});
return (
<div key={index}>
<div className="flex items-center justify-between mb-2 pb-1 border-b border-border">
{!hasSinglePath ? (
<div className="text-sm text-foreground/70 font-semibold">
Path {index + 1}{' '}
<span className="font-normal text-muted-foreground">
received {formatTime(pathData.received_at)}
</span>
</div>
) : (
<div />
)}
<button
onClick={toggleMap}
aria-expanded={mapExpanded}
className="text-xs text-primary hover:underline cursor-pointer shrink-0 ml-2"
>
{mapExpanded ? 'Hide map' : 'Map route'}
</button>
</div>
{mapExpanded && (
<div className="mb-2">
<Suspense
fallback={
<div
className="rounded border border-border bg-muted/30 animate-pulse"
style={{ height: 220 }}
/>
}
>
<PathRouteMap resolved={pathData.resolved} senderInfo={senderInfo} />
</Suspense>
{resolvedPaths.map((pathData, index) => (
<div key={index}>
<div className="flex items-center justify-between mb-2 pb-1 border-b border-border">
{!hasSinglePath ? (
<div className="text-sm text-foreground/70 font-semibold">
Path {index + 1}{' '}
<span className="font-normal text-muted-foreground">
received {formatTime(pathData.received_at)}
</span>
</div>
) : (
<div />
)}
<PathVisualization
resolved={pathData.resolved}
senderInfo={senderInfo}
distanceUnit={distanceUnit}
/>
<button
onClick={() => setMapModalIndex(index)}
className="text-xs text-primary hover:underline cursor-pointer shrink-0 ml-2"
>
Map route
</button>
</div>
);
})}
<PathVisualization
resolved={pathData.resolved}
senderInfo={senderInfo}
distanceUnit={distanceUnit}
/>
</div>
))}
{/* Map modal — opens when a "Map route" button is clicked */}
<Dialog
open={mapModalIndex !== null}
onOpenChange={(open) => !open && setMapModalIndex(null)}
>
<DialogContent className="sm:max-w-2xl">
<DialogHeader>
<DialogTitle>
{mapModalIndex !== null && !hasSinglePath
? `Path ${mapModalIndex + 1} Route Map`
: 'Route Map'}
</DialogTitle>
<DialogDescription>
Map of known node locations along this message route.
</DialogDescription>
</DialogHeader>
{mapModalIndex !== null && (
<Suspense
fallback={
<div
className="rounded border border-border bg-muted/30 animate-pulse"
style={{ height: 400 }}
/>
}
>
<PathRouteMap
resolved={resolvedPaths[mapModalIndex].resolved}
senderInfo={senderInfo}
height={400}
/>
</Suspense>
)}
</DialogContent>
</Dialog>
</div>
)}

View File

@@ -8,6 +8,7 @@ import type { ResolvedPath, SenderInfo } from '../utils/pathUtils';
interface PathRouteMapProps {
resolved: ResolvedPath;
senderInfo: SenderInfo;
height?: number;
}
// Colors for hop markers (indexed by hop number - 1)
@@ -82,7 +83,7 @@ function RouteMapBounds({ points }: { points: [number, number][] }) {
return null;
}
export function PathRouteMap({ resolved, senderInfo }: PathRouteMapProps) {
export function PathRouteMap({ resolved, senderInfo, height = 220 }: PathRouteMapProps) {
const points = collectPoints(resolved);
const hasAnyGps = points.length > 0;
@@ -117,7 +118,7 @@ export function PathRouteMap({ resolved, senderInfo }: PathRouteMapProps) {
className="rounded border border-border overflow-hidden"
role="img"
aria-label="Map showing message route between nodes"
style={{ height: 220 }}
style={{ height }}
>
<MapContainer
center={center}
@@ -138,6 +139,8 @@ export function PathRouteMap({ resolved, senderInfo }: PathRouteMapProps) {
icon={makeIcon('S', SENDER_COLOR)}
>
<Tooltip direction="top" offset={[0, -14]}>
<span className="font-mono">{resolved.sender.prefix}</span>
{' · '}
{senderInfo.name || 'Sender'}
</Tooltip>
</Marker>
@@ -154,6 +157,8 @@ export function PathRouteMap({ resolved, senderInfo }: PathRouteMapProps) {
icon={makeIcon(String(hopIdx + 1), getHopColor(hopIdx))}
>
<Tooltip direction="top" offset={[0, -14]}>
<span className="font-mono">{hop.prefix}</span>
{' · '}
{m.name || m.public_key.slice(0, 12)}
</Tooltip>
</Marker>
@@ -167,6 +172,8 @@ export function PathRouteMap({ resolved, senderInfo }: PathRouteMapProps) {
icon={makeIcon('R', RECEIVER_COLOR)}
>
<Tooltip direction="top" offset={[0, -14]}>
<span className="font-mono">{resolved.receiver.prefix}</span>
{' · '}
{resolved.receiver.name || 'Receiver'}
</Tooltip>
</Marker>

View File

@@ -161,7 +161,7 @@ function buildGroupTextResolutionCandidates(channels: Channel[]): GroupTextResol
}));
}
function resolveGroupTextRoomName(
function resolveGroupTextChannelName(
payload: {
channelHash?: string;
cipherMac?: string;
@@ -211,15 +211,15 @@ function getPacketContext(
groupTextCandidates: GroupTextResolutionCandidate[]
) {
const fallbackSender = packet.decrypted_info?.sender ?? null;
const fallbackRoom = packet.decrypted_info?.channel_name ?? null;
const fallbackChannel = packet.decrypted_info?.channel_name ?? null;
if (!inspection.decoded?.payload.decoded) {
if (!fallbackSender && !fallbackRoom) {
if (!fallbackSender && !fallbackChannel) {
return null;
}
return {
title: fallbackRoom ? 'Room' : 'Context',
primary: fallbackRoom ?? 'Sender metadata available',
title: fallbackChannel ? 'Channel' : 'Context',
primary: fallbackChannel ?? 'Sender metadata available',
secondary: fallbackSender ? `Sender: ${fallbackSender}` : null,
};
}
@@ -231,11 +231,12 @@ function getPacketContext(
ciphertext?: string;
decrypted?: { sender?: string; message?: string };
};
const roomName = fallbackRoom ?? resolveGroupTextRoomName(payload, groupTextCandidates);
const channelName =
fallbackChannel ?? resolveGroupTextChannelName(payload, groupTextCandidates);
return {
title: roomName ? 'Room' : 'Channel',
title: 'Channel',
primary:
roomName ?? (payload.channelHash ? `Channel hash ${payload.channelHash}` : 'GroupText'),
channelName ?? (payload.channelHash ? `Channel hash ${payload.channelHash}` : 'GroupText'),
secondary: payload.decrypted?.sender
? `Sender: ${payload.decrypted.sender}`
: fallbackSender
@@ -783,7 +784,7 @@ export function RawPacketInspectorDialog({
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="flex h-[92vh] max-w-[min(96vw,82rem)] flex-col gap-0 overflow-hidden p-0">
<DialogContent className="flex h-[92dvh] max-w-[min(96vw,82rem)] flex-col gap-0 overflow-hidden p-0">
<DialogHeader className="border-b border-border px-5 py-3">
<DialogTitle>{title}</DialogTitle>
<DialogDescription className="sr-only">{description}</DialogDescription>

View File

@@ -69,7 +69,7 @@ export function SecurityWarningModal({ health }: SecurityWarningModalProps) {
<Dialog open>
<DialogContent
hideCloseButton
className="top-3 w-[calc(100vw-1rem)] max-w-[42rem] translate-y-0 gap-5 overflow-y-auto px-4 py-5 max-h-[calc(100vh-1.5rem)] sm:top-[50%] sm:w-full sm:max-h-[min(90vh,48rem)] sm:translate-y-[-50%] sm:px-6"
className="w-[calc(100vw-1rem)] max-w-[42rem] gap-5 overflow-y-auto px-4 py-5 max-h-[calc(100dvh-2rem)] sm:w-full sm:max-h-[min(85dvh,48rem)] sm:px-6"
onEscapeKeyDown={(event) => event.preventDefault()}
onInteractOutside={(event) => event.preventDefault()}
>
@@ -88,12 +88,12 @@ export function SecurityWarningModal({ health }: SecurityWarningModalProps) {
<div className="space-y-3 break-words text-sm leading-6 text-muted-foreground">
<DialogDescription>
Bots are enabled, and app-wide Basic Auth is not configured.
Bots are not disabled, and app-wide Basic Auth is not configured.
</DialogDescription>
<p>
Without one of those protections, or another access-control layer in front of
RemoteTerm, anyone on your local network who can reach this app can run Python code on
the computer hosting this instance.
the computer hosting this instance via the bot system.
</p>
<p className="font-semibold text-foreground">
This is only safe on protected or isolated networks with appropriate access control. If

View File

@@ -147,8 +147,8 @@ export function SettingsModal(props: SettingsModalProps) {
: 'mx-auto w-full max-w-[800px] space-y-4 border-t border-input p-4';
const settingsContainerClass = externalDesktopSidebarMode
? 'w-full h-full overflow-y-auto'
: 'w-full h-full overflow-y-auto space-y-3';
? 'w-full h-full min-w-0 overflow-x-hidden overflow-y-auto [contain:layout_paint]'
: 'w-full h-full min-w-0 overflow-x-hidden overflow-y-auto space-y-3 [contain:layout_paint]';
const sectionButtonClasses =
'w-full flex items-center justify-between px-4 py-3 text-left hover:bg-muted/40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset';

View File

@@ -748,7 +748,7 @@ export function Sidebar({
icon: <LockOpen className="h-4 w-4" />,
label: (
<>
{showCracker ? 'Hide' : 'Show'} Room Finder
{showCracker ? 'Hide' : 'Show'} Channel Finder
<span
className={cn(
'ml-1 text-[11px]',
@@ -844,7 +844,7 @@ export function Sidebar({
<div className="relative min-w-0 flex-1">
<Input
type="text"
placeholder="Search rooms/contacts..."
placeholder="Search channels/contacts..."
aria-label="Search conversations"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}

View File

@@ -1,8 +1,10 @@
import { useState, useEffect, useCallback, useRef, lazy, Suspense } from 'react';
import { useState, useEffect, useCallback, useMemo, useRef, lazy, Suspense } from 'react';
import { ChevronDown } from 'lucide-react';
import { Input } from '../ui/input';
import { Label } from '../ui/label';
import { Button } from '../ui/button';
import { Separator } from '../ui/separator';
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from '../ui/dialog';
import { toast } from '../ui/sonner';
import { cn } from '@/lib/utils';
import { api } from '../../api';
@@ -15,21 +17,12 @@ const BotCodeEditor = lazy(() =>
const TYPE_LABELS: Record<string, string> = {
mqtt_private: 'Private MQTT',
mqtt_community: 'Community MQTT',
bot: 'Bot',
bot: 'Python Bot',
webhook: 'Webhook',
apprise: 'Apprise',
sqs: 'Amazon SQS',
};
const LIST_TYPE_OPTIONS = [
{ value: 'mqtt_private', label: 'Private MQTT' },
{ value: 'mqtt_community', label: 'Community MQTT' },
{ value: 'bot', label: 'Bot' },
{ value: 'webhook', label: 'Webhook' },
{ value: 'apprise', label: 'Apprise' },
{ value: 'sqs', label: 'Amazon SQS' },
];
const DEFAULT_COMMUNITY_PACKET_TOPIC_TEMPLATE = 'meshcore/{IATA}/{PUBLIC_KEY}/packets';
const DEFAULT_COMMUNITY_BROKER_HOST = 'mqtt-us-v1.letsmesh.net';
const DEFAULT_COMMUNITY_BROKER_HOST_EU = 'mqtt-eu-v1.letsmesh.net';
@@ -42,30 +35,6 @@ const DEFAULT_MESHRANK_TRANSPORT = 'tcp';
const DEFAULT_MESHRANK_AUTH_MODE = 'none';
const DEFAULT_MESHRANK_IATA = 'XYZ';
const CREATE_TYPE_OPTIONS = [
{ value: 'mqtt_private', label: 'Private MQTT' },
{ value: 'mqtt_community_meshrank', label: 'MeshRank' },
{ value: 'mqtt_community_letsmesh_us', label: 'LetsMesh (US)' },
{ value: 'mqtt_community_letsmesh_eu', label: 'LetsMesh (EU)' },
{ value: 'mqtt_community', label: 'Community MQTT/meshcoretomqtt' },
{ value: 'bot', label: 'Bot' },
{ value: 'webhook', label: 'Webhook' },
{ value: 'apprise', label: 'Apprise' },
{ value: 'sqs', label: 'Amazon SQS' },
] as const;
type DraftType = (typeof CREATE_TYPE_OPTIONS)[number]['value'];
type DraftRecipe = {
savedType: string;
detailLabel: string;
defaultName: string;
defaults: {
config: Record<string, unknown>;
scope: Record<string, unknown>;
};
};
function createCommunityConfigDefaults(
overrides: Partial<Record<string, unknown>> = {}
): Record<string, unknown> {
@@ -122,11 +91,41 @@ const DEFAULT_BOT_CODE = `def bot(**kwargs) -> str | list[str] | None:
return "[BOT] Plong!"
return None`;
const DRAFT_RECIPES: Record<DraftType, DraftRecipe> = {
mqtt_private: {
type DraftType =
| 'mqtt_private'
| 'mqtt_community'
| 'mqtt_community_meshrank'
| 'mqtt_community_letsmesh_us'
| 'mqtt_community_letsmesh_eu'
| 'webhook'
| 'apprise'
| 'sqs'
| 'bot';
type CreateIntegrationDefinition = {
value: DraftType;
savedType: string;
label: string;
section: string;
description: string;
defaultName: string;
nameMode: 'counted' | 'fixed';
defaults: {
config: Record<string, unknown>;
scope: Record<string, unknown>;
};
};
const CREATE_INTEGRATION_DEFINITIONS: readonly CreateIntegrationDefinition[] = [
{
value: 'mqtt_private',
savedType: 'mqtt_private',
detailLabel: 'Private MQTT',
label: 'Private MQTT',
section: 'Bulk Forwarding',
description:
'Customizable-scope forwarding of all or some messages to an MQTT broker of your choosing, in raw and/or decrypted form.',
defaultName: 'Private MQTT',
nameMode: 'counted',
defaults: {
config: {
broker_host: '',
@@ -140,10 +139,29 @@ const DRAFT_RECIPES: Record<DraftType, DraftRecipe> = {
scope: { messages: 'all', raw_packets: 'all' },
},
},
mqtt_community_meshrank: {
{
value: 'mqtt_community',
savedType: 'mqtt_community',
detailLabel: 'MeshRank',
label: 'Community MQTT/meshcoretomqtt',
section: 'Community MQTT',
description:
'MeshcoreToMQTT-compatible raw-packet feed publishing, compatible with community aggregators (in other words, make your companion radio also serve as an observer node). Superset of other Community MQTT presets.',
defaultName: 'Community MQTT',
nameMode: 'counted',
defaults: {
config: createCommunityConfigDefaults(),
scope: { messages: 'none', raw_packets: 'all' },
},
},
{
value: 'mqtt_community_meshrank',
savedType: 'mqtt_community',
label: 'MeshRank',
section: 'Community MQTT',
description:
'A community MQTT config preconfigured for MeshRank, requiring only the provided topic from your MeshRank configuration. A subset of the primary Community MQTT/meshcoretomqtt configuration; you are free to edit all configuration after creation.',
defaultName: 'MeshRank',
nameMode: 'fixed',
defaults: {
config: createCommunityConfigDefaults({
broker_host: DEFAULT_MESHRANK_BROKER_HOST,
@@ -158,10 +176,15 @@ const DRAFT_RECIPES: Record<DraftType, DraftRecipe> = {
scope: { messages: 'none', raw_packets: 'all' },
},
},
mqtt_community_letsmesh_us: {
{
value: 'mqtt_community_letsmesh_us',
savedType: 'mqtt_community',
detailLabel: 'LetsMesh (US)',
label: 'LetsMesh (US)',
section: 'Community MQTT',
description:
'A community MQTT config preconfigured for the LetsMesh US-ingest endpoint, requiring only your email and IATA region code. Good to use with an additional EU configuration for redundancy. A subset of the primary Community MQTT/meshcoretomqtt configuration; you are free to edit all configuration after creation.',
defaultName: 'LetsMesh (US)',
nameMode: 'fixed',
defaults: {
config: createCommunityConfigDefaults({
broker_host: DEFAULT_COMMUNITY_BROKER_HOST,
@@ -170,10 +193,15 @@ const DRAFT_RECIPES: Record<DraftType, DraftRecipe> = {
scope: { messages: 'none', raw_packets: 'all' },
},
},
mqtt_community_letsmesh_eu: {
{
value: 'mqtt_community_letsmesh_eu',
savedType: 'mqtt_community',
detailLabel: 'LetsMesh (EU)',
label: 'LetsMesh (EU)',
section: 'Community MQTT',
description:
'A community MQTT config preconfigured for the LetsMesh EU-ingest endpoint, requiring only your email and IATA region code. Good to use with an additional US configuration for redundancy. A subset of the primary Community MQTT/meshcoretomqtt configuration; you are free to edit all configuration after creation.',
defaultName: 'LetsMesh (EU)',
nameMode: 'fixed',
defaults: {
config: createCommunityConfigDefaults({
broker_host: DEFAULT_COMMUNITY_BROKER_HOST_EU,
@@ -182,30 +210,15 @@ const DRAFT_RECIPES: Record<DraftType, DraftRecipe> = {
scope: { messages: 'none', raw_packets: 'all' },
},
},
mqtt_community: {
savedType: 'mqtt_community',
detailLabel: 'Community MQTT/meshcoretomqtt',
defaultName: 'Community MQTT',
defaults: {
config: createCommunityConfigDefaults(),
scope: { messages: 'none', raw_packets: 'all' },
},
},
bot: {
savedType: 'bot',
detailLabel: 'Bot',
defaultName: 'Bot',
defaults: {
config: {
code: DEFAULT_BOT_CODE,
},
scope: { messages: 'all', raw_packets: 'none' },
},
},
webhook: {
{
value: 'webhook',
savedType: 'webhook',
detailLabel: 'Webhook',
label: 'Webhook',
section: 'Automation',
description:
'Generic webhook for decrypted channel/DM messages with customizable verb, method, and optional HMAC signature.',
defaultName: 'Webhook',
nameMode: 'counted',
defaults: {
config: {
url: '',
@@ -217,10 +230,15 @@ const DRAFT_RECIPES: Record<DraftType, DraftRecipe> = {
scope: { messages: 'all', raw_packets: 'none' },
},
},
apprise: {
{
value: 'apprise',
savedType: 'apprise',
detailLabel: 'Apprise',
label: 'Apprise',
section: 'Automation',
description:
'A wide-ranging generic fanout, capable of forwarding decrypted channel/DM messages to Discord, Telegram, email, SMS, and many others.',
defaultName: 'Apprise',
nameMode: 'counted',
defaults: {
config: {
urls: '',
@@ -230,10 +248,14 @@ const DRAFT_RECIPES: Record<DraftType, DraftRecipe> = {
scope: { messages: 'all', raw_packets: 'none' },
},
},
sqs: {
{
value: 'sqs',
savedType: 'sqs',
detailLabel: 'Amazon SQS',
label: 'Amazon SQS',
section: 'Bulk Forwarding',
description: 'Send full or scope-customized raw or decrypted packets to an SQS',
defaultName: 'Amazon SQS',
nameMode: 'counted',
defaults: {
config: {
queue_url: '',
@@ -246,15 +268,41 @@ const DRAFT_RECIPES: Record<DraftType, DraftRecipe> = {
scope: { messages: 'all', raw_packets: 'none' },
},
},
};
{
value: 'bot',
savedType: 'bot',
label: 'Python Bot',
section: 'Automation',
description:
'A simple, Python-based interface for basic bots that can respond to DM and channel messages.',
defaultName: 'Bot',
nameMode: 'counted',
defaults: {
config: {
code: DEFAULT_BOT_CODE,
},
scope: { messages: 'all', raw_packets: 'none' },
},
},
];
const CREATE_INTEGRATION_DEFINITIONS_BY_VALUE = Object.fromEntries(
CREATE_INTEGRATION_DEFINITIONS.map((definition) => [definition.value, definition])
) as Record<DraftType, CreateIntegrationDefinition>;
function isDraftType(value: string): value is DraftType {
return value in DRAFT_RECIPES;
return value in CREATE_INTEGRATION_DEFINITIONS_BY_VALUE;
}
function getCreateIntegrationDefinition(draftType: DraftType) {
return CREATE_INTEGRATION_DEFINITIONS_BY_VALUE[draftType];
}
function normalizeDraftName(draftType: DraftType, name: string, configs: FanoutConfig[]) {
const recipe = DRAFT_RECIPES[draftType];
return name || getDefaultIntegrationName(recipe.savedType, configs);
const definition = getCreateIntegrationDefinition(draftType);
if (name) return name;
if (definition.nameMode === 'fixed') return definition.defaultName;
return getDefaultIntegrationName(definition.savedType, configs);
}
function normalizeDraftConfig(draftType: DraftType, config: Record<string, unknown>) {
@@ -305,22 +353,160 @@ function normalizeDraftConfig(draftType: DraftType, config: Record<string, unkno
}
function normalizeDraftScope(draftType: DraftType, scope: Record<string, unknown>) {
if (draftType.startsWith('mqtt_community_')) {
if (getCreateIntegrationDefinition(draftType).savedType === 'mqtt_community') {
return { messages: 'none', raw_packets: 'all' };
}
return scope;
}
function cloneDraftDefaults(draftType: DraftType) {
const recipe = DRAFT_RECIPES[draftType];
const recipe = getCreateIntegrationDefinition(draftType);
return {
config: structuredClone(recipe.defaults.config),
scope: structuredClone(recipe.defaults.scope),
};
}
function CreateIntegrationDialog({
open,
options,
selectedType,
onOpenChange,
onSelect,
onCreate,
}: {
open: boolean;
options: readonly CreateIntegrationDefinition[];
selectedType: DraftType | null;
onOpenChange: (open: boolean) => void;
onSelect: (type: DraftType) => void;
onCreate: () => void;
}) {
const selectedOption =
options.find((option) => option.value === selectedType) ?? options[0] ?? null;
const listRef = useRef<HTMLDivElement | null>(null);
const [showScrollHint, setShowScrollHint] = useState(false);
const updateScrollHint = useCallback(() => {
const container = listRef.current;
if (!container) {
setShowScrollHint(false);
return;
}
setShowScrollHint(container.scrollTop + container.clientHeight < container.scrollHeight - 8);
}, []);
useEffect(() => {
if (!open) return;
const frame = window.requestAnimationFrame(updateScrollHint);
window.addEventListener('resize', updateScrollHint);
return () => {
window.cancelAnimationFrame(frame);
window.removeEventListener('resize', updateScrollHint);
};
}, [open, options, updateScrollHint]);
const sectionedOptions = [...new Set(options.map((o) => o.section))]
.map((section) => ({
section,
options: options.filter((option) => option.section === section),
}))
.filter((group) => group.options.length > 0);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent
aria-describedby={undefined}
hideCloseButton
className="flex max-h-[calc(100dvh-2rem)] w-[96vw] max-w-[960px] flex-col overflow-hidden p-0 sm:rounded-xl"
>
<DialogHeader className="border-b border-border px-5 py-4">
<DialogTitle>Create Integration</DialogTitle>
</DialogHeader>
<div className="grid min-h-0 flex-1 grid-cols-1 overflow-hidden md:grid-cols-[240px_minmax(0,1fr)]">
<div className="relative border-b border-border bg-muted/20 md:border-b-0 md:border-r">
<div
ref={listRef}
onScroll={updateScrollHint}
className="max-h-56 overflow-y-auto p-2 md:max-h-[420px]"
>
<div className="space-y-4">
{sectionedOptions.map((group) => (
<div key={group.section} className="space-y-1.5">
<div className="px-2 text-[11px] font-semibold uppercase tracking-[0.12em] text-muted-foreground">
{group.section}
</div>
{group.options.map((option) => {
const selected = option.value === selectedOption?.value;
return (
<button
key={option.value}
type="button"
className={cn(
'w-full rounded-md border px-3 py-2 text-left transition-colors',
selected
? 'border-primary bg-accent text-foreground'
: 'border-transparent bg-transparent hover:bg-accent/70'
)}
aria-pressed={selected}
onClick={() => onSelect(option.value)}
>
<div className="text-sm font-medium">{option.label}</div>
</button>
);
})}
</div>
))}
</div>
</div>
{showScrollHint && (
<div className="pointer-events-none absolute inset-x-0 bottom-0 flex justify-center bg-gradient-to-t from-background via-background/85 to-transparent px-4 pb-2 pt-8">
<div className="rounded-full border border-border/80 bg-background/95 px-2 py-1 text-muted-foreground shadow-sm">
<ChevronDown className="h-4 w-4" aria-hidden="true" />
</div>
</div>
)}
</div>
<div className="min-h-0 space-y-4 overflow-y-auto px-5 py-5 md:min-h-[280px] md:max-h-[420px]">
{selectedOption ? (
<>
<div className="space-y-1.5">
<div className="text-xs font-semibold uppercase tracking-[0.12em] text-muted-foreground">
{selectedOption.section}
</div>
<h3 className="text-lg font-semibold">{selectedOption.label}</h3>
</div>
<p className="text-sm leading-6 text-muted-foreground">
{selectedOption.description}
</p>
</>
) : (
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
No integration types are currently available.
</div>
)}
</div>
</div>
<DialogFooter className="gap-2 border-t border-border px-5 py-4 sm:justify-end">
<Button variant="outline" onClick={() => onOpenChange(false)}>
Close
</Button>
<Button onClick={onCreate} disabled={!selectedOption}>
Create
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
function getDetailTypeLabel(detailType: string) {
if (isDraftType(detailType)) return DRAFT_RECIPES[detailType].detailLabel;
if (isDraftType(detailType)) return getCreateIntegrationDefinition(detailType).label;
return TYPE_LABELS[detailType] || detailType;
}
@@ -1499,9 +1685,9 @@ export function SettingsFanoutSection({
const [editName, setEditName] = useState('');
const [inlineEditingId, setInlineEditingId] = useState<string | null>(null);
const [inlineEditName, setInlineEditName] = useState('');
const [addMenuOpen, setAddMenuOpen] = useState(false);
const [createDialogOpen, setCreateDialogOpen] = useState(false);
const [selectedCreateType, setSelectedCreateType] = useState<DraftType | null>(null);
const [busy, setBusy] = useState(false);
const addMenuRef = useRef<HTMLDivElement | null>(null);
const loadConfigs = useCallback(async () => {
try {
@@ -1516,18 +1702,28 @@ export function SettingsFanoutSection({
loadConfigs();
}, [loadConfigs]);
const availableCreateOptions = useMemo(
() =>
CREATE_INTEGRATION_DEFINITIONS.filter(
(definition) => definition.savedType !== 'bot' || !health?.bots_disabled
),
[health?.bots_disabled]
);
useEffect(() => {
if (!addMenuOpen) return;
const handlePointerDown = (event: MouseEvent) => {
if (!addMenuRef.current?.contains(event.target as Node)) {
setAddMenuOpen(false);
}
};
document.addEventListener('mousedown', handlePointerDown);
return () => document.removeEventListener('mousedown', handlePointerDown);
}, [addMenuOpen]);
if (!createDialogOpen) return;
if (availableCreateOptions.length === 0) {
setSelectedCreateType(null);
return;
}
if (
selectedCreateType &&
availableCreateOptions.some((option) => option.value === selectedCreateType)
) {
return;
}
setSelectedCreateType(availableCreateOptions[0].value);
}, [createDialogOpen, availableCreateOptions, selectedCreateType]);
const handleToggleEnabled = async (cfg: FanoutConfig) => {
try {
@@ -1541,7 +1737,7 @@ export function SettingsFanoutSection({
};
const handleEdit = (cfg: FanoutConfig) => {
setAddMenuOpen(false);
setCreateDialogOpen(false);
setInlineEditingId(null);
setInlineEditName('');
setDraftType(null);
@@ -1552,7 +1748,7 @@ export function SettingsFanoutSection({
};
const handleStartInlineEdit = (cfg: FanoutConfig) => {
setAddMenuOpen(false);
setCreateDialogOpen(false);
setInlineEditingId(cfg.id);
setInlineEditName(cfg.name);
};
@@ -1611,7 +1807,7 @@ export function SettingsFanoutSection({
setBusy(true);
try {
if (currentDraftType) {
const recipe = DRAFT_RECIPES[currentDraftType];
const recipe = getCreateIntegrationDefinition(currentDraftType);
await api.createFanoutConfig({
type: recipe.savedType,
name: normalizeDraftName(currentDraftType, editName.trim(), configs),
@@ -1663,18 +1859,16 @@ export function SettingsFanoutSection({
}
};
const handleAddCreate = async (type: string) => {
if (!isDraftType(type)) return;
const handleAddCreate = (type: DraftType) => {
const definition = getCreateIntegrationDefinition(type);
const defaults = cloneDraftDefaults(type);
setAddMenuOpen(false);
setCreateDialogOpen(false);
setEditingId(null);
setDraftType(type);
setEditName(
type === 'mqtt_community_meshrank' ||
type === 'mqtt_community_letsmesh_us' ||
type === 'mqtt_community_letsmesh_eu'
? DRAFT_RECIPES[type].defaultName
: getDefaultIntegrationName(DRAFT_RECIPES[type].savedType, configs)
definition.nameMode === 'fixed'
? definition.defaultName
: getDefaultIntegrationName(definition.savedType, configs)
);
setEditConfig(defaults.config);
setEditScope(defaults.scope);
@@ -1683,13 +1877,15 @@ export function SettingsFanoutSection({
const editingConfig = editingId ? configs.find((c) => c.id === editingId) : null;
const detailType = draftType ?? editingConfig?.type ?? null;
const isDraft = draftType !== null;
const configGroups = LIST_TYPE_OPTIONS.map((opt) => ({
type: opt.value,
label: opt.label,
configs: configs
.filter((cfg) => cfg.type === opt.value)
.sort((a, b) => a.name.localeCompare(b.name, undefined, { sensitivity: 'base' })),
})).filter((group) => group.configs.length > 0);
const configGroups = Object.entries(TYPE_LABELS)
.map(([type, label]) => ({
type,
label,
configs: configs
.filter((cfg) => cfg.type === type)
.sort((a, b) => a.name.localeCompare(b.name, undefined, { sensitivity: 'base' })),
}))
.filter((group) => group.configs.length > 0);
// Detail view
if (detailType) {
@@ -1823,37 +2019,22 @@ export function SettingsFanoutSection({
</div>
)}
<div className="relative inline-block" ref={addMenuRef}>
<Button
type="button"
size="sm"
aria-haspopup="menu"
aria-expanded={addMenuOpen}
onClick={() => setAddMenuOpen((open) => !open)}
>
Add Integration
</Button>
{addMenuOpen && (
<div
role="menu"
className="absolute left-0 top-full z-10 mt-2 min-w-72 rounded-md border border-input bg-background p-1 shadow-md"
>
{CREATE_TYPE_OPTIONS.filter((opt) => opt.value !== 'bot' || !health?.bots_disabled).map(
(opt) => (
<button
key={opt.value}
type="button"
role="menuitem"
className="flex w-full rounded-sm px-3 py-2 text-left text-sm hover:bg-muted"
onClick={() => handleAddCreate(opt.value)}
>
{opt.label}
</button>
)
)}
</div>
)}
</div>
<Button type="button" size="sm" onClick={() => setCreateDialogOpen(true)}>
Add Integration
</Button>
<CreateIntegrationDialog
open={createDialogOpen}
options={availableCreateOptions}
selectedType={selectedCreateType}
onOpenChange={setCreateDialogOpen}
onSelect={setSelectedCreateType}
onCreate={() => {
if (selectedCreateType) {
handleAddCreate(selectedCreateType);
}
}}
/>
{configGroups.length > 0 && (
<div className="columns-1 gap-4 md:columns-2">

View File

@@ -10,4 +10,5 @@ export { useRealtimeAppState } from './useRealtimeAppState';
export { useConversationActions } from './useConversationActions';
export { useConversationNavigation } from './useConversationNavigation';
export { useBrowserNotifications } from './useBrowserNotifications';
export { useFaviconBadge, useUnreadTitle } from './useFaviconBadge';
export { useRawPacketStatsSession } from './useRawPacketStatsSession';

View File

@@ -9,6 +9,17 @@ const NOTIFICATION_ICON_PATH = '/favicon-256x256.png';
type NotificationPermissionState = NotificationPermission | 'unsupported';
type ConversationNotificationMap = Record<string, boolean>;
interface NotificationEnableToastInfo {
level: 'success' | 'warning';
title: string;
description?: string;
}
interface NotificationEnvironment {
protocol: string;
isSecureContext: boolean;
}
function getConversationNotificationKey(type: 'channel' | 'contact', id: string): string {
return getStateKey(type, id);
}
@@ -92,6 +103,40 @@ function buildMessageNotificationHash(message: Message): string | null {
return null;
}
export function getNotificationEnableToastInfo(
environment?: Partial<NotificationEnvironment>
): NotificationEnableToastInfo {
if (typeof window === 'undefined') {
return { level: 'success', title: 'Notifications enabled' };
}
const protocol = environment?.protocol ?? window.location.protocol;
const isSecureContext = environment?.isSecureContext ?? window.isSecureContext;
if (protocol === 'http:') {
return {
level: 'warning',
title: 'Notifications enabled with warning',
description:
'Desktop notifications are on for this conversation, but you are using HTTP instead of HTTPS. Notifications will likely not work reliably.',
};
}
// Best-effort heuristic only. Browsers do not expose certificate trust details
// directly to page JS, so an HTTPS page that is not a secure context is the
// closest signal we have for an untrusted/self-signed setup.
if (protocol === 'https:' && !isSecureContext) {
return {
level: 'warning',
title: 'Notifications enabled with warning',
description:
'Desktop notifications are on for this conversation, but your HTTPS connection is untrusted, such as a self-signed certificate. Notification delivery may be inconsistent depending on your browser.',
};
}
return { level: 'success', title: 'Notifications enabled' };
}
export function useBrowserNotifications() {
const [permission, setPermission] = useState<NotificationPermissionState>(getInitialPermission);
const [enabledByConversation, setEnabledByConversation] =
@@ -110,8 +155,6 @@ export function useBrowserNotifications() {
const toggleConversationNotifications = useCallback(
async (type: 'channel' | 'contact', id: string, label: string) => {
const blockedDescription =
'Allow notifications in your browser settings, then try again. Some browsers may refuse notifications on non-HTTPS or self-signed HTTPS origins. Check your browser documentation for how to trust an insecure origin and the associated risks before doing so.';
const conversationKey = getConversationNotificationKey(type, id);
if (enabledByConversation[conversationKey]) {
setEnabledByConversation((prev) => {
@@ -120,20 +163,23 @@ export function useBrowserNotifications() {
writeStoredEnabledMap(next);
return next;
});
toast.success(`${label} notifications disabled`);
toast.success('Notifications disabled', {
description: `Desktop notifications are off for ${label}.`,
});
return;
}
if (permission === 'unsupported') {
toast.error('Browser notifications unavailable', {
toast.error('Notifications unavailable', {
description: 'This browser does not support desktop notifications.',
});
return;
}
if (permission === 'denied') {
toast.error('Browser notifications blocked', {
description: blockedDescription,
toast.error('Notifications blocked', {
description:
'Desktop notifications are blocked by your browser. Allow notifications in browser settings, then try again. Non-HTTPS or untrusted HTTPS origins may also prevent notifications from working reliably.',
});
return;
}
@@ -155,13 +201,24 @@ export function useBrowserNotifications() {
icon: NOTIFICATION_ICON_PATH,
tag: `meshcore-notification-preview-${conversationKey}`,
});
toast.success(`${label} notifications enabled`);
const toastInfo = getNotificationEnableToastInfo();
if (toastInfo.level === 'warning') {
toast.warning(toastInfo.title, {
description: toastInfo.description,
});
} else {
toast.success(toastInfo.title, {
description: `Desktop notifications are on for ${label}.`,
});
}
return;
}
toast.error('Browser notifications not enabled', {
toast.error('Notifications not enabled', {
description:
nextPermission === 'denied' ? blockedDescription : 'Permission request was dismissed.',
nextPermission === 'denied'
? 'Desktop notifications were denied by your browser. Allow notifications in browser settings, then try again.'
: 'The browser permission request was dismissed.',
});
},
[enabledByConversation, permission]

View File

@@ -0,0 +1,196 @@
import { useEffect, useMemo, useRef } from 'react';
import type { Favorite } from '../types';
import { getStateKey } from '../utils/conversationState';
const APP_TITLE = 'RemoteTerm for MeshCore';
const UNREAD_APP_TITLE = 'RemoteTerm';
const BASE_FAVICON_PATH = '/favicon.svg';
const GREEN_BADGE_FILL = '#16a34a';
const RED_BADGE_FILL = '#dc2626';
const BADGE_CENTER = 750;
const BADGE_OUTER_RADIUS = 220;
const BADGE_INNER_RADIUS = 180;
let baseFaviconSvgPromise: Promise<string> | null = null;
export type FaviconBadgeState = 'none' | 'green' | 'red';
function getUnreadDirectMessageCount(unreadCounts: Record<string, number>): number {
return Object.entries(unreadCounts).reduce(
(sum, [stateKey, count]) => sum + (stateKey.startsWith('contact-') ? count : 0),
0
);
}
function getUnreadFavoriteChannelCount(
unreadCounts: Record<string, number>,
favorites: Favorite[]
): number {
return favorites.reduce(
(sum, favorite) =>
sum +
(favorite.type === 'channel' ? unreadCounts[getStateKey('channel', favorite.id)] || 0 : 0),
0
);
}
export function getTotalUnreadCount(unreadCounts: Record<string, number>): number {
return Object.values(unreadCounts).reduce((sum, count) => sum + count, 0);
}
export function getFavoriteUnreadCount(
unreadCounts: Record<string, number>,
favorites: Favorite[]
): number {
return favorites.reduce((sum, favorite) => {
const stateKey = getStateKey(favorite.type, favorite.id);
return sum + (unreadCounts[stateKey] || 0);
}, 0);
}
export function getUnreadTitle(
unreadCounts: Record<string, number>,
favorites: Favorite[]
): string {
const unreadCount = getFavoriteUnreadCount(unreadCounts, favorites);
if (unreadCount <= 0) {
return APP_TITLE;
}
const label = unreadCount > 99 ? '99+' : String(unreadCount);
return `(${label}) ${UNREAD_APP_TITLE}`;
}
export function deriveFaviconBadgeState(
unreadCounts: Record<string, number>,
mentions: Record<string, boolean>,
favorites: Favorite[]
): FaviconBadgeState {
if (Object.values(mentions).some(Boolean) || getUnreadDirectMessageCount(unreadCounts) > 0) {
return 'red';
}
if (getUnreadFavoriteChannelCount(unreadCounts, favorites) > 0) {
return 'green';
}
return 'none';
}
export function buildBadgedFaviconSvg(baseSvg: string, badgeFill: string): string {
const closingTagIndex = baseSvg.lastIndexOf('</svg>');
if (closingTagIndex === -1) {
return baseSvg;
}
const badge = `
<circle cx="${BADGE_CENTER}" cy="${BADGE_CENTER}" r="${BADGE_OUTER_RADIUS}" fill="#ffffff"/>
<circle cx="${BADGE_CENTER}" cy="${BADGE_CENTER}" r="${BADGE_INNER_RADIUS}" fill="${badgeFill}"/>
`;
return `${baseSvg.slice(0, closingTagIndex)}${badge}</svg>`;
}
async function loadBaseFaviconSvg(): Promise<string> {
if (!baseFaviconSvgPromise) {
baseFaviconSvgPromise = fetch(BASE_FAVICON_PATH, { cache: 'force-cache' })
.then(async (response) => {
if (!response.ok) {
throw new Error(`Failed to load favicon SVG: ${response.status}`);
}
return response.text();
})
.catch((error) => {
baseFaviconSvgPromise = null;
throw error;
});
}
return baseFaviconSvgPromise;
}
function upsertFaviconLinks(rel: 'icon' | 'shortcut icon', href: string): void {
const links = Array.from(document.head.querySelectorAll<HTMLLinkElement>(`link[rel="${rel}"]`));
const targets = links.length > 0 ? links : [document.createElement('link')];
for (const link of targets) {
if (!link.parentNode) {
link.rel = rel;
document.head.appendChild(link);
}
link.type = 'image/svg+xml';
link.href = href;
}
}
function applyFaviconHref(href: string): void {
upsertFaviconLinks('icon', href);
upsertFaviconLinks('shortcut icon', href);
}
export function useUnreadTitle(unreadCounts: Record<string, number>, favorites: Favorite[]): void {
const title = useMemo(() => getUnreadTitle(unreadCounts, favorites), [favorites, unreadCounts]);
useEffect(() => {
document.title = title;
return () => {
document.title = APP_TITLE;
};
}, [title]);
}
export function useFaviconBadge(
unreadCounts: Record<string, number>,
mentions: Record<string, boolean>,
favorites: Favorite[]
): void {
const objectUrlRef = useRef<string | null>(null);
const badgeState = useMemo(
() => deriveFaviconBadgeState(unreadCounts, mentions, favorites),
[favorites, mentions, unreadCounts]
);
useEffect(() => {
if (objectUrlRef.current) {
URL.revokeObjectURL(objectUrlRef.current);
objectUrlRef.current = null;
}
if (badgeState === 'none') {
applyFaviconHref(BASE_FAVICON_PATH);
return;
}
const badgeFill = badgeState === 'red' ? RED_BADGE_FILL : GREEN_BADGE_FILL;
let cancelled = false;
void loadBaseFaviconSvg()
.then((baseSvg) => {
if (cancelled) {
return;
}
const objectUrl = URL.createObjectURL(
new Blob([buildBadgedFaviconSvg(baseSvg, badgeFill)], {
type: 'image/svg+xml',
})
);
objectUrlRef.current = objectUrl;
applyFaviconHref(objectUrl);
})
.catch(() => {
if (!cancelled) {
applyFaviconHref(BASE_FAVICON_PATH);
}
});
return () => {
cancelled = true;
if (objectUrlRef.current) {
URL.revokeObjectURL(objectUrlRef.current);
objectUrlRef.current = null;
}
};
}, [badgeState]);
}

View File

@@ -150,7 +150,7 @@ describe('ContactInfoPane', () => {
});
});
it('loads name-only channel stats and most active rooms', async () => {
it('loads name-only channel stats and most active channels', async () => {
getContactAnalytics.mockResolvedValue(
createAnalytics(null, {
lookup_type: 'name',
@@ -188,7 +188,7 @@ describe('ContactInfoPane', () => {
expect(screen.getByText('Name First In Use')).toBeInTheDocument();
expect(screen.getByText('Messages Per Hour')).toBeInTheDocument();
expect(screen.getByText('Messages Per Week')).toBeInTheDocument();
expect(screen.getByText('Most Active Rooms')).toBeInTheDocument();
expect(screen.getByText('Most Active Channels')).toBeInTheDocument();
expect(screen.getByText('#ops')).toBeInTheDocument();
expect(
screen.getByText(/Name-only analytics include channel messages only/i)

View File

@@ -67,6 +67,29 @@ function renderSectionWithRefresh(
);
}
function startsWithAccessibleName(name: string) {
const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
return new RegExp(`^${escaped}(?:\\s|$)`);
}
async function openCreateIntegrationDialog() {
await waitFor(() => {
expect(screen.getByRole('button', { name: 'Add Integration' })).toBeInTheDocument();
});
fireEvent.click(screen.getByRole('button', { name: 'Add Integration' }));
return screen.findByRole('dialog', { name: 'Create Integration' });
}
function selectCreateIntegration(name: string) {
const dialog = screen.getByRole('dialog', { name: 'Create Integration' });
fireEvent.click(within(dialog).getByRole('button', { name: startsWithAccessibleName(name) }));
}
function confirmCreateIntegration() {
const dialog = screen.getByRole('dialog', { name: 'Create Integration' });
fireEvent.click(within(dialog).getByRole('button', { name: 'Create' }));
}
beforeEach(() => {
vi.clearAllMocks();
vi.spyOn(window, 'confirm').mockReturnValue(true);
@@ -76,35 +99,64 @@ beforeEach(() => {
});
describe('SettingsFanoutSection', () => {
it('shows add integration menu with all integration types', async () => {
it('shows add integration dialog with all integration types', async () => {
renderSection();
await waitFor(() => {
expect(screen.getByRole('button', { name: 'Add Integration' })).toBeInTheDocument();
});
const dialog = await openCreateIntegrationDialog();
fireEvent.click(screen.getByRole('button', { name: 'Add Integration' }));
expect(screen.getByRole('menuitem', { name: 'Private MQTT' })).toBeInTheDocument();
expect(screen.getByRole('menuitem', { name: 'MeshRank' })).toBeInTheDocument();
expect(screen.getByRole('menuitem', { name: 'LetsMesh (US)' })).toBeInTheDocument();
expect(screen.getByRole('menuitem', { name: 'LetsMesh (EU)' })).toBeInTheDocument();
const optionButtons = within(dialog)
.getAllByRole('button')
.filter((button) => button.hasAttribute('aria-pressed'));
expect(optionButtons).toHaveLength(9);
expect(within(dialog).getByRole('button', { name: 'Close' })).toBeInTheDocument();
expect(within(dialog).getByRole('button', { name: 'Create' })).toBeInTheDocument();
expect(
screen.getByRole('menuitem', { name: 'Community MQTT/meshcoretomqtt' })
within(dialog).getByRole('button', { name: startsWithAccessibleName('Private MQTT') })
).toBeInTheDocument();
expect(screen.getByRole('menuitem', { name: 'Webhook' })).toBeInTheDocument();
expect(screen.getByRole('menuitem', { name: 'Apprise' })).toBeInTheDocument();
expect(screen.getByRole('menuitem', { name: 'Amazon SQS' })).toBeInTheDocument();
expect(screen.getByRole('menuitem', { name: 'Bot' })).toBeInTheDocument();
expect(
within(dialog).getByRole('button', { name: startsWithAccessibleName('MeshRank') })
).toBeInTheDocument();
expect(
within(dialog).getByRole('button', { name: startsWithAccessibleName('LetsMesh (US)') })
).toBeInTheDocument();
expect(
within(dialog).getByRole('button', { name: startsWithAccessibleName('LetsMesh (EU)') })
).toBeInTheDocument();
expect(
within(dialog).getByRole('button', {
name: startsWithAccessibleName('Community MQTT/meshcoretomqtt'),
})
).toBeInTheDocument();
expect(
within(dialog).getByRole('button', { name: startsWithAccessibleName('Webhook') })
).toBeInTheDocument();
expect(
within(dialog).getByRole('button', { name: startsWithAccessibleName('Apprise') })
).toBeInTheDocument();
expect(
within(dialog).getByRole('button', { name: startsWithAccessibleName('Amazon SQS') })
).toBeInTheDocument();
expect(
within(dialog).getByRole('button', { name: startsWithAccessibleName('Python Bot') })
).toBeInTheDocument();
expect(within(dialog).getByRole('heading', { level: 3 })).toBeInTheDocument();
const genericCommunityIndex = optionButtons.findIndex((button) =>
button.textContent?.startsWith('Community MQTT/meshcoretomqtt')
);
const meshRankIndex = optionButtons.findIndex((button) =>
button.textContent?.startsWith('MeshRank')
);
expect(genericCommunityIndex).toBeGreaterThan(-1);
expect(meshRankIndex).toBeGreaterThan(-1);
expect(genericCommunityIndex).toBeLessThan(meshRankIndex);
});
it('shows bot option in add integration menu when bots are enabled', async () => {
it('shows bot option in add integration dialog when bots are enabled', async () => {
renderSection();
await waitFor(() => {
expect(screen.getByRole('button', { name: 'Add Integration' })).toBeInTheDocument();
});
fireEvent.click(screen.getByRole('button', { name: 'Add Integration' }));
expect(screen.getByRole('menuitem', { name: 'Bot' })).toBeInTheDocument();
const dialog = await openCreateIntegrationDialog();
expect(
within(dialog).getByRole('button', { name: startsWithAccessibleName('Python Bot') })
).toBeInTheDocument();
});
it('shows bots disabled banner when bots_disabled', async () => {
@@ -123,14 +175,12 @@ describe('SettingsFanoutSection', () => {
});
});
it('hides bot option from add integration menu when bots_disabled', async () => {
it('hides bot option from add integration dialog when bots_disabled', async () => {
renderSection({ health: { ...baseHealth, bots_disabled: true } });
await waitFor(() => {
expect(screen.getByRole('button', { name: 'Add Integration' })).toBeInTheDocument();
});
fireEvent.click(screen.getByRole('button', { name: 'Add Integration' }));
expect(screen.queryByRole('menuitem', { name: 'Bot' })).not.toBeInTheDocument();
const dialog = await openCreateIntegrationDialog();
expect(
within(dialog).queryByRole('button', { name: startsWithAccessibleName('Python Bot') })
).not.toBeInTheDocument();
});
it('lists existing configs after load', async () => {
@@ -305,12 +355,9 @@ describe('SettingsFanoutSection', () => {
it('navigates to create view when clicking add button', async () => {
renderSection();
await waitFor(() => {
expect(screen.getByRole('button', { name: 'Add Integration' })).toBeInTheDocument();
});
fireEvent.click(screen.getByRole('button', { name: 'Add Integration' }));
fireEvent.click(screen.getByRole('menuitem', { name: 'Webhook' }));
await openCreateIntegrationDialog();
selectCreateIntegration('Webhook');
confirmCreateIntegration();
await waitFor(() => {
expect(screen.getByText('← Back to list')).toBeInTheDocument();
@@ -324,12 +371,9 @@ describe('SettingsFanoutSection', () => {
it('new SQS draft shows queue url fields and sensible defaults', async () => {
renderSection();
await waitFor(() => {
expect(screen.getByRole('button', { name: 'Add Integration' })).toBeInTheDocument();
});
fireEvent.click(screen.getByRole('button', { name: 'Add Integration' }));
fireEvent.click(screen.getByRole('menuitem', { name: 'Amazon SQS' }));
await openCreateIntegrationDialog();
selectCreateIntegration('Amazon SQS');
confirmCreateIntegration();
await waitFor(() => {
expect(screen.getByText('← Back to list')).toBeInTheDocument();
@@ -341,12 +385,9 @@ describe('SettingsFanoutSection', () => {
it('backing out of a new draft does not create an integration', async () => {
renderSection();
await waitFor(() => {
expect(screen.getByRole('button', { name: 'Add Integration' })).toBeInTheDocument();
});
fireEvent.click(screen.getByRole('button', { name: 'Add Integration' }));
fireEvent.click(screen.getByRole('menuitem', { name: 'Webhook' }));
await openCreateIntegrationDialog();
selectCreateIntegration('Webhook');
confirmCreateIntegration();
await waitFor(() => expect(screen.getByText('← Back to list')).toBeInTheDocument());
fireEvent.click(screen.getByText('← Back to list'));
@@ -420,12 +461,9 @@ describe('SettingsFanoutSection', () => {
mockedApi.getFanoutConfigs.mockResolvedValueOnce([]).mockResolvedValueOnce([createdWebhook]);
renderSection();
await waitFor(() =>
expect(screen.getByRole('button', { name: 'Add Integration' })).toBeInTheDocument()
);
fireEvent.click(screen.getByRole('button', { name: 'Add Integration' }));
fireEvent.click(screen.getByRole('menuitem', { name: 'Webhook' }));
await openCreateIntegrationDialog();
selectCreateIntegration('Webhook');
confirmCreateIntegration();
await waitFor(() => expect(screen.getByText('← Back to list')).toBeInTheDocument());
fireEvent.click(screen.getByRole('button', { name: 'Save as Disabled' }));
@@ -453,8 +491,9 @@ describe('SettingsFanoutSection', () => {
renderSection();
await waitFor(() => expect(screen.getByText('Test Hook')).toBeInTheDocument());
fireEvent.click(screen.getByRole('button', { name: 'Add Integration' }));
fireEvent.click(screen.getByRole('menuitem', { name: 'Webhook' }));
await openCreateIntegrationDialog();
selectCreateIntegration('Webhook');
confirmCreateIntegration();
await waitFor(() => expect(screen.getByLabelText('Name')).toHaveValue('Webhook #3'));
});
@@ -656,21 +695,21 @@ describe('SettingsFanoutSection', () => {
mockedApi.getFanoutConfigs.mockResolvedValue([communityConfig]);
renderSection();
await waitFor(() =>
expect(screen.getByText('Broker: mqtt-us-v1.letsmesh.net:443')).toBeInTheDocument()
);
expect(screen.getByText('mesh2mqtt/{IATA}/node/{PUBLIC_KEY}')).toBeInTheDocument();
const group = await screen.findByRole('group', { name: 'Integration Community Feed' });
expect(
within(group).getByText(
(_, element) => element?.textContent === 'Broker: mqtt-us-v1.letsmesh.net:443'
)
).toBeInTheDocument();
expect(within(group).getByText('mesh2mqtt/{IATA}/node/{PUBLIC_KEY}')).toBeInTheDocument();
expect(screen.queryByText('Region: LAX')).not.toBeInTheDocument();
});
it('MeshRank preset pre-fills the broker settings and asks for the topic template', async () => {
renderSection();
await waitFor(() =>
expect(screen.getByRole('button', { name: 'Add Integration' })).toBeInTheDocument()
);
fireEvent.click(screen.getByRole('button', { name: 'Add Integration' }));
fireEvent.click(screen.getByRole('menuitem', { name: 'MeshRank' }));
await openCreateIntegrationDialog();
selectCreateIntegration('MeshRank');
confirmCreateIntegration();
await waitFor(() => expect(screen.getByText('← Back to list')).toBeInTheDocument());
@@ -707,12 +746,9 @@ describe('SettingsFanoutSection', () => {
mockedApi.getFanoutConfigs.mockResolvedValueOnce([]).mockResolvedValueOnce([createdConfig]);
renderSection();
await waitFor(() =>
expect(screen.getByRole('button', { name: 'Add Integration' })).toBeInTheDocument()
);
fireEvent.click(screen.getByRole('button', { name: 'Add Integration' }));
fireEvent.click(screen.getByRole('menuitem', { name: 'MeshRank' }));
await openCreateIntegrationDialog();
selectCreateIntegration('MeshRank');
confirmCreateIntegration();
await waitFor(() => expect(screen.getByText('← Back to list')).toBeInTheDocument());
fireEvent.change(screen.getByLabelText('Packet Topic Template'), {
@@ -774,12 +810,9 @@ describe('SettingsFanoutSection', () => {
mockedApi.getFanoutConfigs.mockResolvedValueOnce([]).mockResolvedValueOnce([createdConfig]);
renderSection();
await waitFor(() =>
expect(screen.getByRole('button', { name: 'Add Integration' })).toBeInTheDocument()
);
fireEvent.click(screen.getByRole('button', { name: 'Add Integration' }));
fireEvent.click(screen.getByRole('menuitem', { name: 'LetsMesh (US)' }));
await openCreateIntegrationDialog();
selectCreateIntegration('LetsMesh (US)');
confirmCreateIntegration();
await waitFor(() => expect(screen.getByText('← Back to list')).toBeInTheDocument());
expect(screen.getByLabelText('Name')).toHaveValue('LetsMesh (US)');
@@ -842,12 +875,9 @@ describe('SettingsFanoutSection', () => {
mockedApi.getFanoutConfigs.mockResolvedValueOnce([]).mockResolvedValueOnce([createdConfig]);
renderSection();
await waitFor(() =>
expect(screen.getByRole('button', { name: 'Add Integration' })).toBeInTheDocument()
);
fireEvent.click(screen.getByRole('button', { name: 'Add Integration' }));
fireEvent.click(screen.getByRole('menuitem', { name: 'LetsMesh (EU)' }));
await openCreateIntegrationDialog();
selectCreateIntegration('LetsMesh (EU)');
confirmCreateIntegration();
await waitFor(() => expect(screen.getByText('← Back to list')).toBeInTheDocument());
fireEvent.change(screen.getByLabelText('Email'), { target: { value: 'user@example.com' } });
@@ -880,12 +910,9 @@ describe('SettingsFanoutSection', () => {
it('generic Community MQTT entry still opens the full editor', async () => {
renderSection();
await waitFor(() =>
expect(screen.getByRole('button', { name: 'Add Integration' })).toBeInTheDocument()
);
fireEvent.click(screen.getByRole('button', { name: 'Add Integration' }));
fireEvent.click(screen.getByRole('menuitem', { name: 'Community MQTT/meshcoretomqtt' }));
await openCreateIntegrationDialog();
selectCreateIntegration('Community MQTT/meshcoretomqtt');
confirmCreateIntegration();
await waitFor(() => expect(screen.getByText('← Back to list')).toBeInTheDocument());
@@ -909,9 +936,12 @@ describe('SettingsFanoutSection', () => {
mockedApi.getFanoutConfigs.mockResolvedValue([privateConfig]);
renderSection();
await waitFor(() => expect(screen.getByText('Broker: broker.local:1883')).toBeInTheDocument());
const group = await screen.findByRole('group', { name: 'Integration Private Broker' });
expect(
screen.getByText('meshcore/dm:<pubkey>, meshcore/gm:<channel>, meshcore/raw/...')
within(group).getByText((_, element) => element?.textContent === 'Broker: broker.local:1883')
).toBeInTheDocument();
expect(
within(group).getByText('meshcore/dm:<pubkey>, meshcore/gm:<channel>, meshcore/raw/...')
).toBeInTheDocument();
});
@@ -929,7 +959,8 @@ describe('SettingsFanoutSection', () => {
mockedApi.getFanoutConfigs.mockResolvedValue([config]);
renderSection();
await waitFor(() => expect(screen.getByText('https://example.com/hook')).toBeInTheDocument());
const group = await screen.findByRole('group', { name: 'Integration Webhook Feed' });
expect(within(group).getByText('https://example.com/hook')).toBeInTheDocument();
});
it('apprise list shows compact target summary', async () => {
@@ -950,9 +981,10 @@ describe('SettingsFanoutSection', () => {
mockedApi.getFanoutConfigs.mockResolvedValue([config]);
renderSection();
await waitFor(() =>
expect(screen.getByText(/discord:\/\/abc, mailto:\/\/one@example.com/)).toBeInTheDocument()
);
const group = await screen.findByRole('group', { name: 'Integration Apprise Feed' });
expect(
within(group).getByText(/discord:\/\/abc, mailto:\/\/one@example.com/)
).toBeInTheDocument();
});
it('sqs list shows queue url summary', async () => {
@@ -972,11 +1004,10 @@ describe('SettingsFanoutSection', () => {
mockedApi.getFanoutConfigs.mockResolvedValue([config]);
renderSection();
await waitFor(() =>
expect(
screen.getByText('https://sqs.us-east-1.amazonaws.com/123456789012/mesh-events')
).toBeInTheDocument()
);
const group = await screen.findByRole('group', { name: 'Integration Queue Feed' });
expect(
within(group).getByText('https://sqs.us-east-1.amazonaws.com/123456789012/mesh-events')
).toBeInTheDocument();
});
it('groups integrations by type and sorts entries alphabetically within each group', async () => {

View File

@@ -10,7 +10,6 @@ import userEvent from '@testing-library/user-event';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { NewMessageModal } from '../components/NewMessageModal';
import type { Contact } from '../types';
import { toast } from '../components/ui/sonner';
// Mock sonner (toast)
@@ -18,24 +17,6 @@ vi.mock('../components/ui/sonner', () => ({
toast: { success: vi.fn(), error: vi.fn() },
}));
const mockContact: Contact = {
public_key: 'aa'.repeat(32),
name: 'Alice',
type: 1,
flags: 0,
direct_path: null,
direct_path_len: -1,
direct_path_hash_mode: 0,
last_advert: null,
lat: null,
lon: null,
last_seen: null,
on_radio: false,
last_contacted: null,
last_read_at: null,
first_seen: null,
};
const mockToast = toast as unknown as {
success: ReturnType<typeof vi.fn>;
error: ReturnType<typeof vi.fn>;
@@ -43,7 +24,6 @@ const mockToast = toast as unknown as {
describe('NewMessageModal form reset', () => {
const onClose = vi.fn();
const onSelectConversation = vi.fn();
const onCreateContact = vi.fn().mockResolvedValue(undefined);
const onCreateChannel = vi.fn().mockResolvedValue(undefined);
const onCreateHashtagChannel = vi.fn().mockResolvedValue(undefined);
@@ -56,10 +36,8 @@ describe('NewMessageModal form reset', () => {
return render(
<NewMessageModal
open={open}
contacts={[mockContact]}
undecryptedCount={5}
onClose={onClose}
onSelectConversation={onSelectConversation}
onCreateContact={onCreateContact}
onCreateChannel={onCreateChannel}
onCreateHashtagChannel={onCreateHashtagChannel}
@@ -75,7 +53,7 @@ describe('NewMessageModal form reset', () => {
it('clears name after successful Create', async () => {
const user = userEvent.setup();
const { unmount } = renderModal();
await switchToTab(user, 'Hashtag');
await switchToTab(user, 'Hashtag Channel');
const input = screen.getByPlaceholderText('channel-name') as HTMLInputElement;
await user.type(input, 'testchan');
@@ -91,14 +69,14 @@ describe('NewMessageModal form reset', () => {
// Re-render to simulate reopening — state should be reset
renderModal();
await switchToTab(user, 'Hashtag');
await switchToTab(user, 'Hashtag Channel');
expect((screen.getByPlaceholderText('channel-name') as HTMLInputElement).value).toBe('');
});
it('clears name when Cancel is clicked', async () => {
const user = userEvent.setup();
renderModal();
await switchToTab(user, 'Hashtag');
await switchToTab(user, 'Hashtag Channel');
const input = screen.getByPlaceholderText('channel-name') as HTMLInputElement;
await user.type(input, 'mychannel');
@@ -127,13 +105,13 @@ describe('NewMessageModal form reset', () => {
});
});
describe('new-room tab', () => {
describe('new-channel tab', () => {
it('clears name and key after successful Create', async () => {
const user = userEvent.setup();
renderModal();
await switchToTab(user, 'Room');
await switchToTab(user, 'Private Channel');
await user.type(screen.getByPlaceholderText('Room name'), 'MyRoom');
await user.type(screen.getByPlaceholderText('Channel name'), 'MyRoom');
await user.type(screen.getByPlaceholderText('Pre-shared key (hex)'), 'cc'.repeat(16));
await user.click(screen.getByRole('button', { name: 'Create' }));
@@ -148,9 +126,9 @@ describe('NewMessageModal form reset', () => {
const user = userEvent.setup();
onCreateChannel.mockRejectedValueOnce(new Error('Bad key'));
renderModal();
await switchToTab(user, 'Room');
await switchToTab(user, 'Private Channel');
await user.type(screen.getByPlaceholderText('Room name'), 'MyRoom');
await user.type(screen.getByPlaceholderText('Channel name'), 'MyRoom');
await user.type(screen.getByPlaceholderText('Pre-shared key (hex)'), 'cc'.repeat(16));
await user.click(screen.getByRole('button', { name: 'Create' }));
@@ -164,7 +142,7 @@ describe('NewMessageModal form reset', () => {
});
describe('tab switching resets form', () => {
it('clears contact fields when switching to room tab', async () => {
it('clears contact fields when switching to channel tab', async () => {
const user = userEvent.setup();
renderModal();
await switchToTab(user, 'Contact');
@@ -172,24 +150,24 @@ describe('NewMessageModal form reset', () => {
await user.type(screen.getByPlaceholderText('Contact name'), 'Bob');
await user.type(screen.getByPlaceholderText('64-character hex public key'), 'deadbeef');
// Switch to Room tab — fields should reset
await switchToTab(user, 'Room');
// Switch to Private Channel tab — fields should reset
await switchToTab(user, 'Private Channel');
expect((screen.getByPlaceholderText('Room name') as HTMLInputElement).value).toBe('');
expect((screen.getByPlaceholderText('Channel name') as HTMLInputElement).value).toBe('');
expect((screen.getByPlaceholderText('Pre-shared key (hex)') as HTMLInputElement).value).toBe(
''
);
});
it('clears room fields when switching to hashtag tab', async () => {
it('clears channel fields when switching to hashtag tab', async () => {
const user = userEvent.setup();
renderModal();
await switchToTab(user, 'Room');
await switchToTab(user, 'Private Channel');
await user.type(screen.getByPlaceholderText('Room name'), 'SecretRoom');
await user.type(screen.getByPlaceholderText('Channel name'), 'SecretRoom');
await user.type(screen.getByPlaceholderText('Pre-shared key (hex)'), 'ff'.repeat(16));
await switchToTab(user, 'Hashtag');
await switchToTab(user, 'Hashtag Channel');
expect((screen.getByPlaceholderText('channel-name') as HTMLInputElement).value).toBe('');
});
@@ -199,7 +177,7 @@ describe('NewMessageModal form reset', () => {
it('resets tryHistorical when switching tabs', async () => {
const user = userEvent.setup();
renderModal();
await switchToTab(user, 'Hashtag');
await switchToTab(user, 'Hashtag Channel');
// Check the "Try decrypting" checkbox
const checkbox = screen.getByRole('checkbox', { name: /Try decrypting/ });
@@ -210,7 +188,7 @@ describe('NewMessageModal form reset', () => {
// Switch tab and come back
await switchToTab(user, 'Contact');
await switchToTab(user, 'Hashtag');
await switchToTab(user, 'Hashtag Channel');
// The streaming message should be gone (tryHistorical was reset)
expect(screen.queryByText(/Messages will stream in/)).toBeNull();

View File

@@ -361,7 +361,7 @@ describe('RawPacketFeedView', () => {
expect(screen.queryByText('Identity not resolvable')).not.toBeInTheDocument();
});
it('opens a packet detail modal from the raw feed and decrypts room messages when a key is loaded', () => {
it('opens a packet detail modal from the raw feed and decrypts channel messages when a key is loaded', () => {
renderView({
packets: [
{
@@ -392,7 +392,7 @@ describe('RawPacketFeedView', () => {
).toBeInTheDocument();
});
it('does not guess a room name when multiple loaded channels collide on the group hash', () => {
it('does not guess a channel name when multiple loaded channels collide on the group hash', () => {
renderView({
packets: [
{

View File

@@ -1,12 +1,16 @@
import { act, renderHook } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { useBrowserNotifications } from '../hooks/useBrowserNotifications';
import {
getNotificationEnableToastInfo,
useBrowserNotifications,
} from '../hooks/useBrowserNotifications';
import type { Message } from '../types';
const mocks = vi.hoisted(() => ({
toast: {
success: vi.fn(),
warning: vi.fn(),
error: vi.fn(),
},
}));
@@ -57,6 +61,10 @@ describe('useBrowserNotifications', () => {
configurable: true,
value: NotificationMock,
});
Object.defineProperty(window, 'isSecureContext', {
configurable: true,
value: true,
});
});
it('stores notification opt-in per conversation', async () => {
@@ -84,6 +92,10 @@ describe('useBrowserNotifications', () => {
icon: '/favicon-256x256.png',
tag: `meshcore-notification-preview-channel-${incomingChannelMessage.conversation_key}`,
});
expect(mocks.toast.warning).toHaveBeenCalledWith('Notifications enabled with warning', {
description:
'Desktop notifications are on for this conversation, but you are using HTTP instead of HTTPS. Notifications will likely not work reliably.',
});
});
it('only sends desktop notifications for opted-in conversations', async () => {
@@ -164,9 +176,65 @@ describe('useBrowserNotifications', () => {
);
});
expect(mocks.toast.error).toHaveBeenCalledWith('Browser notifications blocked', {
expect(mocks.toast.error).toHaveBeenCalledWith('Notifications blocked', {
description:
'Allow notifications in your browser settings, then try again. Some browsers may refuse notifications on non-HTTPS or self-signed HTTPS origins. Check your browser documentation for how to trust an insecure origin and the associated risks before doing so.',
'Desktop notifications are blocked by your browser. Allow notifications in browser settings, then try again. Non-HTTPS or untrusted HTTPS origins may also prevent notifications from working reliably.',
});
});
it('shows a warning toast when notifications are enabled on HTTP', async () => {
const { result } = renderHook(() => useBrowserNotifications());
await act(async () => {
await result.current.toggleConversationNotifications(
'channel',
incomingChannelMessage.conversation_key,
'#flightless'
);
});
expect(mocks.toast.warning).toHaveBeenCalledWith('Notifications enabled with warning', {
description:
'Desktop notifications are on for this conversation, but you are using HTTP instead of HTTPS. Notifications will likely not work reliably.',
});
expect(mocks.toast.success).not.toHaveBeenCalledWith('Notifications enabled');
});
it('best-effort detects insecure HTTPS for the enable-warning copy', () => {
expect(
getNotificationEnableToastInfo({
protocol: 'https:',
isSecureContext: false,
})
).toEqual({
level: 'warning',
title: 'Notifications enabled with warning',
description:
'Desktop notifications are on for this conversation, but your HTTPS connection is untrusted, such as a self-signed certificate. Notification delivery may be inconsistent depending on your browser.',
});
});
it('shows a descriptive success toast when notifications are disabled', async () => {
const { result } = renderHook(() => useBrowserNotifications());
await act(async () => {
await result.current.toggleConversationNotifications(
'channel',
incomingChannelMessage.conversation_key,
'#flightless'
);
});
await act(async () => {
await result.current.toggleConversationNotifications(
'channel',
incomingChannelMessage.conversation_key,
'#flightless'
);
});
expect(mocks.toast.success).toHaveBeenCalledWith('Notifications disabled', {
description: 'Desktop notifications are off for #flightless.',
});
});
});

View File

@@ -0,0 +1,255 @@
import { renderHook, waitFor } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import {
buildBadgedFaviconSvg,
deriveFaviconBadgeState,
getFavoriteUnreadCount,
getUnreadTitle,
getTotalUnreadCount,
useFaviconBadge,
useUnreadTitle,
} from '../hooks/useFaviconBadge';
import type { Favorite } from '../types';
import { getStateKey } from '../utils/conversationState';
function getIconHref(rel: 'icon' | 'shortcut icon'): string | null {
return (
document.head.querySelector<HTMLLinkElement>(`link[rel="${rel}"]`)?.getAttribute('href') ?? null
);
}
describe('useFaviconBadge', () => {
const baseSvg =
'<svg xmlns="http://www.w3.org/2000/svg"><rect width="1000" height="1000"/></svg>';
const originalCreateObjectURL = URL.createObjectURL;
const originalRevokeObjectURL = URL.revokeObjectURL;
let objectUrlCounter = 0;
let fetchMock: ReturnType<typeof vi.fn>;
let createObjectURLMock: ReturnType<typeof vi.fn>;
let revokeObjectURLMock: ReturnType<typeof vi.fn>;
beforeEach(() => {
document.head.innerHTML = `
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="shortcut icon" href="/favicon.ico" />
`;
document.title = 'RemoteTerm for MeshCore';
objectUrlCounter = 0;
fetchMock = vi.fn().mockResolvedValue({
ok: true,
text: async () => baseSvg,
});
createObjectURLMock = vi.fn(() => `blob:generated-${++objectUrlCounter}`);
revokeObjectURLMock = vi.fn();
vi.stubGlobal('fetch', fetchMock);
Object.defineProperty(URL, 'createObjectURL', {
configurable: true,
writable: true,
value: createObjectURLMock,
});
Object.defineProperty(URL, 'revokeObjectURL', {
configurable: true,
writable: true,
value: revokeObjectURLMock,
});
});
afterEach(() => {
vi.unstubAllGlobals();
Object.defineProperty(URL, 'createObjectURL', {
configurable: true,
writable: true,
value: originalCreateObjectURL,
});
Object.defineProperty(URL, 'revokeObjectURL', {
configurable: true,
writable: true,
value: originalRevokeObjectURL,
});
});
it('derives badge priority from unread counts, mentions, and favorites', () => {
const favorites: Favorite[] = [{ type: 'channel', id: 'fav-chan' }];
expect(deriveFaviconBadgeState({}, {}, favorites)).toBe('none');
expect(
deriveFaviconBadgeState(
{
[getStateKey('channel', 'fav-chan')]: 3,
},
{},
favorites
)
).toBe('green');
expect(
deriveFaviconBadgeState(
{
[getStateKey('contact', 'abc')]: 12,
},
{},
favorites
)
).toBe('red');
expect(
deriveFaviconBadgeState(
{
[getStateKey('channel', 'fav-chan')]: 1,
},
{
[getStateKey('channel', 'fav-chan')]: true,
},
favorites
)
).toBe('red');
});
it('builds a dot-only badge into the base svg markup', () => {
const svg = buildBadgedFaviconSvg(baseSvg, '#16a34a');
expect(svg).toContain('<circle cx="750" cy="750" r="220" fill="#ffffff"/>');
expect(svg).toContain('<circle cx="750" cy="750" r="180" fill="#16a34a"/>');
expect(svg).not.toContain('<text');
});
it('derives the unread count and page title', () => {
expect(getTotalUnreadCount({})).toBe(0);
expect(getTotalUnreadCount({ a: 2, b: 5 })).toBe(7);
expect(getFavoriteUnreadCount({}, [])).toBe(0);
expect(
getFavoriteUnreadCount(
{
[getStateKey('channel', 'fav-chan')]: 7,
[getStateKey('contact', 'fav-contact')]: 3,
[getStateKey('channel', 'other-chan')]: 9,
},
[
{ type: 'channel', id: 'fav-chan' },
{ type: 'contact', id: 'fav-contact' },
]
)
).toBe(10);
expect(getUnreadTitle({}, [])).toBe('RemoteTerm for MeshCore');
expect(
getUnreadTitle(
{
[getStateKey('channel', 'fav-chan')]: 7,
[getStateKey('channel', 'other-chan')]: 9,
},
[{ type: 'channel', id: 'fav-chan' }]
)
).toBe('(7) RemoteTerm');
expect(
getUnreadTitle(
{
[getStateKey('channel', 'fav-chan')]: 120,
},
[{ type: 'channel', id: 'fav-chan' }]
)
).toBe('(99+) RemoteTerm');
});
it('switches between the base favicon and generated blob badges', async () => {
const favorites: Favorite[] = [{ type: 'channel', id: 'fav-chan' }];
const { rerender } = renderHook(
({
unreadCounts,
mentions,
currentFavorites,
}: {
unreadCounts: Record<string, number>;
mentions: Record<string, boolean>;
currentFavorites: Favorite[];
}) => useFaviconBadge(unreadCounts, mentions, currentFavorites),
{
initialProps: {
unreadCounts: {},
mentions: {},
currentFavorites: favorites,
},
}
);
await waitFor(() => {
expect(getIconHref('icon')).toBe('/favicon.svg');
expect(getIconHref('shortcut icon')).toBe('/favicon.svg');
});
rerender({
unreadCounts: {
[getStateKey('channel', 'fav-chan')]: 1,
},
mentions: {},
currentFavorites: favorites,
});
await waitFor(() => {
expect(getIconHref('icon')).toBe('blob:generated-1');
expect(getIconHref('shortcut icon')).toBe('blob:generated-1');
});
rerender({
unreadCounts: {
[getStateKey('contact', 'dm-key')]: 12,
},
mentions: {},
currentFavorites: favorites,
});
await waitFor(() => {
expect(getIconHref('icon')).toBe('blob:generated-2');
expect(getIconHref('shortcut icon')).toBe('blob:generated-2');
});
rerender({
unreadCounts: {},
mentions: {},
currentFavorites: favorites,
});
await waitFor(() => {
expect(getIconHref('icon')).toBe('/favicon.svg');
expect(getIconHref('shortcut icon')).toBe('/favicon.svg');
});
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(createObjectURLMock).toHaveBeenCalledTimes(2);
expect(revokeObjectURLMock).toHaveBeenCalledWith('blob:generated-1');
expect(revokeObjectURLMock).toHaveBeenCalledWith('blob:generated-2');
});
it('writes unread counts into the page title', () => {
const { rerender, unmount } = renderHook(
({
unreadCounts,
favorites,
}: {
unreadCounts: Record<string, number>;
favorites: Favorite[];
}) => useUnreadTitle(unreadCounts, favorites),
{
initialProps: {
unreadCounts: {},
favorites: [{ type: 'channel', id: 'fav-chan' }],
},
}
);
expect(document.title).toBe('RemoteTerm for MeshCore');
rerender({
unreadCounts: {
[getStateKey('channel', 'fav-chan')]: 4,
[getStateKey('contact', 'dm-key')]: 2,
},
favorites: [{ type: 'channel', id: 'fav-chan' }],
});
expect(document.title).toBe('(4) RemoteTerm');
unmount();
expect(document.title).toBe('RemoteTerm for MeshCore');
});
});

View File

@@ -67,8 +67,8 @@ export function describeCiphertextStructure(
case PayloadType.GroupText:
return `Encrypted message content (${byteLength} bytes). Contains encrypted plaintext with this structure:
• Timestamp (4 bytes) - send time as unix timestamp
• Flags (1 byte) - room-message flags byte
• Message (remaining bytes) - UTF-8 room message text`;
• Flags (1 byte) - channel-message flags byte
• Message (remaining bytes) - UTF-8 channel message text`;
case PayloadType.TextMessage:
return `Encrypted message data (${byteLength} bytes). Contains encrypted plaintext with this structure:
• Timestamp (4 bytes) - send time as unix timestamp

View File

@@ -0,0 +1,106 @@
#!/usr/bin/env python3
"""
fetch_prebuilt_frontend.py
Downloads the latest prebuilt frontend artifact from the GitHub releases page
and installs it into frontend/prebuilt/ so the backend can serve it directly.
No GitHub CLI or authentication required — uses only the public releases API
and browser_download_url. Requires only the Python standard library.
"""
import json
import shutil
import sys
import urllib.request
import zipfile
from pathlib import Path
REPO = "jkingsman/Remote-Terminal-for-MeshCore"
API_URL = f"https://api.github.com/repos/{REPO}/releases/latest"
PREBUILT_PREFIX = "Remote-Terminal-for-MeshCore/frontend/prebuilt/"
SCRIPT_DIR = Path(__file__).resolve().parent
PREBUILT_DIR = SCRIPT_DIR.parent / "frontend" / "prebuilt"
def fetch_json(url: str) -> dict:
req = urllib.request.Request(url, headers={"Accept": "application/vnd.github+json"})
with urllib.request.urlopen(req) as resp:
return json.loads(resp.read())
def find_prebuilt_asset(release: dict) -> tuple[str, str, str]:
"""Return (tag_name, asset_name, download_url) for the prebuilt zip."""
tag = release.get("tag_name", "")
for asset in release.get("assets", []):
name = asset.get("name", "")
if name.startswith("remoteterm-prebuilt-frontend-") and name.endswith(".zip"):
return tag, name, asset["browser_download_url"]
raise SystemExit(
f"No prebuilt frontend artifact found in the latest release.\n"
f"Check https://github.com/{REPO}/releases for available assets."
)
def download(url: str, dest: Path) -> None:
with urllib.request.urlopen(url) as resp, open(dest, "wb") as f:
shutil.copyfileobj(resp, f)
def extract_prebuilt(zip_path: Path, dest: Path) -> int:
with zipfile.ZipFile(zip_path) as zf:
members = [m for m in zf.namelist() if m.startswith(PREBUILT_PREFIX)]
if not members:
raise SystemExit(f"'{PREBUILT_PREFIX}' not found inside zip.")
if dest.exists():
shutil.rmtree(dest)
dest.mkdir(parents=True)
for member in members:
rel = member[len(PREBUILT_PREFIX):]
if not rel:
continue
target = dest / rel
if member.endswith("/"):
target.mkdir(parents=True, exist_ok=True)
else:
target.parent.mkdir(parents=True, exist_ok=True)
with zf.open(member) as src, open(target, "wb") as dst:
shutil.copyfileobj(src, dst)
return len(members)
def main() -> None:
print("Fetching latest release info...")
release = fetch_json(API_URL)
tag, asset_name, download_url = find_prebuilt_asset(release)
print(f" Release : {tag}")
print(f" Asset : {asset_name}")
print()
zip_path = PREBUILT_DIR.parent / asset_name
try:
print(f"Downloading {asset_name}...")
download(download_url, zip_path)
print("Extracting prebuilt frontend...")
count = extract_prebuilt(zip_path, PREBUILT_DIR)
print(f"Extracted {count} entries.")
finally:
zip_path.unlink(missing_ok=True)
print()
print(f"Done! Prebuilt frontend ({tag}) installed to frontend/prebuilt/")
print("Start the server with:")
print(" uv run uvicorn app.main:app --host 0.0.0.0 --port 8000")
if __name__ == "__main__":
try:
main()
except KeyboardInterrupt:
print("\nAborted.", file=sys.stderr)
sys.exit(1)

413
scripts/install_service.sh Executable file
View File

@@ -0,0 +1,413 @@
#!/usr/bin/env bash
# install_service.sh
#
# Sets up RemoteTerm for MeshCore as a persistent systemd service running as
# the current user from the current repo directory. No separate service account
# is needed. After installation, git pull and rebuilds work without any sudo -u
# gymnastics.
#
# Run from anywhere inside the repo:
# bash scripts/install_service.sh
set -e
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
CYAN='\033[0;36m'
BOLD='\033[1m'
NC='\033[0m'
SERVICE_NAME="remoteterm"
REPO_DIR="$(cd "$(dirname "$0")/.." && pwd)"
CURRENT_USER="$(id -un)"
SERVICE_FILE="/etc/systemd/system/${SERVICE_NAME}.service"
FRONTEND_MODE="build"
echo -e "${BOLD}=== RemoteTerm for MeshCore — Service Installer ===${NC}"
echo
# ── sanity checks ──────────────────────────────────────────────────────────────
if [ "$(uname -s)" != "Linux" ]; then
echo -e "${RED}Error: this script is for Linux (systemd) only.${NC}"
exit 1
fi
if ! command -v systemctl &>/dev/null; then
echo -e "${RED}Error: systemd not found. This script requires a systemd-based Linux system.${NC}"
exit 1
fi
if ! command -v uv &>/dev/null; then
echo -e "${RED}Error: 'uv' not found. Install it first:${NC}"
echo " curl -LsSf https://astral.sh/uv/install.sh | sh"
exit 1
fi
if ! command -v python3 &>/dev/null; then
echo -e "${RED}Error: python3 is required but was not found.${NC}"
exit 1
fi
UV_BIN="$(command -v uv)"
UVICORN_BIN="$REPO_DIR/.venv/bin/uvicorn"
echo -e " Installing as user : ${CYAN}${CURRENT_USER}${NC}"
echo -e " Repo directory : ${CYAN}${REPO_DIR}${NC}"
echo -e " Service name : ${CYAN}${SERVICE_NAME}${NC}"
echo -e " uv : ${CYAN}${UV_BIN}${NC}"
echo
version_major() {
local version="$1"
version="${version#v}"
printf '%s' "${version%%.*}"
}
require_minimum_version() {
local tool_name="$1"
local detected_version="$2"
local minimum_major="$3"
local major
major="$(version_major "$detected_version")"
if ! [[ "$major" =~ ^[0-9]+$ ]] || [ "$major" -lt "$minimum_major" ]; then
echo -e "${RED}Error: ${tool_name} ${minimum_major}+ is required for a local frontend build, but found ${detected_version}.${NC}"
exit 1
fi
}
# ── transport selection ────────────────────────────────────────────────────────
echo -e "${BOLD}─── Transport ───────────────────────────────────────────────────────${NC}"
echo "How is your MeshCore radio connected?"
echo " 1) Serial — auto-detect port (default)"
echo " 2) Serial — specify port manually"
echo " 3) TCP (network connection)"
echo " 4) BLE (Bluetooth)"
echo
read -rp "Select transport [1-4] (default: 1): " TRANSPORT_CHOICE
TRANSPORT_CHOICE="${TRANSPORT_CHOICE:-1}"
echo
NEED_DIALOUT=false
SERIAL_PORT=""
TCP_HOST=""
TCP_PORT=""
BLE_ADDRESS=""
BLE_PIN=""
case "$TRANSPORT_CHOICE" in
1)
echo -e "${GREEN}Serial auto-detect selected.${NC}"
NEED_DIALOUT=true
;;
2)
read -rp "Serial port path (default: /dev/ttyUSB0): " SERIAL_PORT
SERIAL_PORT="${SERIAL_PORT:-/dev/ttyUSB0}"
echo -e "${GREEN}Serial port: ${SERIAL_PORT}${NC}"
NEED_DIALOUT=true
;;
3)
read -rp "TCP host (IP address or hostname): " TCP_HOST
while [ -z "$TCP_HOST" ]; do
echo -e "${RED}TCP host is required.${NC}"
read -rp "TCP host: " TCP_HOST
done
read -rp "TCP port (default: 4000): " TCP_PORT
TCP_PORT="${TCP_PORT:-4000}"
echo -e "${GREEN}TCP: ${TCP_HOST}:${TCP_PORT}${NC}"
;;
4)
read -rp "BLE device address (e.g. AA:BB:CC:DD:EE:FF): " BLE_ADDRESS
while [ -z "$BLE_ADDRESS" ]; do
echo -e "${RED}BLE address is required.${NC}"
read -rp "BLE device address: " BLE_ADDRESS
done
read -rsp "BLE PIN: " BLE_PIN
echo
while [ -z "$BLE_PIN" ]; do
echo -e "${RED}BLE PIN is required.${NC}"
read -rsp "BLE PIN: " BLE_PIN
echo
done
echo -e "${GREEN}BLE: ${BLE_ADDRESS}${NC}"
;;
*)
echo -e "${YELLOW}Invalid selection — defaulting to serial auto-detect.${NC}"
TRANSPORT_CHOICE=1
NEED_DIALOUT=true
;;
esac
echo
# ── frontend install mode ──────────────────────────────────────────────────────
echo -e "${BOLD}─── Frontend Assets ─────────────────────────────────────────────────${NC}"
echo "How should the frontend be installed?"
echo " 1) Build locally with npm (default, latest code, requires node/npm)"
echo " 2) Download prebuilt frontend (fastest)"
echo
read -rp "Select frontend mode [1-2] (default: 1): " FRONTEND_CHOICE
FRONTEND_CHOICE="${FRONTEND_CHOICE:-1}"
echo
case "$FRONTEND_CHOICE" in
1)
FRONTEND_MODE="build"
echo -e "${GREEN}Using local frontend build.${NC}"
;;
2)
FRONTEND_MODE="prebuilt"
echo -e "${GREEN}Using prebuilt frontend download.${NC}"
;;
*)
FRONTEND_MODE="build"
echo -e "${YELLOW}Invalid selection — defaulting to local frontend build.${NC}"
;;
esac
echo
# ── bots ──────────────────────────────────────────────────────────────────────
echo -e "${BOLD}─── Bot System ──────────────────────────────────────────────────────${NC}"
echo -e "${YELLOW}Warning:${NC} The bot system executes arbitrary Python code on the server."
echo "It is not recommended on untrusted networks. You can always enable"
echo "it later by editing the service file."
echo
read -rp "Enable bots? [y/N]: " ENABLE_BOTS
ENABLE_BOTS="${ENABLE_BOTS:-N}"
echo
ENABLE_AUTH="N"
AUTH_USERNAME=""
AUTH_PASSWORD=""
if [[ "$ENABLE_BOTS" =~ ^[Yy] ]]; then
echo -e "${GREEN}Bots enabled.${NC}"
echo
echo -e "${BOLD}─── HTTP Basic Auth ─────────────────────────────────────────────────${NC}"
echo "With bots enabled, HTTP Basic Auth is strongly recommended if this"
echo "service will be accessible beyond your local machine."
echo
read -rp "Set up HTTP Basic Auth? [Y/n]: " ENABLE_AUTH
ENABLE_AUTH="${ENABLE_AUTH:-Y}"
echo
if [[ "$ENABLE_AUTH" =~ ^[Yy] ]]; then
read -rp "Username: " AUTH_USERNAME
while [ -z "$AUTH_USERNAME" ]; do
echo -e "${RED}Username cannot be empty.${NC}"
read -rp "Username: " AUTH_USERNAME
done
read -rsp "Password: " AUTH_PASSWORD
echo
while [ -z "$AUTH_PASSWORD" ]; do
echo -e "${RED}Password cannot be empty.${NC}"
read -rsp "Password: " AUTH_PASSWORD
echo
done
echo -e "${GREEN}Basic Auth configured for user '${AUTH_USERNAME}'.${NC}"
echo -e "${YELLOW}Note:${NC} Basic Auth credentials are not safe over plain HTTP."
echo "See README_ADVANCED.md for HTTPS setup."
fi
else
echo -e "${GREEN}Bots disabled.${NC}"
fi
echo
# ── python dependencies ────────────────────────────────────────────────────────
echo -e "${YELLOW}Installing Python dependencies (uv sync)...${NC}"
cd "$REPO_DIR"
uv sync
echo -e "${GREEN}Dependencies ready.${NC}"
echo
# ── frontend assets ────────────────────────────────────────────────────────────
if [ "$FRONTEND_MODE" = "build" ]; then
if ! command -v node &>/dev/null; then
echo -e "${RED}Error: node is required for a local frontend build but was not found.${NC}"
echo -e "${YELLOW}Tip:${NC} Re-run the installer and choose the prebuilt frontend option, or install Node.js 18+ and npm 9+."
exit 1
fi
if ! command -v npm &>/dev/null; then
echo -e "${RED}Error: npm is required for a local frontend build but was not found.${NC}"
echo -e "${YELLOW}Tip:${NC} Re-run the installer and choose the prebuilt frontend option, or install Node.js 18+ and npm 9+."
exit 1
fi
NODE_VERSION="$(node -v)"
NPM_VERSION="$(npm -v)"
require_minimum_version "Node.js" "$NODE_VERSION" 18
require_minimum_version "npm" "$NPM_VERSION" 9
echo -e "${YELLOW}Building frontend locally with Node ${NODE_VERSION} and npm ${NPM_VERSION}...${NC}"
(
cd "$REPO_DIR/frontend"
npm install
npm run build
)
else
echo -e "${YELLOW}Fetching prebuilt frontend...${NC}"
python3 "$REPO_DIR/scripts/fetch_prebuilt_frontend.py"
fi
echo
# ── data directory ─────────────────────────────────────────────────────────────
mkdir -p "$REPO_DIR/data"
# ── serial port access ─────────────────────────────────────────────────────────
if [ "$NEED_DIALOUT" = true ]; then
if ! id -nG "$CURRENT_USER" | grep -qw dialout; then
echo -e "${YELLOW}Adding ${CURRENT_USER} to the 'dialout' group for serial port access...${NC}"
sudo usermod -aG dialout "$CURRENT_USER"
echo -e "${GREEN}Done. You may need to log out and back in for this to take effect for${NC}"
echo -e "${GREEN}manual runs; the service itself handles it via SupplementaryGroups.${NC}"
echo
else
echo -e "${GREEN}User ${CURRENT_USER} is already in the 'dialout' group.${NC}"
echo
fi
fi
# ── systemd service file ───────────────────────────────────────────────────────
if sudo systemctl is-active --quiet "$SERVICE_NAME"; then
echo -e "${YELLOW}${SERVICE_NAME} is currently running; stopping it before applying changes...${NC}"
sudo systemctl stop "$SERVICE_NAME"
echo
fi
echo -e "${YELLOW}Writing systemd service file to ${SERVICE_FILE}...${NC}"
generate_service_file() {
echo "[Unit]"
echo "Description=RemoteTerm for MeshCore"
echo "After=network.target"
echo ""
echo "[Service]"
echo "Type=simple"
echo "User=${CURRENT_USER}"
echo "WorkingDirectory=${REPO_DIR}"
echo "ExecStart=${UVICORN_BIN} app.main:app --host 0.0.0.0 --port 8000"
echo "Restart=always"
echo "RestartSec=5"
echo "Environment=MESHCORE_DATABASE_PATH=${REPO_DIR}/data/meshcore.db"
# Transport
case "$TRANSPORT_CHOICE" in
2) echo "Environment=MESHCORE_SERIAL_PORT=${SERIAL_PORT}" ;;
3)
echo "Environment=MESHCORE_TCP_HOST=${TCP_HOST}"
echo "Environment=MESHCORE_TCP_PORT=${TCP_PORT}"
;;
4)
echo "Environment=MESHCORE_BLE_ADDRESS=${BLE_ADDRESS}"
echo "Environment=MESHCORE_BLE_PIN=${BLE_PIN}"
;;
esac
# Bots
if [[ ! "$ENABLE_BOTS" =~ ^[Yy] ]]; then
echo "Environment=MESHCORE_DISABLE_BOTS=true"
fi
# Basic auth
if [[ "$ENABLE_BOTS" =~ ^[Yy] ]] && [[ "$ENABLE_AUTH" =~ ^[Yy] ]]; then
echo "Environment=MESHCORE_BASIC_AUTH_USERNAME=${AUTH_USERNAME}"
echo "Environment=MESHCORE_BASIC_AUTH_PASSWORD=${AUTH_PASSWORD}"
fi
# Serial group access
if [ "$NEED_DIALOUT" = true ]; then
echo "SupplementaryGroups=dialout"
fi
echo ""
echo "[Install]"
echo "WantedBy=multi-user.target"
}
generate_service_file | sudo tee "$SERVICE_FILE" > /dev/null
echo -e "${GREEN}Service file written.${NC}"
echo
# ── enable and start ───────────────────────────────────────────────────────────
echo -e "${YELLOW}Reloading systemd and applying ${SERVICE_NAME}...${NC}"
sudo systemctl daemon-reload
sudo systemctl enable "$SERVICE_NAME"
sudo systemctl start "$SERVICE_NAME"
echo
# ── status check ───────────────────────────────────────────────────────────────
echo -e "${YELLOW}Service status:${NC}"
sudo systemctl status "$SERVICE_NAME" --no-pager -l || true
echo
# ── summary ────────────────────────────────────────────────────────────────────
echo -e "${GREEN}${BOLD}=== Installation complete! ===${NC}"
echo
echo -e "RemoteTerm is running at ${CYAN}http://$(hostname -I | awk '{print $1}'):8000${NC}"
echo
case "$TRANSPORT_CHOICE" in
1) echo -e " Transport : ${CYAN}Serial (auto-detect)${NC}" ;;
2) echo -e " Transport : ${CYAN}Serial (${SERIAL_PORT})${NC}" ;;
3) echo -e " Transport : ${CYAN}TCP (${TCP_HOST}:${TCP_PORT})${NC}" ;;
4) echo -e " Transport : ${CYAN}BLE (${BLE_ADDRESS})${NC}" ;;
esac
if [ "$FRONTEND_MODE" = "build" ]; then
echo -e " Frontend : ${GREEN}Built locally${NC}"
else
echo -e " Frontend : ${YELLOW}Prebuilt download${NC}"
fi
if [[ "$ENABLE_BOTS" =~ ^[Yy] ]]; then
echo -e " Bots : ${YELLOW}Enabled${NC}"
if [[ "$ENABLE_AUTH" =~ ^[Yy] ]]; then
echo -e " Basic Auth: ${GREEN}Enabled (user: ${AUTH_USERNAME})${NC}"
else
echo -e " Basic Auth: ${YELLOW}Not configured${NC}"
fi
else
echo -e " Bots : ${GREEN}Disabled${NC} (edit ${SERVICE_FILE} to enable)"
fi
echo
if [ "$FRONTEND_MODE" = "prebuilt" ]; then
echo -e "${YELLOW}Note:${NC} A prebuilt frontend has been fetched and installed. It may lag"
echo "behind the latest code. To build the frontend from source for the most"
echo "up-to-date features later, run:"
echo
echo -e " ${CYAN}cd ${REPO_DIR}/frontend && npm install && npm run build${NC}"
echo
fi
echo -e "${BOLD}─── Quick Reference ─────────────────────────────────────────────────${NC}"
echo
echo -e "${YELLOW}Update to latest and restart:${NC}"
echo -e " cd ${REPO_DIR}"
echo -e " git pull"
echo -e " uv sync"
echo -e " cd frontend && npm install && npm run build && cd .."
echo -e " sudo systemctl restart ${SERVICE_NAME}"
echo
echo -e "${YELLOW}Refresh prebuilt frontend only (skips local build):${NC}"
echo -e " python3 ${REPO_DIR}/scripts/fetch_prebuilt_frontend.py"
echo -e " sudo systemctl restart ${SERVICE_NAME}"
echo
echo -e "${YELLOW}View live logs (useful for troubleshooting):${NC}"
echo -e " sudo journalctl -u ${SERVICE_NAME} -f"
echo
echo -e "${YELLOW}Service control:${NC}"
echo -e " sudo systemctl start|stop|restart|status ${SERVICE_NAME}"
echo -e "${BOLD}─────────────────────────────────────────────────────────────────────${NC}"

View File

@@ -1,6 +1,10 @@
import type { Locator, Page } from '@playwright/test';
import http from 'http';
function escapeRegex(text: string): string {
return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
export function createCaptureServer(urlFactory: (port: number) => string) {
const requests: { body: string; headers: http.IncomingHttpHeaders }[] = [];
const server = http.createServer((req, res) => {
@@ -38,6 +42,15 @@ export async function openFanoutSettings(page: Page): Promise<void> {
await page.getByRole('button', { name: /MQTT.*Automation/ }).click();
}
export async function startIntegrationDraft(page: Page, integrationName: string): Promise<void> {
await page.getByRole('button', { name: 'Add Integration' }).click();
const dialog = page.getByRole('dialog', { name: 'Create Integration' });
await dialog
.getByRole('button', { name: new RegExp(`^${escapeRegex(integrationName)}(?:\\s|$)`) })
.click();
await dialog.getByRole('button', { name: 'Create' }).click();
}
export function fanoutHeader(page: Page, name: string): Locator {
const nameButton = page.getByRole('button', { name, exact: true });
return page

View File

@@ -25,6 +25,16 @@ export default defineConfig({
baseURL: 'http://localhost:8001',
trace: 'on-first-retry',
screenshot: 'only-on-failure',
// Dismiss the security warning modal that blocks interaction on fresh browser contexts
storageState: {
cookies: [],
origins: [
{
origin: 'http://localhost:8001',
localStorage: [{ name: 'meshcore_security_warning_acknowledged', value: 'true' }],
},
],
},
},
projects: [

View File

@@ -4,7 +4,12 @@ import {
deleteFanoutConfig,
getFanoutConfigs,
} from '../helpers/api';
import { createCaptureServer, fanoutHeader, openFanoutSettings } from '../helpers/fanout';
import {
createCaptureServer,
fanoutHeader,
openFanoutSettings,
startIntegrationDraft,
} from '../helpers/fanout';
test.describe('Apprise integration settings', () => {
let createdAppriseId: string | null = null;
@@ -35,9 +40,7 @@ test.describe('Apprise integration settings', () => {
await openFanoutSettings(page);
await expect(page.getByRole('status', { name: 'Radio OK' })).toBeVisible();
// Open add menu and pick Apprise
await page.getByRole('button', { name: 'Add Integration' }).click();
await page.getByRole('menuitem', { name: 'Apprise' }).click();
await startIntegrationDraft(page, 'Apprise');
// Should navigate to the detail/edit view with a numbered default name
await expect(page.locator('#fanout-edit-name')).toHaveValue(/Apprise #\d+/);

View File

@@ -1,9 +1,10 @@
import { test, expect } from '@playwright/test';
import {
ensureFlightlessChannel,
createFanoutConfig,
deleteFanoutConfig,
getFanoutConfigs,
} from '../helpers/api';
import { openFanoutSettings, startIntegrationDraft } from '../helpers/fanout';
const BOT_CODE = `def bot(sender_name, sender_key, message_text, is_dm, channel_key, channel_name, sender_timestamp, path):
if channel_name == "#flightless" and "!e2etest" in message_text.lower():
@@ -28,32 +29,35 @@ test.describe('Bot functionality', () => {
}
});
test('create a bot via API, verify it in UI, trigger it, and verify response', async ({
test('create a bot via UI, trigger it, and verify response', async ({
page,
}) => {
// --- Step 1: Create and enable bot via fanout API ---
const bot = await createFanoutConfig({
type: 'bot',
name: 'E2E Test Bot',
config: { code: BOT_CODE },
enabled: true,
});
createdBotId = bot.id;
// --- Step 2: Verify bot appears in settings UI ---
await page.goto('/');
await openFanoutSettings(page);
await expect(page.getByRole('status', { name: 'Radio OK' })).toBeVisible();
await page.getByText('Settings').click();
await page.getByRole('button', { name: /MQTT.*Automation/ }).click();
await startIntegrationDraft(page, 'Python Bot');
await expect(page.locator('#fanout-edit-name')).toHaveValue(/Python Bot #\d+/);
await page.locator('#fanout-edit-name').fill('E2E Test Bot');
const codeEditor = page.locator('[aria-label="Bot code editor"] [contenteditable]');
await codeEditor.click();
await codeEditor.press(process.platform === 'darwin' ? 'Meta+A' : 'Control+A');
await codeEditor.fill(BOT_CODE);
await page.getByRole('button', { name: /Save as Enabled/i }).click();
await expect(page.getByText('Integration saved and enabled')).toBeVisible();
// The bot name should be visible in the integration list
await expect(page.getByText('E2E Test Bot')).toBeVisible();
// Exit settings page mode
const configs = await getFanoutConfigs();
const createdBot = configs.find((config) => config.name === 'E2E Test Bot');
if (createdBot) {
createdBotId = createdBot.id;
}
await page.getByRole('button', { name: /Back to Chat/i }).click();
// --- Step 3: Trigger the bot ---
await page.getByText('#flightless', { exact: true }).first().click();
const triggerMessage = `!e2etest ${Date.now()}`;
@@ -61,8 +65,6 @@ test.describe('Bot functionality', () => {
await input.fill(triggerMessage);
await page.getByRole('button', { name: 'Send', exact: true }).click();
// --- Step 4: Verify bot response appears ---
// Bot has ~2s delay before responding, plus radio send time
await expect(page.getByText('[BOT] e2e-ok')).toBeVisible({ timeout: 30_000 });
});
});

View File

@@ -7,7 +7,7 @@ import { createChannel, getChannels, getMessages } from '../helpers/api';
* Timeout is 3 minutes to allow for intermittent traffic.
*/
const ROOMS = [
const CHANNELS = [
'#flightless', '#bot', '#snoco', '#skagit', '#edmonds', '#bachelorette',
'#emergency', '#furry', '#public', '#puppy', '#foobar', '#capitolhill',
'#hamradio', '#icewatch', '#saucefamily', '#scvsar', '#startrek', '#metalmusic',
@@ -39,14 +39,14 @@ test.describe('Incoming mesh messages', () => {
test.setTimeout(180_000);
test.beforeAll(async () => {
// Ensure all rooms exist — create any that are missing
// Ensure all channels exist — create any that are missing
const existing = await getChannels();
const existingNames = new Set(existing.map((c) => c.name));
for (const room of ROOMS) {
if (!existingNames.has(room)) {
for (const channel of CHANNELS) {
if (!existingNames.has(channel)) {
try {
await createChannel(room);
await createChannel(channel);
} catch {
// May already exist from a concurrent creation, ignore
}
@@ -54,7 +54,7 @@ test.describe('Incoming mesh messages', () => {
}
});
test('receive an incoming message in any room', { tag: '@mesh-traffic' }, async ({ page }) => {
test('receive an incoming message in any channel', { tag: '@mesh-traffic' }, async ({ page }) => {
// Nudge echo bot on #flightless — may generate an incoming packet quickly
await nudgeEchoBot();

View File

@@ -4,7 +4,12 @@ import {
deleteFanoutConfig,
getFanoutConfigs,
} from '../helpers/api';
import { createCaptureServer, fanoutHeader, openFanoutSettings } from '../helpers/fanout';
import {
createCaptureServer,
fanoutHeader,
openFanoutSettings,
startIntegrationDraft,
} from '../helpers/fanout';
test.describe('Webhook integration settings', () => {
let createdWebhookId: string | null = null;
@@ -35,9 +40,7 @@ test.describe('Webhook integration settings', () => {
await openFanoutSettings(page);
await expect(page.getByRole('status', { name: 'Radio OK' })).toBeVisible();
// Open add menu and pick Webhook
await page.getByRole('button', { name: 'Add Integration' }).click();
await page.getByRole('menuitem', { name: 'Webhook' }).click();
await startIntegrationDraft(page, 'Webhook');
// Should navigate to the detail/edit view with a numbered default name
await expect(page.locator('#fanout-edit-name')).toHaveValue(/Webhook #\d+/);
@@ -77,8 +80,7 @@ test.describe('Webhook integration settings', () => {
await openFanoutSettings(page);
await expect(page.getByRole('status', { name: 'Radio OK' })).toBeVisible();
await page.getByRole('button', { name: 'Add Integration' }).click();
await page.getByRole('menuitem', { name: 'Webhook' }).click();
await startIntegrationDraft(page, 'Webhook');
await expect(page.locator('#fanout-edit-name')).toHaveValue(/Webhook #\d+/);
await page.locator('#fanout-edit-name').fill('Unsaved Webhook Draft');