diff --git a/app/radio.py b/app/radio.py index 70710e0..cacb9a9 100644 --- a/app/radio.py +++ b/app/radio.py @@ -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: diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index ac1a7c0..9e68741 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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} diff --git a/frontend/src/components/AppShell.tsx b/frontend/src/components/AppShell.tsx index d283508..b141b97 100644 --- a/frontend/src/components/AppShell.tsx +++ b/frontend/src/components/AppShell.tsx @@ -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({
{SETTINGS_SECTION_ORDER.map((section) => { const Icon = SETTINGS_SECTION_ICONS[section]; + const disabled = disabledSettingsSections.includes(section); return (