mirror of
https://github.com/jkingsman/Remote-Terminal-for-MeshCore.git
synced 2026-03-28 17:43:05 +01:00
Use better behavior on disconnected radio and allow deeplinking into settings. Closes #66.
This commit is contained in:
23
app/radio.py
23
app/radio.py
@@ -12,6 +12,7 @@ from app.config import settings
|
|||||||
from app.keystore import clear_keys
|
from app.keystore import clear_keys
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
MAX_FRONTEND_RECONNECT_ERROR_BROADCASTS = 3
|
||||||
|
|
||||||
|
|
||||||
class RadioOperationError(RuntimeError):
|
class RadioOperationError(RuntimeError):
|
||||||
@@ -131,6 +132,7 @@ class RadioManager:
|
|||||||
self._setup_lock: asyncio.Lock | None = None
|
self._setup_lock: asyncio.Lock | None = None
|
||||||
self._setup_in_progress: bool = False
|
self._setup_in_progress: bool = False
|
||||||
self._setup_complete: bool = False
|
self._setup_complete: bool = False
|
||||||
|
self._frontend_reconnect_error_broadcasts: int = 0
|
||||||
self.device_info_loaded: bool = False
|
self.device_info_loaded: bool = False
|
||||||
self.max_contacts: int | None = None
|
self.max_contacts: int | None = None
|
||||||
self.device_model: str | None = None
|
self.device_model: str | None = None
|
||||||
@@ -387,6 +389,21 @@ class RadioManager:
|
|||||||
self._last_connected = False
|
self._last_connected = False
|
||||||
await self.disconnect()
|
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:
|
async def _disable_meshcore_auto_reconnect(self, mc: MeshCore) -> None:
|
||||||
"""Disable library-managed reconnects so manual teardown fully releases transport."""
|
"""Disable library-managed reconnects so manual teardown fully releases transport."""
|
||||||
connection_manager = getattr(mc, "connection_manager", None)
|
connection_manager = getattr(mc, "connection_manager", None)
|
||||||
@@ -485,6 +502,7 @@ class RadioManager:
|
|||||||
async def disconnect(self) -> None:
|
async def disconnect(self) -> None:
|
||||||
"""Disconnect from the radio."""
|
"""Disconnect from the radio."""
|
||||||
clear_keys()
|
clear_keys()
|
||||||
|
self._reset_reconnect_error_broadcasts()
|
||||||
if self._meshcore is not None:
|
if self._meshcore is not None:
|
||||||
logger.debug("Disconnecting from radio")
|
logger.debug("Disconnecting from radio")
|
||||||
mc = self._meshcore
|
mc = self._meshcore
|
||||||
@@ -511,7 +529,7 @@ class RadioManager:
|
|||||||
Returns True if reconnection was successful, False otherwise.
|
Returns True if reconnection was successful, False otherwise.
|
||||||
Uses a lock to prevent concurrent reconnection attempts.
|
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)
|
# Lazily initialize lock (can't create in __init__ before event loop exists)
|
||||||
if self._reconnect_lock is None:
|
if self._reconnect_lock is None:
|
||||||
@@ -549,6 +567,7 @@ class RadioManager:
|
|||||||
|
|
||||||
if self.is_connected:
|
if self.is_connected:
|
||||||
logger.info("Radio reconnected successfully at %s", self._connection_info)
|
logger.info("Radio reconnected successfully at %s", self._connection_info)
|
||||||
|
self._reset_reconnect_error_broadcasts()
|
||||||
if broadcast_on_success:
|
if broadcast_on_success:
|
||||||
broadcast_health(True, self._connection_info)
|
broadcast_health(True, self._connection_info)
|
||||||
return True
|
return True
|
||||||
@@ -558,7 +577,7 @@ class RadioManager:
|
|||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning("Reconnection failed: %s", e, exc_info=True)
|
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
|
return False
|
||||||
|
|
||||||
async def start_connection_monitor(self) -> None:
|
async def start_connection_monitor(self) -> None:
|
||||||
|
|||||||
@@ -194,6 +194,7 @@ export function App() {
|
|||||||
channels,
|
channels,
|
||||||
contacts,
|
contacts,
|
||||||
contactsLoaded,
|
contactsLoaded,
|
||||||
|
suspendHashSync: showSettings,
|
||||||
setSidebarOpen,
|
setSidebarOpen,
|
||||||
pendingDeleteFallbackRef,
|
pendingDeleteFallbackRef,
|
||||||
hasSetDefaultConversation,
|
hasSetDefaultConversation,
|
||||||
@@ -254,6 +255,12 @@ export function App() {
|
|||||||
refreshUnreads,
|
refreshUnreads,
|
||||||
} = useUnreadCounts(channels, contacts, activeConversation);
|
} = useUnreadCounts(channels, contacts, activeConversation);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (showSettings && !config && settingsSection === 'radio') {
|
||||||
|
setSettingsSection('local');
|
||||||
|
}
|
||||||
|
}, [config, settingsSection, setSettingsSection, showSettings]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (activeConversation?.type !== 'channel') {
|
if (activeConversation?.type !== 'channel') {
|
||||||
setChannelUnreadMarker(null);
|
setChannelUnreadMarker(null);
|
||||||
@@ -566,6 +573,7 @@ export function App() {
|
|||||||
settingsSection={settingsSection}
|
settingsSection={settingsSection}
|
||||||
sidebarOpen={sidebarOpen}
|
sidebarOpen={sidebarOpen}
|
||||||
showCracker={showCracker}
|
showCracker={showCracker}
|
||||||
|
disabledSettingsSections={config ? [] : ['radio']}
|
||||||
onSettingsSectionChange={setSettingsSection}
|
onSettingsSectionChange={setSettingsSection}
|
||||||
onSidebarOpenChange={setSidebarOpen}
|
onSidebarOpenChange={setSidebarOpen}
|
||||||
onCrackerRunningChange={setCrackerRunning}
|
onCrackerRunningChange={setCrackerRunning}
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ interface AppShellProps {
|
|||||||
settingsSection: SettingsSection;
|
settingsSection: SettingsSection;
|
||||||
sidebarOpen: boolean;
|
sidebarOpen: boolean;
|
||||||
showCracker: boolean;
|
showCracker: boolean;
|
||||||
|
disabledSettingsSections?: SettingsSection[];
|
||||||
onSettingsSectionChange: (section: SettingsSection) => void;
|
onSettingsSectionChange: (section: SettingsSection) => void;
|
||||||
onSidebarOpenChange: (open: boolean) => void;
|
onSidebarOpenChange: (open: boolean) => void;
|
||||||
onCrackerRunningChange: (running: boolean) => void;
|
onCrackerRunningChange: (running: boolean) => void;
|
||||||
@@ -69,6 +70,7 @@ export function AppShell({
|
|||||||
settingsSection,
|
settingsSection,
|
||||||
sidebarOpen,
|
sidebarOpen,
|
||||||
showCracker,
|
showCracker,
|
||||||
|
disabledSettingsSections = [],
|
||||||
onSettingsSectionChange,
|
onSettingsSectionChange,
|
||||||
onSidebarOpenChange,
|
onSidebarOpenChange,
|
||||||
onCrackerRunningChange,
|
onCrackerRunningChange,
|
||||||
@@ -118,13 +120,16 @@ export function AppShell({
|
|||||||
<div className="flex-1 min-h-0 overflow-y-auto py-1 [contain:layout_paint]">
|
<div className="flex-1 min-h-0 overflow-y-auto py-1 [contain:layout_paint]">
|
||||||
{SETTINGS_SECTION_ORDER.map((section) => {
|
{SETTINGS_SECTION_ORDER.map((section) => {
|
||||||
const Icon = SETTINGS_SECTION_ICONS[section];
|
const Icon = SETTINGS_SECTION_ICONS[section];
|
||||||
|
const disabled = disabledSettingsSections.includes(section);
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={section}
|
key={section}
|
||||||
type="button"
|
type="button"
|
||||||
|
disabled={disabled}
|
||||||
className={cn(
|
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',
|
'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',
|
||||||
settingsSection === section && 'bg-accent border-l-primary'
|
!disabled && 'hover:bg-accent',
|
||||||
|
settingsSection === section && !disabled && 'bg-accent border-l-primary'
|
||||||
)}
|
)}
|
||||||
aria-current={settingsSection === section ? 'true' : undefined}
|
aria-current={settingsSection === section ? 'true' : undefined}
|
||||||
onClick={() => onSettingsSectionChange(section)}
|
onClick={() => onSettingsSectionChange(section)}
|
||||||
|
|||||||
@@ -155,11 +155,13 @@ export function SettingsModal(props: SettingsModalProps) {
|
|||||||
const renderSectionHeader = (section: SettingsSection): ReactNode => {
|
const renderSectionHeader = (section: SettingsSection): ReactNode => {
|
||||||
if (!showSectionButton) return null;
|
if (!showSectionButton) return null;
|
||||||
const Icon = SETTINGS_SECTION_ICONS[section];
|
const Icon = SETTINGS_SECTION_ICONS[section];
|
||||||
|
const disabled = section === 'radio' && !config;
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={sectionButtonClasses}
|
className={`${sectionButtonClasses} disabled:cursor-not-allowed disabled:opacity-50`}
|
||||||
aria-expanded={expandedSections[section]}
|
aria-expanded={expandedSections[section]}
|
||||||
|
disabled={disabled}
|
||||||
onClick={() => toggleSection(section)}
|
onClick={() => toggleSection(section)}
|
||||||
>
|
>
|
||||||
<span className="inline-flex items-center gap-2 font-medium" role="heading" aria-level={3}>
|
<span className="inline-flex items-center gap-2 font-medium" role="heading" aria-level={3}>
|
||||||
@@ -177,33 +179,38 @@ export function SettingsModal(props: SettingsModalProps) {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return !config ? (
|
return (
|
||||||
<div className="py-8 text-center text-muted-foreground">Loading configuration...</div>
|
|
||||||
) : (
|
|
||||||
<div className={settingsContainerClass}>
|
<div className={settingsContainerClass}>
|
||||||
{shouldRenderSection('radio') && (
|
{shouldRenderSection('radio') && (
|
||||||
<section className={sectionWrapperClass}>
|
<section className={sectionWrapperClass}>
|
||||||
{renderSectionHeader('radio')}
|
{renderSectionHeader('radio')}
|
||||||
{isSectionVisible('radio') && appSettings && (
|
{isSectionVisible('radio') &&
|
||||||
<SettingsRadioSection
|
(config && appSettings ? (
|
||||||
config={config}
|
<SettingsRadioSection
|
||||||
health={health}
|
config={config}
|
||||||
appSettings={appSettings}
|
health={health}
|
||||||
pageMode={pageMode}
|
appSettings={appSettings}
|
||||||
onSave={onSave}
|
pageMode={pageMode}
|
||||||
onSaveAppSettings={onSaveAppSettings}
|
onSave={onSave}
|
||||||
onSetPrivateKey={onSetPrivateKey}
|
onSaveAppSettings={onSaveAppSettings}
|
||||||
onReboot={onReboot}
|
onSetPrivateKey={onSetPrivateKey}
|
||||||
onDisconnect={onDisconnect}
|
onReboot={onReboot}
|
||||||
onReconnect={onReconnect}
|
onDisconnect={onDisconnect}
|
||||||
onAdvertise={onAdvertise}
|
onReconnect={onReconnect}
|
||||||
meshDiscovery={meshDiscovery}
|
onAdvertise={onAdvertise}
|
||||||
meshDiscoveryLoadingTarget={meshDiscoveryLoadingTarget}
|
meshDiscovery={meshDiscovery}
|
||||||
onDiscoverMesh={onDiscoverMesh}
|
meshDiscoveryLoadingTarget={meshDiscoveryLoadingTarget}
|
||||||
onClose={onClose}
|
onDiscoverMesh={onDiscoverMesh}
|
||||||
className={sectionContentClass}
|
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>
|
</section>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -222,19 +229,26 @@ export function SettingsModal(props: SettingsModalProps) {
|
|||||||
{shouldRenderSection('database') && (
|
{shouldRenderSection('database') && (
|
||||||
<section className={sectionWrapperClass}>
|
<section className={sectionWrapperClass}>
|
||||||
{renderSectionHeader('database')}
|
{renderSectionHeader('database')}
|
||||||
{isSectionVisible('database') && appSettings && (
|
{isSectionVisible('database') &&
|
||||||
<SettingsDatabaseSection
|
(appSettings ? (
|
||||||
appSettings={appSettings}
|
<SettingsDatabaseSection
|
||||||
health={health}
|
appSettings={appSettings}
|
||||||
onSaveAppSettings={onSaveAppSettings}
|
health={health}
|
||||||
onHealthRefresh={onHealthRefresh}
|
onSaveAppSettings={onSaveAppSettings}
|
||||||
blockedKeys={blockedKeys}
|
onHealthRefresh={onHealthRefresh}
|
||||||
blockedNames={blockedNames}
|
blockedKeys={blockedKeys}
|
||||||
onToggleBlockedKey={onToggleBlockedKey}
|
blockedNames={blockedNames}
|
||||||
onToggleBlockedName={onToggleBlockedName}
|
onToggleBlockedKey={onToggleBlockedKey}
|
||||||
className={sectionContentClass}
|
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>
|
</section>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -173,7 +173,8 @@ export function SettingsDatabaseSection({
|
|||||||
Deletes archival copies of raw packet bytes for messages that are already decrypted and
|
Deletes archival copies of raw packet bytes for messages that are already decrypted and
|
||||||
visible in your chat history.{' '}
|
visible in your chat history.{' '}
|
||||||
<em className="text-muted-foreground/80">
|
<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>{' '}
|
</em>{' '}
|
||||||
The raw bytes are only useful for manual packet analysis.
|
The raw bytes are only useful for manual packet analysis.
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -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 { getLocalLabel, type LocalLabel } from '../utils/localLabel';
|
||||||
import type { SettingsSection } from '../components/settings/settingsConstants';
|
import type { SettingsSection } from '../components/settings/settingsConstants';
|
||||||
|
import { parseHashSettingsSection, updateSettingsHash } from '../utils/urlHash';
|
||||||
|
|
||||||
interface UseAppShellResult {
|
interface UseAppShellResult {
|
||||||
showNewMessage: boolean;
|
showNewMessage: boolean;
|
||||||
@@ -23,25 +24,47 @@ interface UseAppShellResult {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function useAppShell(): UseAppShellResult {
|
export function useAppShell(): UseAppShellResult {
|
||||||
|
const initialSettingsSection = typeof window === 'undefined' ? null : parseHashSettingsSection();
|
||||||
const [showNewMessage, setShowNewMessage] = useState(false);
|
const [showNewMessage, setShowNewMessage] = useState(false);
|
||||||
const [showSettings, setShowSettings] = useState(false);
|
const [showSettings, setShowSettings] = useState(() => initialSettingsSection !== null);
|
||||||
const [settingsSection, setSettingsSection] = useState<SettingsSection>('radio');
|
const [settingsSection, setSettingsSection] = useState<SettingsSection>(
|
||||||
|
() => initialSettingsSection ?? 'radio'
|
||||||
|
);
|
||||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||||
const [showCracker, setShowCracker] = useState(false);
|
const [showCracker, setShowCracker] = useState(false);
|
||||||
const [crackerRunning, setCrackerRunning] = useState(false);
|
const [crackerRunning, setCrackerRunning] = useState(false);
|
||||||
const [localLabel, setLocalLabel] = useState(getLocalLabel);
|
const [localLabel, setLocalLabel] = useState(getLocalLabel);
|
||||||
|
const previousHashRef = useRef('');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (showSettings) {
|
||||||
|
updateSettingsHash(settingsSection);
|
||||||
|
}
|
||||||
|
}, [settingsSection, showSettings]);
|
||||||
|
|
||||||
const handleCloseSettingsView = useCallback(() => {
|
const handleCloseSettingsView = useCallback(() => {
|
||||||
|
if (typeof window !== 'undefined' && parseHashSettingsSection() !== null) {
|
||||||
|
window.history.replaceState(null, '', previousHashRef.current || window.location.pathname);
|
||||||
|
}
|
||||||
startTransition(() => setShowSettings(false));
|
startTransition(() => setShowSettings(false));
|
||||||
setSidebarOpen(false);
|
setSidebarOpen(false);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleToggleSettingsView = useCallback(() => {
|
const handleToggleSettingsView = useCallback(() => {
|
||||||
|
if (showSettings) {
|
||||||
|
handleCloseSettingsView();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
previousHashRef.current =
|
||||||
|
parseHashSettingsSection() === null ? window.location.hash : previousHashRef.current;
|
||||||
|
}
|
||||||
startTransition(() => {
|
startTransition(() => {
|
||||||
setShowSettings((prev) => !prev);
|
setShowSettings(true);
|
||||||
});
|
});
|
||||||
setSidebarOpen(false);
|
setSidebarOpen(false);
|
||||||
}, []);
|
}, [handleCloseSettingsView, showSettings]);
|
||||||
|
|
||||||
const handleOpenNewMessage = useCallback(() => {
|
const handleOpenNewMessage = useCallback(() => {
|
||||||
setShowNewMessage(true);
|
setShowNewMessage(true);
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useState, useCallback, useEffect, useRef, type MutableRefObject } from 'react';
|
import { useState, useCallback, useEffect, useRef, type MutableRefObject } from 'react';
|
||||||
import {
|
import {
|
||||||
parseHashConversation,
|
parseHashConversation,
|
||||||
|
parseHashSettingsSection,
|
||||||
updateUrlHash,
|
updateUrlHash,
|
||||||
resolveChannelFromHashToken,
|
resolveChannelFromHashToken,
|
||||||
resolveContactFromHashToken,
|
resolveContactFromHashToken,
|
||||||
@@ -18,6 +19,7 @@ interface UseConversationRouterArgs {
|
|||||||
channels: Channel[];
|
channels: Channel[];
|
||||||
contacts: Contact[];
|
contacts: Contact[];
|
||||||
contactsLoaded: boolean;
|
contactsLoaded: boolean;
|
||||||
|
suspendHashSync: boolean;
|
||||||
setSidebarOpen: (open: boolean) => void;
|
setSidebarOpen: (open: boolean) => void;
|
||||||
pendingDeleteFallbackRef: MutableRefObject<boolean>;
|
pendingDeleteFallbackRef: MutableRefObject<boolean>;
|
||||||
hasSetDefaultConversation: MutableRefObject<boolean>;
|
hasSetDefaultConversation: MutableRefObject<boolean>;
|
||||||
@@ -27,6 +29,7 @@ export function useConversationRouter({
|
|||||||
channels,
|
channels,
|
||||||
contacts,
|
contacts,
|
||||||
contactsLoaded,
|
contactsLoaded,
|
||||||
|
suspendHashSync,
|
||||||
setSidebarOpen,
|
setSidebarOpen,
|
||||||
pendingDeleteFallbackRef,
|
pendingDeleteFallbackRef,
|
||||||
hasSetDefaultConversation,
|
hasSetDefaultConversation,
|
||||||
@@ -34,7 +37,9 @@ export function useConversationRouter({
|
|||||||
const [activeConversation, setActiveConversationState] = useState<Conversation | null>(null);
|
const [activeConversation, setActiveConversationState] = useState<Conversation | null>(null);
|
||||||
const activeConversationRef = useRef<Conversation | null>(null);
|
const activeConversationRef = useRef<Conversation | null>(null);
|
||||||
const hashSyncEnabledRef = useRef(
|
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) => {
|
const setActiveConversation = useCallback((conv: Conversation | null) => {
|
||||||
@@ -58,7 +63,7 @@ export function useConversationRouter({
|
|||||||
if (hasSetDefaultConversation.current || activeConversation) return;
|
if (hasSetDefaultConversation.current || activeConversation) return;
|
||||||
if (channels.length === 0) return;
|
if (channels.length === 0) return;
|
||||||
|
|
||||||
const hashConv = parseHashConversation();
|
const hashConv = parseHashSettingsSection() ? null : parseHashConversation();
|
||||||
|
|
||||||
// Handle non-data views immediately
|
// Handle non-data views immediately
|
||||||
if (hashConv?.type === 'raw') {
|
if (hashConv?.type === 'raw') {
|
||||||
@@ -141,7 +146,7 @@ export function useConversationRouter({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (hasSetDefaultConversation.current || activeConversation) return;
|
if (hasSetDefaultConversation.current || activeConversation) return;
|
||||||
|
|
||||||
const hashConv = parseHashConversation();
|
const hashConv = parseHashSettingsSection() ? null : parseHashConversation();
|
||||||
if (hashConv?.type === 'contact') {
|
if (hashConv?.type === 'contact') {
|
||||||
if (!contactsLoaded) return;
|
if (!contactsLoaded) return;
|
||||||
|
|
||||||
@@ -203,14 +208,14 @@ export function useConversationRouter({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
activeConversationRef.current = activeConversation;
|
activeConversationRef.current = activeConversation;
|
||||||
if (activeConversation) {
|
if (activeConversation) {
|
||||||
if (hashSyncEnabledRef.current) {
|
if (hashSyncEnabledRef.current && !suspendHashSync) {
|
||||||
updateUrlHash(activeConversation);
|
updateUrlHash(activeConversation);
|
||||||
}
|
}
|
||||||
if (getReopenLastConversationEnabled() && activeConversation.type !== 'search') {
|
if (getReopenLastConversationEnabled() && activeConversation.type !== 'search') {
|
||||||
saveLastViewedConversation(activeConversation);
|
saveLastViewedConversation(activeConversation);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [activeConversation]);
|
}, [activeConversation, suspendHashSync]);
|
||||||
|
|
||||||
// If a delete action left us without an active conversation, recover to Public
|
// If a delete action left us without an active conversation, recover to Public
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -164,7 +164,10 @@ vi.mock('../components/ui/sonner', () => ({
|
|||||||
|
|
||||||
vi.mock('../utils/urlHash', () => ({
|
vi.mock('../utils/urlHash', () => ({
|
||||||
parseHashConversation: () => null,
|
parseHashConversation: () => null,
|
||||||
|
parseHashSettingsSection: () => null,
|
||||||
updateUrlHash: vi.fn(),
|
updateUrlHash: vi.fn(),
|
||||||
|
updateSettingsHash: vi.fn(),
|
||||||
|
getSettingsHash: (section: string) => `#settings/${section}`,
|
||||||
getMapFocusHash: () => '#map',
|
getMapFocusHash: () => '#map',
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
|||||||
@@ -90,7 +90,9 @@ vi.mock('../components/NewMessageModal', () => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('../components/SettingsModal', () => ({
|
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_ORDER: ['radio', 'local', 'database', 'bot'],
|
||||||
SETTINGS_SECTION_LABELS: {
|
SETTINGS_SECTION_LABELS: {
|
||||||
radio: 'Radio',
|
radio: 'Radio',
|
||||||
@@ -293,4 +295,20 @@ describe('App startup hash resolution', () => {
|
|||||||
});
|
});
|
||||||
expect(window.location.hash).toBe('');
|
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');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -63,6 +63,7 @@ const baseSettings: AppSettings = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
function renderModal(overrides?: {
|
function renderModal(overrides?: {
|
||||||
|
config?: RadioConfig | null;
|
||||||
appSettings?: AppSettings;
|
appSettings?: AppSettings;
|
||||||
health?: HealthStatus;
|
health?: HealthStatus;
|
||||||
onSaveAppSettings?: (update: AppSettingsUpdate) => Promise<void>;
|
onSaveAppSettings?: (update: AppSettingsUpdate) => Promise<void>;
|
||||||
@@ -97,7 +98,7 @@ function renderModal(overrides?: {
|
|||||||
const commonProps = {
|
const commonProps = {
|
||||||
open: overrides?.open ?? true,
|
open: overrides?.open ?? true,
|
||||||
pageMode: overrides?.pageMode,
|
pageMode: overrides?.pageMode,
|
||||||
config: baseConfig,
|
config: overrides?.config === undefined ? baseConfig : overrides.config,
|
||||||
health: overrides?.health ?? baseHealth,
|
health: overrides?.health ?? baseHealth,
|
||||||
appSettings: overrides?.appSettings ?? baseSettings,
|
appSettings: overrides?.appSettings ?? baseSettings,
|
||||||
onClose,
|
onClose,
|
||||||
@@ -205,6 +206,32 @@ describe('SettingsModal', () => {
|
|||||||
expect(screen.getByText(/Configured radio contact capacity/i)).toBeInTheDocument();
|
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', () => {
|
it('shows cached radio firmware and capacity info under the connection status', () => {
|
||||||
renderModal({
|
renderModal({
|
||||||
health: {
|
health: {
|
||||||
|
|||||||
@@ -8,6 +8,8 @@
|
|||||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||||
import {
|
import {
|
||||||
parseHashConversation,
|
parseHashConversation,
|
||||||
|
parseHashSettingsSection,
|
||||||
|
getSettingsHash,
|
||||||
getMapFocusHash,
|
getMapFocusHash,
|
||||||
resolveChannelFromHashToken,
|
resolveChannelFromHashToken,
|
||||||
resolveContactFromHashToken,
|
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', () => {
|
describe('resolveChannelFromHashToken', () => {
|
||||||
const channels: Channel[] = [
|
const channels: Channel[] = [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,9 +1,19 @@
|
|||||||
import { act, renderHook } from '@testing-library/react';
|
import { act, renderHook, waitFor } from '@testing-library/react';
|
||||||
import { describe, expect, it } from 'vitest';
|
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
import { useAppShell } from '../hooks/useAppShell';
|
import { useAppShell } from '../hooks/useAppShell';
|
||||||
|
|
||||||
describe('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', () => {
|
it('opens new-message modal and closes the sidebar', () => {
|
||||||
const { result } = renderHook(() => useAppShell());
|
const { result } = renderHook(() => useAppShell());
|
||||||
|
|
||||||
@@ -34,6 +44,55 @@ describe('useAppShell', () => {
|
|||||||
expect(result.current.showSettings).toBe(false);
|
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', () => {
|
it('toggles the cracker shell without affecting sidebar state', () => {
|
||||||
const { result } = renderHook(() => useAppShell());
|
const { result } = renderHook(() => useAppShell());
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import type { Channel, Contact, Conversation } from '../types';
|
import type { Channel, Contact, Conversation } from '../types';
|
||||||
import { findPublicChannel, PUBLIC_CHANNEL_NAME } from './publicChannel';
|
import { findPublicChannel, PUBLIC_CHANNEL_NAME } from './publicChannel';
|
||||||
import { getContactDisplayName } from './pubkey';
|
import { getContactDisplayName } from './pubkey';
|
||||||
|
import type { SettingsSection } from '../components/settings/settingsConstants';
|
||||||
|
|
||||||
interface ParsedHashConversation {
|
interface ParsedHashConversation {
|
||||||
type: 'channel' | 'contact' | 'raw' | 'map' | 'visualizer' | 'search';
|
type: 'channel' | 'contact' | 'raw' | 'map' | 'visualizer' | 'search';
|
||||||
@@ -12,6 +13,15 @@ interface ParsedHashConversation {
|
|||||||
mapFocusKey?: string;
|
mapFocusKey?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const SETTINGS_SECTIONS: SettingsSection[] = [
|
||||||
|
'radio',
|
||||||
|
'local',
|
||||||
|
'fanout',
|
||||||
|
'database',
|
||||||
|
'statistics',
|
||||||
|
'about',
|
||||||
|
];
|
||||||
|
|
||||||
// Parse URL hash to get conversation
|
// Parse URL hash to get conversation
|
||||||
// (e.g., #channel/ABCDEF0123456789ABCDEF0123456789 or #contact/<64-char-pubkey>).
|
// (e.g., #channel/ABCDEF0123456789ABCDEF0123456789 or #contact/<64-char-pubkey>).
|
||||||
export function parseHashConversation(): ParsedHashConversation | null {
|
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 {
|
export function resolveChannelFromHashToken(token: string, channels: Channel[]): Channel | null {
|
||||||
const normalizedToken = token.trim();
|
const normalizedToken = token.trim();
|
||||||
if (!normalizedToken) return null;
|
if (!normalizedToken) return null;
|
||||||
@@ -141,3 +165,10 @@ export function updateUrlHash(conv: Conversation | null): void {
|
|||||||
window.history.replaceState(null, '', newHash || window.location.pathname);
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -449,6 +449,66 @@ class TestReconnectLock:
|
|||||||
assert result is False
|
assert result is False
|
||||||
rm.connect.assert_not_called()
|
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:
|
class TestManualDisconnectCleanup:
|
||||||
"""Tests for manual disconnect teardown behavior."""
|
"""Tests for manual disconnect teardown behavior."""
|
||||||
|
|||||||
Reference in New Issue
Block a user