Use better behavior on disconnected radio and allow deeplinking into settings. Closes #66.

This commit is contained in:
Jack Kingsman
2026-03-16 17:46:12 -07:00
parent ffb5fa51c1
commit b68bfc41d6
14 changed files with 359 additions and 56 deletions

View File

@@ -12,6 +12,7 @@ from app.config import settings
from app.keystore import clear_keys
logger = logging.getLogger(__name__)
MAX_FRONTEND_RECONNECT_ERROR_BROADCASTS = 3
class RadioOperationError(RuntimeError):
@@ -131,6 +132,7 @@ class RadioManager:
self._setup_lock: asyncio.Lock | None = None
self._setup_in_progress: bool = False
self._setup_complete: bool = False
self._frontend_reconnect_error_broadcasts: int = 0
self.device_info_loaded: bool = False
self.max_contacts: int | None = None
self.device_model: str | None = None
@@ -387,6 +389,21 @@ class RadioManager:
self._last_connected = False
await self.disconnect()
def _reset_reconnect_error_broadcasts(self) -> None:
self._frontend_reconnect_error_broadcasts = 0
def _broadcast_reconnect_error_if_needed(self, details: str) -> None:
from app.websocket import broadcast_error
self._frontend_reconnect_error_broadcasts += 1
if self._frontend_reconnect_error_broadcasts > MAX_FRONTEND_RECONNECT_ERROR_BROADCASTS:
return
if self._frontend_reconnect_error_broadcasts == MAX_FRONTEND_RECONNECT_ERROR_BROADCASTS:
details = f"{details} Further reconnect failures will be logged only until a connection succeeds."
broadcast_error("Reconnection failed", details)
async def _disable_meshcore_auto_reconnect(self, mc: MeshCore) -> None:
"""Disable library-managed reconnects so manual teardown fully releases transport."""
connection_manager = getattr(mc, "connection_manager", None)
@@ -485,6 +502,7 @@ class RadioManager:
async def disconnect(self) -> None:
"""Disconnect from the radio."""
clear_keys()
self._reset_reconnect_error_broadcasts()
if self._meshcore is not None:
logger.debug("Disconnecting from radio")
mc = self._meshcore
@@ -511,7 +529,7 @@ class RadioManager:
Returns True if reconnection was successful, False otherwise.
Uses a lock to prevent concurrent reconnection attempts.
"""
from app.websocket import broadcast_error, broadcast_health
from app.websocket import broadcast_health
# Lazily initialize lock (can't create in __init__ before event loop exists)
if self._reconnect_lock is None:
@@ -549,6 +567,7 @@ class RadioManager:
if self.is_connected:
logger.info("Radio reconnected successfully at %s", self._connection_info)
self._reset_reconnect_error_broadcasts()
if broadcast_on_success:
broadcast_health(True, self._connection_info)
return True
@@ -558,7 +577,7 @@ class RadioManager:
except Exception as e:
logger.warning("Reconnection failed: %s", e, exc_info=True)
broadcast_error("Reconnection failed", str(e))
self._broadcast_reconnect_error_if_needed(str(e))
return False
async def start_connection_monitor(self) -> None:

View File

@@ -194,6 +194,7 @@ export function App() {
channels,
contacts,
contactsLoaded,
suspendHashSync: showSettings,
setSidebarOpen,
pendingDeleteFallbackRef,
hasSetDefaultConversation,
@@ -254,6 +255,12 @@ export function App() {
refreshUnreads,
} = useUnreadCounts(channels, contacts, activeConversation);
useEffect(() => {
if (showSettings && !config && settingsSection === 'radio') {
setSettingsSection('local');
}
}, [config, settingsSection, setSettingsSection, showSettings]);
useEffect(() => {
if (activeConversation?.type !== 'channel') {
setChannelUnreadMarker(null);
@@ -566,6 +573,7 @@ export function App() {
settingsSection={settingsSection}
sidebarOpen={sidebarOpen}
showCracker={showCracker}
disabledSettingsSections={config ? [] : ['radio']}
onSettingsSectionChange={setSettingsSection}
onSidebarOpenChange={setSidebarOpen}
onCrackerRunningChange={setCrackerRunning}

View File

@@ -41,6 +41,7 @@ interface AppShellProps {
settingsSection: SettingsSection;
sidebarOpen: boolean;
showCracker: boolean;
disabledSettingsSections?: SettingsSection[];
onSettingsSectionChange: (section: SettingsSection) => void;
onSidebarOpenChange: (open: boolean) => void;
onCrackerRunningChange: (running: boolean) => void;
@@ -69,6 +70,7 @@ export function AppShell({
settingsSection,
sidebarOpen,
showCracker,
disabledSettingsSections = [],
onSettingsSectionChange,
onSidebarOpenChange,
onCrackerRunningChange,
@@ -118,13 +120,16 @@ export function AppShell({
<div className="flex-1 min-h-0 overflow-y-auto py-1 [contain:layout_paint]">
{SETTINGS_SECTION_ORDER.map((section) => {
const Icon = SETTINGS_SECTION_ICONS[section];
const disabled = disabledSettingsSections.includes(section);
return (
<button
key={section}
type="button"
disabled={disabled}
className={cn(
'w-full px-3 py-2 text-left text-[13px] border-l-2 border-transparent hover:bg-accent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset',
settingsSection === section && 'bg-accent border-l-primary'
'w-full px-3 py-2 text-left text-[13px] border-l-2 border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset disabled:cursor-not-allowed disabled:opacity-50',
!disabled && 'hover:bg-accent',
settingsSection === section && !disabled && 'bg-accent border-l-primary'
)}
aria-current={settingsSection === section ? 'true' : undefined}
onClick={() => onSettingsSectionChange(section)}

View File

@@ -155,11 +155,13 @@ export function SettingsModal(props: SettingsModalProps) {
const renderSectionHeader = (section: SettingsSection): ReactNode => {
if (!showSectionButton) return null;
const Icon = SETTINGS_SECTION_ICONS[section];
const disabled = section === 'radio' && !config;
return (
<button
type="button"
className={sectionButtonClasses}
className={`${sectionButtonClasses} disabled:cursor-not-allowed disabled:opacity-50`}
aria-expanded={expandedSections[section]}
disabled={disabled}
onClick={() => toggleSection(section)}
>
<span className="inline-flex items-center gap-2 font-medium" role="heading" aria-level={3}>
@@ -177,14 +179,13 @@ export function SettingsModal(props: SettingsModalProps) {
return null;
}
return !config ? (
<div className="py-8 text-center text-muted-foreground">Loading configuration...</div>
) : (
return (
<div className={settingsContainerClass}>
{shouldRenderSection('radio') && (
<section className={sectionWrapperClass}>
{renderSectionHeader('radio')}
{isSectionVisible('radio') && appSettings && (
{isSectionVisible('radio') &&
(config && appSettings ? (
<SettingsRadioSection
config={config}
health={health}
@@ -203,7 +204,13 @@ export function SettingsModal(props: SettingsModalProps) {
onClose={onClose}
className={sectionContentClass}
/>
)}
) : (
<div className={sectionContentClass}>
<div className="rounded-md border border-input bg-muted/20 px-4 py-3 text-sm text-muted-foreground">
Radio settings are unavailable until a radio connects.
</div>
</div>
))}
</section>
)}
@@ -222,7 +229,8 @@ export function SettingsModal(props: SettingsModalProps) {
{shouldRenderSection('database') && (
<section className={sectionWrapperClass}>
{renderSectionHeader('database')}
{isSectionVisible('database') && appSettings && (
{isSectionVisible('database') &&
(appSettings ? (
<SettingsDatabaseSection
appSettings={appSettings}
health={health}
@@ -234,7 +242,13 @@ export function SettingsModal(props: SettingsModalProps) {
onToggleBlockedName={onToggleBlockedName}
className={sectionContentClass}
/>
)}
) : (
<div className={sectionContentClass}>
<div className="rounded-md border border-input bg-muted/20 px-4 py-3 text-sm text-muted-foreground">
Loading app settings...
</div>
</div>
))}
</section>
)}

View File

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

View File

@@ -1,7 +1,8 @@
import { startTransition, useCallback, useState } from 'react';
import { startTransition, useCallback, useEffect, useRef, useState } from 'react';
import { getLocalLabel, type LocalLabel } from '../utils/localLabel';
import type { SettingsSection } from '../components/settings/settingsConstants';
import { parseHashSettingsSection, updateSettingsHash } from '../utils/urlHash';
interface UseAppShellResult {
showNewMessage: boolean;
@@ -23,25 +24,47 @@ interface UseAppShellResult {
}
export function useAppShell(): UseAppShellResult {
const initialSettingsSection = typeof window === 'undefined' ? null : parseHashSettingsSection();
const [showNewMessage, setShowNewMessage] = useState(false);
const [showSettings, setShowSettings] = useState(false);
const [settingsSection, setSettingsSection] = useState<SettingsSection>('radio');
const [showSettings, setShowSettings] = useState(() => initialSettingsSection !== null);
const [settingsSection, setSettingsSection] = useState<SettingsSection>(
() => initialSettingsSection ?? 'radio'
);
const [sidebarOpen, setSidebarOpen] = useState(false);
const [showCracker, setShowCracker] = useState(false);
const [crackerRunning, setCrackerRunning] = useState(false);
const [localLabel, setLocalLabel] = useState(getLocalLabel);
const previousHashRef = useRef('');
useEffect(() => {
if (showSettings) {
updateSettingsHash(settingsSection);
}
}, [settingsSection, showSettings]);
const handleCloseSettingsView = useCallback(() => {
if (typeof window !== 'undefined' && parseHashSettingsSection() !== null) {
window.history.replaceState(null, '', previousHashRef.current || window.location.pathname);
}
startTransition(() => setShowSettings(false));
setSidebarOpen(false);
}, []);
const handleToggleSettingsView = useCallback(() => {
if (showSettings) {
handleCloseSettingsView();
return;
}
if (typeof window !== 'undefined') {
previousHashRef.current =
parseHashSettingsSection() === null ? window.location.hash : previousHashRef.current;
}
startTransition(() => {
setShowSettings((prev) => !prev);
setShowSettings(true);
});
setSidebarOpen(false);
}, []);
}, [handleCloseSettingsView, showSettings]);
const handleOpenNewMessage = useCallback(() => {
setShowNewMessage(true);

View File

@@ -1,6 +1,7 @@
import { useState, useCallback, useEffect, useRef, type MutableRefObject } from 'react';
import {
parseHashConversation,
parseHashSettingsSection,
updateUrlHash,
resolveChannelFromHashToken,
resolveContactFromHashToken,
@@ -18,6 +19,7 @@ interface UseConversationRouterArgs {
channels: Channel[];
contacts: Contact[];
contactsLoaded: boolean;
suspendHashSync: boolean;
setSidebarOpen: (open: boolean) => void;
pendingDeleteFallbackRef: MutableRefObject<boolean>;
hasSetDefaultConversation: MutableRefObject<boolean>;
@@ -27,6 +29,7 @@ export function useConversationRouter({
channels,
contacts,
contactsLoaded,
suspendHashSync,
setSidebarOpen,
pendingDeleteFallbackRef,
hasSetDefaultConversation,
@@ -34,7 +37,9 @@ export function useConversationRouter({
const [activeConversation, setActiveConversationState] = useState<Conversation | null>(null);
const activeConversationRef = useRef<Conversation | null>(null);
const hashSyncEnabledRef = useRef(
typeof window !== 'undefined' ? window.location.hash.length > 0 : false
typeof window !== 'undefined'
? window.location.hash.length > 0 && parseHashSettingsSection() === null
: false
);
const setActiveConversation = useCallback((conv: Conversation | null) => {
@@ -58,7 +63,7 @@ export function useConversationRouter({
if (hasSetDefaultConversation.current || activeConversation) return;
if (channels.length === 0) return;
const hashConv = parseHashConversation();
const hashConv = parseHashSettingsSection() ? null : parseHashConversation();
// Handle non-data views immediately
if (hashConv?.type === 'raw') {
@@ -141,7 +146,7 @@ export function useConversationRouter({
useEffect(() => {
if (hasSetDefaultConversation.current || activeConversation) return;
const hashConv = parseHashConversation();
const hashConv = parseHashSettingsSection() ? null : parseHashConversation();
if (hashConv?.type === 'contact') {
if (!contactsLoaded) return;
@@ -203,14 +208,14 @@ export function useConversationRouter({
useEffect(() => {
activeConversationRef.current = activeConversation;
if (activeConversation) {
if (hashSyncEnabledRef.current) {
if (hashSyncEnabledRef.current && !suspendHashSync) {
updateUrlHash(activeConversation);
}
if (getReopenLastConversationEnabled() && activeConversation.type !== 'search') {
saveLastViewedConversation(activeConversation);
}
}
}, [activeConversation]);
}, [activeConversation, suspendHashSync]);
// If a delete action left us without an active conversation, recover to Public
useEffect(() => {

View File

@@ -164,7 +164,10 @@ vi.mock('../components/ui/sonner', () => ({
vi.mock('../utils/urlHash', () => ({
parseHashConversation: () => null,
parseHashSettingsSection: () => null,
updateUrlHash: vi.fn(),
updateSettingsHash: vi.fn(),
getSettingsHash: (section: string) => `#settings/${section}`,
getMapFocusHash: () => '#map',
}));

View File

@@ -90,7 +90,9 @@ vi.mock('../components/NewMessageModal', () => ({
}));
vi.mock('../components/SettingsModal', () => ({
SettingsModal: () => null,
SettingsModal: ({ desktopSection }: { desktopSection?: string }) => (
<div data-testid="settings-modal-section">{desktopSection ?? 'none'}</div>
),
SETTINGS_SECTION_ORDER: ['radio', 'local', 'database', 'bot'],
SETTINGS_SECTION_LABELS: {
radio: 'Radio',
@@ -293,4 +295,20 @@ describe('App startup hash resolution', () => {
});
expect(window.location.hash).toBe('');
});
it('opens settings from a settings hash and falls back away from radio when disconnected', async () => {
window.location.hash = '#settings/radio';
mocks.api.getRadioConfig.mockRejectedValue(new Error('radio offline'));
render(<App />);
await waitFor(() => {
expect(screen.getByTestId('settings-modal-section')).toHaveTextContent('local');
});
for (const button of screen.getAllByRole('button', { name: 'Radio' })) {
expect(button).toBeDisabled();
}
expect(window.location.hash).toBe('#settings/local');
});
});

View File

@@ -63,6 +63,7 @@ const baseSettings: AppSettings = {
};
function renderModal(overrides?: {
config?: RadioConfig | null;
appSettings?: AppSettings;
health?: HealthStatus;
onSaveAppSettings?: (update: AppSettingsUpdate) => Promise<void>;
@@ -97,7 +98,7 @@ function renderModal(overrides?: {
const commonProps = {
open: overrides?.open ?? true,
pageMode: overrides?.pageMode,
config: baseConfig,
config: overrides?.config === undefined ? baseConfig : overrides.config,
health: overrides?.health ?? baseHealth,
appSettings: overrides?.appSettings ?? baseSettings,
onClose,
@@ -205,6 +206,32 @@ describe('SettingsModal', () => {
expect(screen.getByText(/Configured radio contact capacity/i)).toBeInTheDocument();
});
it('keeps non-radio settings available when radio config is unavailable', () => {
renderModal({ config: null });
const radioToggle = screen.getByRole('button', { name: /Radio/i });
expect(radioToggle).toBeDisabled();
openLocalSection();
expect(screen.getByLabelText('Local label text')).toBeInTheDocument();
openDatabaseSection();
expect(screen.getByText('Delete Undecrypted Packets')).toBeInTheDocument();
});
it('shows a radio-unavailable message instead of blocking the whole settings page', () => {
renderModal({
config: null,
externalSidebarNav: true,
desktopSection: 'radio',
});
expect(
screen.getByText('Radio settings are unavailable until a radio connects.')
).toBeInTheDocument();
expect(screen.queryByText('Loading configuration...')).not.toBeInTheDocument();
});
it('shows cached radio firmware and capacity info under the connection status', () => {
renderModal({
health: {

View File

@@ -8,6 +8,8 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import {
parseHashConversation,
parseHashSettingsSection,
getSettingsHash,
getMapFocusHash,
resolveChannelFromHashToken,
resolveContactFromHashToken,
@@ -147,6 +149,34 @@ describe('parseHashConversation', () => {
});
});
describe('settings URL hashes', () => {
let originalHash: string;
beforeEach(() => {
originalHash = window.location.hash;
});
afterEach(() => {
window.location.hash = originalHash;
});
it('parses a valid settings section hash', () => {
window.location.hash = '#settings/database';
expect(parseHashSettingsSection()).toBe('database');
});
it('returns null for an invalid settings section hash', () => {
window.location.hash = '#settings/not-a-section';
expect(parseHashSettingsSection()).toBeNull();
});
it('builds a stable settings hash', () => {
expect(getSettingsHash('local')).toBe('#settings/local');
});
});
describe('resolveChannelFromHashToken', () => {
const channels: Channel[] = [
{

View File

@@ -1,9 +1,19 @@
import { act, renderHook } from '@testing-library/react';
import { describe, expect, it } from 'vitest';
import { act, renderHook, waitFor } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { useAppShell } from '../hooks/useAppShell';
describe('useAppShell', () => {
let originalHash: string;
beforeEach(() => {
originalHash = window.location.hash;
});
afterEach(() => {
window.location.hash = originalHash;
});
it('opens new-message modal and closes the sidebar', () => {
const { result } = renderHook(() => useAppShell());
@@ -34,6 +44,55 @@ describe('useAppShell', () => {
expect(result.current.showSettings).toBe(false);
});
it('initializes settings mode from the URL hash', () => {
window.location.hash = '#settings/database';
const { result } = renderHook(() => useAppShell());
expect(result.current.showSettings).toBe(true);
expect(result.current.settingsSection).toBe('database');
});
it('syncs the selected settings section into the URL hash', async () => {
const { result } = renderHook(() => useAppShell());
act(() => {
result.current.handleToggleSettingsView();
});
await waitFor(() => {
expect(window.location.hash).toBe('#settings/radio');
});
act(() => {
result.current.setSettingsSection('fanout');
});
await waitFor(() => {
expect(window.location.hash).toBe('#settings/fanout');
});
});
it('restores the previous hash when settings close', async () => {
window.location.hash = '#channel/test/Public';
const { result } = renderHook(() => useAppShell());
act(() => {
result.current.handleToggleSettingsView();
});
await waitFor(() => {
expect(window.location.hash).toBe('#settings/radio');
});
act(() => {
result.current.handleCloseSettingsView();
});
expect(window.location.hash).toBe('#channel/test/Public');
});
it('toggles the cracker shell without affecting sidebar state', () => {
const { result } = renderHook(() => useAppShell());

View File

@@ -1,6 +1,7 @@
import type { Channel, Contact, Conversation } from '../types';
import { findPublicChannel, PUBLIC_CHANNEL_NAME } from './publicChannel';
import { getContactDisplayName } from './pubkey';
import type { SettingsSection } from '../components/settings/settingsConstants';
interface ParsedHashConversation {
type: 'channel' | 'contact' | 'raw' | 'map' | 'visualizer' | 'search';
@@ -12,6 +13,15 @@ interface ParsedHashConversation {
mapFocusKey?: string;
}
const SETTINGS_SECTIONS: SettingsSection[] = [
'radio',
'local',
'fanout',
'database',
'statistics',
'about',
];
// Parse URL hash to get conversation
// (e.g., #channel/ABCDEF0123456789ABCDEF0123456789 or #contact/<64-char-pubkey>).
export function parseHashConversation(): ParsedHashConversation | null {
@@ -70,6 +80,20 @@ export function parseHashConversation(): ParsedHashConversation | null {
};
}
export function parseHashSettingsSection(): SettingsSection | null {
const hash = window.location.hash.slice(1);
if (!hash.startsWith('settings/')) {
return null;
}
const section = decodeURIComponent(hash.slice('settings/'.length)) as SettingsSection;
return SETTINGS_SECTIONS.includes(section) ? section : null;
}
export function getSettingsHash(section: SettingsSection): string {
return `#settings/${encodeURIComponent(section)}`;
}
export function resolveChannelFromHashToken(token: string, channels: Channel[]): Channel | null {
const normalizedToken = token.trim();
if (!normalizedToken) return null;
@@ -141,3 +165,10 @@ export function updateUrlHash(conv: Conversation | null): void {
window.history.replaceState(null, '', newHash || window.location.pathname);
}
}
export function updateSettingsHash(section: SettingsSection): void {
const newHash = getSettingsHash(section);
if (newHash !== window.location.hash) {
window.history.replaceState(null, '', newHash);
}
}

View File

@@ -449,6 +449,66 @@ class TestReconnectLock:
assert result is False
rm.connect.assert_not_called()
@pytest.mark.asyncio
async def test_reconnect_broadcasts_only_first_three_failures(self):
"""Frontend only sees the first few reconnect failures before suppression kicks in."""
from app.radio import MAX_FRONTEND_RECONNECT_ERROR_BROADCASTS, RadioManager
rm = RadioManager()
rm.connect = AsyncMock(side_effect=RuntimeError("radio unavailable"))
with (
patch("app.websocket.broadcast_health"),
patch("app.websocket.broadcast_error") as mock_broadcast_error,
):
for _ in range(MAX_FRONTEND_RECONNECT_ERROR_BROADCASTS + 2):
result = await rm.reconnect(broadcast_on_success=False)
assert result is False
assert mock_broadcast_error.call_count == MAX_FRONTEND_RECONNECT_ERROR_BROADCASTS
assert mock_broadcast_error.call_args_list[0].args == (
"Reconnection failed",
"radio unavailable",
)
assert mock_broadcast_error.call_args_list[-1].args == (
"Reconnection failed",
"radio unavailable Further reconnect failures will be logged only until a connection succeeds.",
)
@pytest.mark.asyncio
async def test_reconnect_success_resets_error_broadcast_suppression(self):
"""A successful reconnect re-enables frontend error broadcasts for later failures."""
from app.radio import RadioManager
rm = RadioManager()
attempts = 0
async def mock_connect():
nonlocal attempts
attempts += 1
if attempts in (1, 2, 4):
raise RuntimeError("radio unavailable")
mock_mc = MagicMock()
mock_mc.is_connected = True
rm._meshcore = mock_mc
rm._connection_info = "TCP: test:4000"
rm.connect = AsyncMock(side_effect=mock_connect)
with (
patch("app.websocket.broadcast_health"),
patch("app.websocket.broadcast_error") as mock_broadcast_error,
):
assert await rm.reconnect(broadcast_on_success=False) is False
assert await rm.reconnect(broadcast_on_success=False) is False
assert await rm.reconnect(broadcast_on_success=False) is True
rm._meshcore = None
assert await rm.reconnect(broadcast_on_success=False) is False
assert mock_broadcast_error.call_count == 3
for call in mock_broadcast_error.call_args_list:
assert call.args == ("Reconnection failed", "radio unavailable")
class TestManualDisconnectCleanup:
"""Tests for manual disconnect teardown behavior."""