20 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
19 changed files with 808 additions and 126 deletions

View File

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

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

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

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

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

View File

@@ -505,7 +505,7 @@ export function CrackerPanel({
? 'GPU Not Available'
: !wordlistLoaded
? 'Loading dictionary...'
: 'Find Rooms'}
: 'Find Channels'}
</button>
{/* Status */}

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

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

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

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

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

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

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

@@ -40,7 +40,7 @@ test.describe('Bot functionality', () => {
await page.locator('#fanout-edit-name').fill('E2E Test Bot');
const codeEditor = page.getByLabel('Bot code editor');
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);