From d8de2b1150496722f7165313d3a1e4f506beeb89 Mon Sep 17 00:00:00 2001 From: Ivan Date: Sun, 30 Nov 2025 15:21:18 -0600 Subject: [PATCH] Improve RNS management and settings interface in Ren Browser - Introduced a new rns.py module to encapsulate Reticulum lifecycle management. - Simplified RNS initialization and error handling in app.py. - Enhanced settings.py to improve configuration management and user feedback. - Updated UI components for better interaction and status display. - Added tests for settings functionality and RNS integration. --- ren_browser/app.py | 97 ++-- ren_browser/rns.py | 284 ++++++++++ ren_browser/ui/settings.py | 645 ++++++++++------------ tests/conftest.py | 5 + tests/integration/test_app_integration.py | 14 +- tests/unit/test_app.py | 30 +- tests/unit/test_ui.py | 107 +++- 7 files changed, 732 insertions(+), 450 deletions(-) create mode 100644 ren_browser/rns.py diff --git a/ren_browser/app.py b/ren_browser/app.py index 147d0e0..078a276 100644 --- a/ren_browser/app.py +++ b/ren_browser/app.py @@ -5,11 +5,14 @@ Ren Browser, a browser for the Reticulum Network built with Flet. """ import argparse +import os +from pathlib import Path import flet as ft import RNS from flet import AppView, Page +from ren_browser import rns from ren_browser.storage.storage import initialize_storage from ren_browser.ui.ui import build_ui @@ -50,44 +53,36 @@ async def main(page: Page): page.add(loader) page.update() - # Initialize storage system - storage = initialize_storage(page) + initialize_storage(page) - # Get Reticulum config directory from storage manager - config_dir = storage.get_reticulum_config_path() + config_override = RNS_CONFIG_DIR - # Update the global RNS_CONFIG_DIR so RNS uses the right path - global RNS_CONFIG_DIR - RNS_CONFIG_DIR = str(config_dir) - - # Ensure any saved config is written to filesystem before RNS init + print("Initializing Reticulum Network...") try: - saved_config = storage.load_config() - if saved_config and saved_config.strip(): - config_file_path = config_dir / "config" - config_file_path.parent.mkdir(parents=True, exist_ok=True) - config_file_path.write_text(saved_config, encoding="utf-8") - except Exception as e: - print(f"Warning: Failed to write config file: {e}") - - print(f"Initializing RNS with config directory: {config_dir}") - print(f"Config directory exists: {config_dir.exists()}") - print(f"Config directory is writable: {config_dir.is_dir() if config_dir.exists() else 'N/A'}") - - try: - # Set up logging capture first, before RNS init import ren_browser.logs ren_browser.logs.setup_rns_logging() + except Exception: + pass + + success = rns.initialize_reticulum(config_override) + if not success: + error_text = rns.get_last_error() or "Unknown error" + print(f"Error initializing Reticulum: {error_text}") + else: global RNS_INSTANCE - RNS_INSTANCE = RNS.Reticulum(str(config_dir)) + RNS_INSTANCE = rns.get_reticulum_instance() + config_dir = rns.get_config_path() + if config_dir: + config_path = Path(config_dir) + print(f"RNS config directory: {config_path}") + print(f"Config directory exists: {config_path.exists()}") + print( + "Config directory is writable: " + f"{config_path.is_dir() and os.access(config_path, os.W_OK)}", + ) print("RNS initialized successfully") - except Exception as e: - print(f"Error initializing Reticulum: {e}") - print(f"Config directory: {config_dir}") - import traceback - traceback.print_exc() - + page.controls.clear() build_ui(page) page.update() @@ -113,46 +108,24 @@ async def reload_reticulum(page: Page, on_complete=None): except Exception as e: print(f"Warning during RNS shutdown: {e}") + rns.shutdown_reticulum() RNS.Reticulum._Reticulum__instance = None - RNS.Transport.destinations = [] - RNS_INSTANCE = None print("RNS instance cleared") await asyncio.sleep(0.5) - # Initialize storage system - storage = initialize_storage(page) - - # Get Reticulum config directory from storage manager - config_dir = storage.get_reticulum_config_path() - - # Ensure any saved config is written to filesystem before RNS init - try: - saved_config = storage.load_config() - if saved_config and saved_config.strip(): - config_file_path = config_dir / "config" - config_file_path.parent.mkdir(parents=True, exist_ok=True) - config_file_path.write_text(saved_config, encoding="utf-8") - except Exception as e: - print(f"Warning: Failed to write config file: {e}") - - try: - # Re-initialize Reticulum - import ren_browser.logs - - ren_browser.logs.setup_rns_logging() - RNS_INSTANCE = RNS.Reticulum(str(config_dir)) - - # Success + success = rns.initialize_reticulum(RNS_CONFIG_DIR) + if success: + RNS_INSTANCE = rns.get_reticulum_instance() if on_complete: on_complete(True, None) - - except Exception as e: - print(f"Error reinitializing Reticulum: {e}") + else: + error_text = rns.get_last_error() or "Unknown error" + print(f"Error reinitializing Reticulum: {error_text}") if on_complete: - on_complete(False, str(e)) + on_complete(False, error_text) except Exception as e: print(f"Error during reload: {e}") @@ -198,9 +171,7 @@ def run(): if args.config_dir: RNS_CONFIG_DIR = args.config_dir else: - import pathlib - - RNS_CONFIG_DIR = str(pathlib.Path.home() / ".reticulum") + RNS_CONFIG_DIR = None if args.web: if args.port is not None: diff --git a/ren_browser/rns.py b/ren_browser/rns.py new file mode 100644 index 0000000..43b0d05 --- /dev/null +++ b/ren_browser/rns.py @@ -0,0 +1,284 @@ +"""Reticulum helper utilities for Ren Browser.""" + +from __future__ import annotations + +import os +import tempfile +from pathlib import Path +from typing import Optional + +import RNS + + +class RNSManager: + """Manage Reticulum lifecycle and configuration.""" + + def __init__(self): + self.reticulum = None + self.config_path: Optional[str] = None + self.last_error: Optional[str] = None + + def _is_android(self) -> bool: + vendor = getattr(RNS, "vendor", None) + platformutils = getattr(vendor, "platformutils", None) + if platformutils and hasattr(platformutils, "is_android"): + try: + return bool(platformutils.is_android()) + except Exception: + return False + return "ANDROID_ROOT" in os.environ + + def _android_storage_root(self) -> Path: + candidates = [ + os.environ.get("ANDROID_APP_PATH"), + os.environ.get("ANDROID_PRIVATE"), + os.environ.get("ANDROID_ARGUMENT"), + ] + for raw_path in candidates: + if not raw_path: + continue + path = Path(raw_path).expanduser() + if path.name == "app": + path = path.parent + if path.is_file(): + path = path.parent + if path.is_dir(): + return path + return Path(tempfile.gettempdir()) + + def _default_config_root(self) -> Path: + override = ( + os.environ.get("REN_BROWSER_RNS_DIR") + or os.environ.get("REN_RETICULUM_CONFIG_DIR") + ) + if override: + return Path(override).expanduser() + if self._is_android(): + return self._android_storage_root() / "ren_browser" / "reticulum" + return Path.home() / ".reticulum" + + def _resolve_config_dir(self, preferred: Optional[str | Path]) -> Path: + target = Path(preferred).expanduser() if preferred else self._default_config_root() + allow_fallback = preferred is None + + try: + target.mkdir(parents=True, exist_ok=True) + except Exception: + if not allow_fallback: + raise + fallback = Path(tempfile.gettempdir()) / "ren_browser" / "reticulum" + fallback.mkdir(parents=True, exist_ok=True) + target = fallback + + self._seed_config_if_missing(target) + return target + + def _default_tcp_interfaces_snippet(self) -> str: + return """ + [[Quad4 Node 1]] + type = TCPClientInterface + interface_enabled = true + target_host = rns.quad4.io + target_port = 4242 + name = Quad4 Node 1 + selected_interface_mode = 1 + + [[Quad4 Node 2]] + type = TCPClientInterface + interface_enabled = true + target_host = rns2.quad4.io + target_port = 4242 + name = Quad4 Node 2 + selected_interface_mode = 1 +""".strip( + "\n", + ) + + def _seed_config_if_missing(self, target: Path) -> None: + config_file = target / "config" + if config_file.exists(): + return + + base_content = None + try: + default_lines = getattr(RNS.Reticulum, "__default_rns_config__", None) + if default_lines: + if isinstance(default_lines, list): + base_content = "\n".join(default_lines) + else: + base_content = str(default_lines) + except Exception: + base_content = None + + if not base_content: + base_content = ( + "[reticulum]\n" + "share_instance = Yes\n\n" + "[interfaces]\n\n" + " [[Default Interface]]\n" + " type = AutoInterface\n" + " enabled = Yes\n" + ) + + snippet = self._default_tcp_interfaces_snippet() + if snippet and snippet not in base_content: + base_content = base_content.rstrip() + "\n\n" + snippet + "\n" + + try: + config_file.write_text(base_content, encoding="utf-8") + os.chmod(config_file, 0o600) + except Exception: + pass + + def _ensure_default_tcp_interfaces(self) -> None: + if not self.config_path: + return + config_file = Path(self.config_path) / "config" + if not config_file.exists(): + return + + try: + content = config_file.read_text(encoding="utf-8") + except Exception: + return + + snippet = self._default_tcp_interfaces_snippet() + if "target_host = rns.quad4.io" in content or "Quad4 Node 1" in content: + return + + try: + with open(config_file, "a", encoding="utf-8") as cfg: + if not content.endswith("\n"): + cfg.write("\n") + cfg.write("\n" + snippet + "\n") + except Exception: + pass + + def _get_or_create_config_dir(self) -> Path: + if self.config_path: + return Path(self.config_path) + + resolved = self._resolve_config_dir(None) + self.config_path = str(resolved) + return resolved + + def initialize(self, config_dir: Optional[str] = None) -> bool: + """Initialize the Reticulum instance.""" + self.last_error = None + try: + use_custom_dir = bool(config_dir or self._is_android()) + if use_custom_dir: + resolved = self._resolve_config_dir(config_dir) + self.config_path = str(resolved) + self.reticulum = RNS.Reticulum(configdir=self.config_path) + else: + self.reticulum = RNS.Reticulum() + self.config_path = getattr( + RNS.Reticulum, + "configdir", + str(Path.home() / ".reticulum"), + ) + + self._ensure_default_tcp_interfaces() + return True + except Exception as exc: + self.last_error = str(exc) + return False + + def shutdown(self) -> bool: + """Shut down the active Reticulum instance.""" + try: + if self.reticulum and hasattr(self.reticulum, "exit_handler"): + self.reticulum.exit_handler() + except Exception: + return False + finally: + self.reticulum = None + return True + + def read_config_file(self) -> str: + """Return the current configuration file contents.""" + config_dir = self._get_or_create_config_dir() + config_file = config_dir / "config" + + try: + return config_file.read_text(encoding="utf-8") + except FileNotFoundError: + self._seed_config_if_missing(config_dir) + try: + return config_file.read_text(encoding="utf-8") + except Exception: + return "" + except Exception: + return "" + + def write_config_file(self, content: str) -> bool: + """Persist configuration text to disk.""" + config_dir = self._get_or_create_config_dir() + config_file = config_dir / "config" + try: + config_dir.mkdir(parents=True, exist_ok=True) + config_file.write_text(content, encoding="utf-8") + os.chmod(config_file, 0o600) + return True + except Exception as exc: + self.last_error = str(exc) + return False + + def get_config_path(self) -> Optional[str]: + """Return the directory holding the active Reticulum config.""" + if self.config_path: + return self.config_path + try: + default_path = self._resolve_config_dir(None) + self.config_path = str(default_path) + return self.config_path + except Exception: + return None + + def get_reticulum_instance(self): + """Return the current Reticulum instance, if any.""" + return self.reticulum + + def get_last_error(self) -> Optional[str]: + """Return the last recorded error string.""" + return self.last_error + + +rns_manager = RNSManager() + + +def initialize_reticulum(config_dir: Optional[str] = None) -> bool: + """Initialize Reticulum using the shared manager.""" + return rns_manager.initialize(config_dir) + + +def shutdown_reticulum() -> bool: + """Shut down the shared Reticulum instance.""" + return rns_manager.shutdown() + + +def get_reticulum_instance(): + """Expose the active Reticulum instance.""" + return rns_manager.get_reticulum_instance() + + +def get_config_path() -> Optional[str]: + """Expose the active configuration directory.""" + return rns_manager.get_config_path() + + +def read_config_file() -> str: + """Read the Reticulum configuration file.""" + return rns_manager.read_config_file() + + +def write_config_file(content: str) -> bool: + """Write the Reticulum configuration file.""" + return rns_manager.write_config_file(content) + + +def get_last_error() -> Optional[str]: + """Return the last recorded Reticulum error.""" + return rns_manager.get_last_error() + diff --git a/ren_browser/ui/settings.py b/ren_browser/ui/settings.py index e1cf227..667939b 100644 --- a/ren_browser/ui/settings.py +++ b/ren_browser/ui/settings.py @@ -1,30 +1,210 @@ -"""Settings interface for Ren Browser. +"""Settings interface for Ren Browser.""" -Provides configuration management, log viewing, and storage -information display. -""" +from __future__ import annotations + +from datetime import datetime +from pathlib import Path import flet as ft +import RNS -from ren_browser.logs import ERROR_LOGS, RET_LOGS +from ren_browser import rns from ren_browser.storage.storage import get_storage_manager +BUTTON_BG = "#0B3D91" +BUTTON_BG_HOVER = "#082C6C" + + +def _blue_button_style() -> ft.ButtonStyle: + return ft.ButtonStyle( + bgcolor=BUTTON_BG, + color=ft.Colors.WHITE, + overlay_color=BUTTON_BG_HOVER, + ) + + +def _get_config_file_path() -> Path: + config_dir = rns.get_config_path() + if config_dir: + return Path(config_dir) / "config" + return Path.home() / ".reticulum" / "config" + + +def _read_config_text(config_path: Path) -> str: + try: + return config_path.read_text(encoding="utf-8") + except FileNotFoundError: + config_path.parent.mkdir(parents=True, exist_ok=True) + config_path.write_text("", encoding="utf-8") + return "" + except Exception as exc: # noqa: BLE001 + return f"# Error loading config: {exc}" + + +def _write_config_text(config_path: Path, content: str) -> None: + config_path.parent.mkdir(parents=True, exist_ok=True) + config_path.write_text(content, encoding="utf-8") + + +def _get_interface_statuses(): + statuses = [] + interfaces = getattr(RNS.Transport, "interfaces", []) or [] + for interface in interfaces: + if interface is None: + continue + if ( + interface.__class__.__name__ == "LocalClientInterface" + and getattr(interface, "is_connected_to_shared_instance", False) + ): + continue + statuses.append( + { + "name": getattr(interface, "name", None) or interface.__class__.__name__, + "online": bool(getattr(interface, "online", False)), + "type": interface.__class__.__name__, + "bitrate": getattr(interface, "bitrate", None), + }, + ) + return statuses + + +def _format_bitrate(bitrate: int | None) -> str | None: + if not bitrate: + return None + if bitrate >= 1_000_000: + return f"{bitrate / 1_000_000:.1f} Mbps" + if bitrate >= 1_000: + return f"{bitrate / 1_000:.0f} kbps" + return f"{bitrate} bps" + + +def _build_interface_chip_controls(statuses): + if not statuses: + return [ + ft.Text( + "No interfaces detected", + size=11, + color=ft.Colors.ON_SURFACE_VARIANT, + ), + ] + + chips = [] + for status in statuses: + indicator_color = ft.Colors.GREEN if status["online"] else ft.Colors.ERROR + tooltip = status["type"] + bitrate_label = _format_bitrate(status.get("bitrate")) + if bitrate_label: + tooltip = f"{tooltip} • {bitrate_label}" + + chips.append( + ft.Container( + content=ft.Row( + [ + ft.Icon(ft.Icons.CIRCLE, size=10, color=indicator_color), + ft.Text(status["name"], size=11), + ], + spacing=4, + vertical_alignment=ft.CrossAxisAlignment.CENTER, + ), + bgcolor="#1C1F2B", + border_radius=999, + padding=ft.padding.symmetric(horizontal=10, vertical=4), + tooltip=tooltip, + ), + ) + return chips + + +def _refresh_interface_status(summary_text, chip_wrap, updated_text): + statuses = _get_interface_statuses() + total = len(statuses) + online = sum(1 for entry in statuses if entry["online"]) + + if total == 0: + summary_text.value = "No active interfaces" + summary_text.color = ft.Colors.ERROR + else: + summary_text.value = f"{online}/{total} interfaces online" + summary_text.color = ft.Colors.GREEN if online else ft.Colors.ERROR + + chip_wrap.controls = _build_interface_chip_controls(statuses) + updated_text.value = f"Updated {datetime.now().strftime('%H:%M:%S')}" + + +def _build_status_section(page: ft.Page): + summary_text = ft.Text("", size=16, weight=ft.FontWeight.BOLD) + updated_text = ft.Text("", size=12, color=ft.Colors.ON_SURFACE_VARIANT) + chip_wrap = ft.Row( + spacing=6, + run_spacing=6, + vertical_alignment=ft.CrossAxisAlignment.CENTER, + ) + + def refresh(_=None): + _refresh_interface_status(summary_text, chip_wrap, updated_text) + page.update() + + refresh() + + refresh_button = ft.IconButton( + icon=ft.Icons.REFRESH, + tooltip="Refresh status", + on_click=refresh, + icon_color=ft.Colors.BLUE_200, + ) + + section = ft.Column( + spacing=12, + controls=[ + ft.Row( + controls=[ + ft.Row( + controls=[ + ft.Icon(ft.Icons.LAN, size=18, color=ft.Colors.BLUE_200), + summary_text, + ], + spacing=6, + vertical_alignment=ft.CrossAxisAlignment.CENTER, + ), + refresh_button, + ], + alignment=ft.MainAxisAlignment.SPACE_BETWEEN, + vertical_alignment=ft.CrossAxisAlignment.CENTER, + ), + chip_wrap, + updated_text, + ], + ) + + return section, refresh + + +def _build_storage_field(storage): + storage_field = ft.TextField( + label="Storage Information", + value="", + expand=True, + multiline=True, + read_only=True, + min_lines=10, + max_lines=15, + border_color=ft.Colors.GREY_700, + text_style=ft.TextStyle(font_family="monospace", size=12), + ) + + def refresh(): + info = storage.get_storage_info() + storage_field.value = "\n".join(f"{key}: {value}" for key, value in info.items()) + + refresh() + return storage_field, refresh + def open_settings_tab(page: ft.Page, tab_manager): - """Open a settings tab with configuration and debugging options. - - Args: - page: Flet page instance for UI updates. - tab_manager: Tab manager to add the settings tab to. - - """ + """Open a settings tab with configuration, status, and storage info.""" storage = get_storage_manager(page) - - try: - config_text = storage.load_config() - except Exception as ex: - config_text = f"Error reading config: {ex}" - + config_path = _get_config_file_path() + config_text = _read_config_text(config_path) app_settings = storage.load_app_settings() config_field = ft.TextField( @@ -61,7 +241,7 @@ def open_settings_tab(page: ft.Page, tab_manager): border=ft.border.all(1, ft.Colors.GREY_700), ) - def on_bgcolor_change(e): + def on_bgcolor_change(_): try: color_preview.bgcolor = page_bgcolor_field.value page.update() @@ -70,196 +250,81 @@ def open_settings_tab(page: ft.Page, tab_manager): page_bgcolor_field.on_change = on_bgcolor_change - def on_save_config(ev): + def show_snack(message, *, success=True): + snack = ft.SnackBar( + content=ft.Row( + controls=[ + ft.Icon( + ft.Icons.CHECK_CIRCLE if success else ft.Icons.ERROR, + color=ft.Colors.GREEN_400 if success else ft.Colors.RED_400, + size=20, + ), + ft.Text(message, color=ft.Colors.WHITE), + ], + tight=True, + ), + bgcolor=ft.Colors.GREEN_900 if success else ft.Colors.RED_900, + duration=3000 if success else 4000, + ) + page.overlay.append(snack) + snack.open = True + page.update() + + def on_save_config(_): try: - success = storage.save_config(config_field.value) - if success: - snack = ft.SnackBar( - content=ft.Row( - controls=[ - ft.Icon( - ft.Icons.CHECK_CIRCLE, - color=ft.Colors.GREEN_400, - size=20, - ), - ft.Text( - "Configuration saved! Restart app to apply changes.", - color=ft.Colors.WHITE, - ), - ], - tight=True, - ), - bgcolor=ft.Colors.GREEN_900, - duration=3000, - ) - page.overlay.append(snack) - snack.open = True - page.update() - else: - snack = ft.SnackBar( - content=ft.Row( - controls=[ - ft.Icon(ft.Icons.ERROR, color=ft.Colors.RED_400, size=20), - ft.Text( - "Failed to save configuration", color=ft.Colors.WHITE - ), - ], - tight=True, - ), - bgcolor=ft.Colors.RED_900, - duration=3000, - ) - page.overlay.append(snack) - snack.open = True - page.update() - except Exception as ex: - snack = ft.SnackBar( - content=ft.Row( - controls=[ - ft.Icon(ft.Icons.ERROR, color=ft.Colors.RED_400, size=20), - ft.Text(f"Error: {ex}", color=ft.Colors.WHITE), - ], - tight=True, - ), - bgcolor=ft.Colors.RED_900, - duration=4000, - ) - page.overlay.append(snack) - snack.open = True - page.update() + _write_config_text(config_path, config_field.value) + show_snack(f"Configuration saved to {config_path}") + except Exception as exc: # noqa: BLE001 + show_snack(f"Failed to save configuration: {exc}", success=False) - def on_save_and_reload_config(ev): + def on_save_and_reload_config(_): try: - success = storage.save_config(config_field.value) - if not success: - snack = ft.SnackBar( - content=ft.Row( - controls=[ - ft.Icon(ft.Icons.ERROR, color=ft.Colors.RED_400, size=20), - ft.Text( - "Failed to save configuration", color=ft.Colors.WHITE - ), - ], - tight=True, + _write_config_text(config_path, config_field.value) + except Exception as exc: # noqa: BLE001 + show_snack(f"Failed to save configuration: {exc}", success=False) + return + + loading_snack = ft.SnackBar( + content=ft.Row( + controls=[ + ft.ProgressRing( + width=16, + height=16, + stroke_width=2, + color=ft.Colors.BLUE_400, ), - bgcolor=ft.Colors.RED_900, - duration=3000, - ) - page.overlay.append(snack) - snack.open = True - page.update() - return + ft.Text("Reloading Reticulum...", color=ft.Colors.WHITE), + ], + tight=True, + ), + bgcolor=ft.Colors.BLUE_900, + duration=10000, + ) + page.overlay.append(loading_snack) + loading_snack.open = True + page.update() - loading_snack = ft.SnackBar( - content=ft.Row( - controls=[ - ft.ProgressRing( - width=16, - height=16, - stroke_width=2, - color=ft.Colors.BLUE_400, - ), - ft.Text("Reloading Reticulum...", color=ft.Colors.WHITE), - ], - tight=True, - ), - bgcolor=ft.Colors.BLUE_900, - duration=10000, - ) - page.overlay.append(loading_snack) - loading_snack.open = True - page.update() + async def do_reload(): + import ren_browser.app as app_module - async def do_reload(): - import ren_browser.app as app_module - - try: - await app_module.reload_reticulum(page, on_reload_complete) - except Exception as e: - loading_snack.open = False - page.update() - - snack = ft.SnackBar( - content=ft.Row( - controls=[ - ft.Icon( - ft.Icons.ERROR, color=ft.Colors.RED_400, size=20 - ), - ft.Text( - f"Reload failed: {str(e)}", color=ft.Colors.WHITE - ), - ], - tight=True, - ), - bgcolor=ft.Colors.RED_900, - duration=4000, - ) - page.overlay.append(snack) - snack.open = True - page.update() - - def on_reload_complete(success, error): + try: + await app_module.reload_reticulum(page, on_reload_complete) + except Exception as exc: # noqa: BLE001 loading_snack.open = False page.update() + show_snack(f"Reload failed: {exc}", success=False) - if success: - snack = ft.SnackBar( - content=ft.Row( - controls=[ - ft.Icon( - ft.Icons.CHECK_CIRCLE, - color=ft.Colors.GREEN_400, - size=20, - ), - ft.Text( - "Reticulum reloaded successfully!", - color=ft.Colors.WHITE, - ), - ], - tight=True, - ), - bgcolor=ft.Colors.GREEN_900, - duration=3000, - ) - else: - snack = ft.SnackBar( - content=ft.Row( - controls=[ - ft.Icon( - ft.Icons.ERROR, color=ft.Colors.RED_400, size=20 - ), - ft.Text( - f"Reload failed: {error}", color=ft.Colors.WHITE - ), - ], - tight=True, - ), - bgcolor=ft.Colors.RED_900, - duration=4000, - ) - page.overlay.append(snack) - snack.open = True - page.update() - - page.run_task(do_reload) - - except Exception as ex: - snack = ft.SnackBar( - content=ft.Row( - controls=[ - ft.Icon(ft.Icons.ERROR, color=ft.Colors.RED_400, size=20), - ft.Text(f"Error: {ex}", color=ft.Colors.WHITE), - ], - tight=True, - ), - bgcolor=ft.Colors.RED_900, - duration=4000, - ) - page.overlay.append(snack) - snack.open = True + def on_reload_complete(success, error): + loading_snack.open = False page.update() + if success: + show_snack("Reticulum reloaded successfully!") + else: + show_snack(f"Reload failed: {error}", success=False) - def on_save_app_settings(ev): + page.run_task(do_reload) + + def on_save_app_settings(_): try: new_settings = { "horizontal_scroll": horizontal_scroll_switch.value, @@ -267,123 +332,35 @@ def open_settings_tab(page: ft.Page, tab_manager): } success = storage.save_app_settings(new_settings) if success: - tab_manager.apply_settings(new_settings) - snack = ft.SnackBar( - content=ft.Row( - controls=[ - ft.Icon( - ft.Icons.CHECK_CIRCLE, - color=ft.Colors.GREEN_400, - size=20, - ), - ft.Text( - "Appearance settings saved and applied!", - color=ft.Colors.WHITE, - ), - ], - tight=True, - ), - bgcolor=ft.Colors.GREEN_900, - duration=2000, - ) - page.overlay.append(snack) - snack.open = True - page.update() + if hasattr(tab_manager, "apply_settings"): + tab_manager.apply_settings(new_settings) + show_snack("Appearance settings saved and applied!") else: - snack = ft.SnackBar( - content=ft.Row( - controls=[ - ft.Icon(ft.Icons.ERROR, color=ft.Colors.RED_400, size=20), - ft.Text( - "Failed to save appearance settings", - color=ft.Colors.WHITE, - ), - ], - tight=True, - ), - bgcolor=ft.Colors.RED_900, - duration=3000, - ) - page.overlay.append(snack) - snack.open = True - page.update() - except Exception as ex: - snack = ft.SnackBar( - content=ft.Row( - controls=[ - ft.Icon(ft.Icons.ERROR, color=ft.Colors.RED_400, size=20), - ft.Text(f"Error: {ex}", color=ft.Colors.WHITE), - ], - tight=True, - ), - bgcolor=ft.Colors.RED_900, - duration=4000, - ) - page.overlay.append(snack) - snack.open = True - page.update() + show_snack("Failed to save appearance settings", success=False) + except Exception as exc: # noqa: BLE001 + show_snack(f"Error saving appearance: {exc}", success=False) save_btn = ft.ElevatedButton( "Save Configuration", icon=ft.Icons.SAVE, on_click=on_save_config, - bgcolor=ft.Colors.BLUE_700, - color=ft.Colors.WHITE, + style=_blue_button_style(), ) - save_reload_btn = ft.ElevatedButton( "Save & Hot Reload", icon=ft.Icons.REFRESH, on_click=on_save_and_reload_config, - bgcolor=ft.Colors.GREEN_700, - color=ft.Colors.WHITE, + style=_blue_button_style(), ) - save_appearance_btn = ft.ElevatedButton( "Save Appearance", icon=ft.Icons.PALETTE, on_click=on_save_app_settings, - bgcolor=ft.Colors.BLUE_700, - color=ft.Colors.WHITE, - ) - error_field = ft.TextField( - label="Error Logs", - value="", - expand=True, - multiline=True, - read_only=True, - min_lines=15, - max_lines=20, - border_color=ft.Colors.GREY_700, - text_style=ft.TextStyle(font_family="monospace", size=12), - ) - ret_field = ft.TextField( - label="Reticulum Logs", - value="", - expand=True, - multiline=True, - read_only=True, - min_lines=15, - max_lines=20, - border_color=ft.Colors.GREY_700, - text_style=ft.TextStyle(font_family="monospace", size=12), + style=_blue_button_style(), ) - storage_info = storage.get_storage_info() - storage_text = "\n".join([f"{key}: {value}" for key, value in storage_info.items()]) - storage_field = ft.TextField( - label="Storage Information", - value=storage_text, - expand=True, - multiline=True, - read_only=True, - min_lines=10, - max_lines=15, - border_color=ft.Colors.GREY_700, - text_style=ft.TextStyle(font_family="monospace", size=12), - ) - - content_placeholder = ft.Container(expand=True) + status_content, refresh_status_section = _build_status_section(page) + storage_field, refresh_storage_info = _build_storage_field(storage) appearance_content = ft.Column( spacing=16, @@ -391,10 +368,7 @@ def open_settings_tab(page: ft.Page, tab_manager): ft.Text("Appearance Settings", size=18, weight=ft.FontWeight.BOLD), horizontal_scroll_switch, ft.Row( - controls=[ - page_bgcolor_field, - color_preview, - ], + controls=[page_bgcolor_field, color_preview], alignment=ft.MainAxisAlignment.START, spacing=16, ), @@ -402,68 +376,55 @@ def open_settings_tab(page: ft.Page, tab_manager): ], ) - def show_config(ev): + content_placeholder = ft.Container(expand=True, content=config_field) + + def show_config(_): content_placeholder.content = config_field page.update() - def show_appearance(ev): + def show_appearance(_): content_placeholder.content = appearance_content page.update() - def show_errors(ev): - error_field.value = "\n".join(ERROR_LOGS) or "No errors logged." - content_placeholder.content = error_field - page.update() + def show_status(_): + content_placeholder.content = status_content + refresh_status_section() - def show_ret_logs(ev): - ret_field.value = "\n".join(RET_LOGS) or "No Reticulum logs." - content_placeholder.content = ret_field - page.update() - - def show_storage_info(ev): - storage_info = storage.get_storage_info() - storage_field.value = "\n".join( - [f"{key}: {value}" for key, value in storage_info.items()], - ) + def show_storage_info(_): + refresh_storage_info() content_placeholder.content = storage_field page.update() - def refresh_current_view(ev): - if content_placeholder.content == error_field: - show_errors(ev) - elif content_placeholder.content == ret_field: - show_ret_logs(ev) + def refresh_current_view(_): + if content_placeholder.content == status_content: + refresh_status_section() elif content_placeholder.content == storage_field: - show_storage_info(ev) - elif content_placeholder.content == appearance_content: - show_appearance(ev) - elif content_placeholder.content == config_field: - show_config(ev) + refresh_storage_info() + page.update() btn_config = ft.FilledButton( "Configuration", icon=ft.Icons.SETTINGS, on_click=show_config, + style=_blue_button_style(), ) btn_appearance = ft.FilledButton( "Appearance", icon=ft.Icons.PALETTE, on_click=show_appearance, + style=_blue_button_style(), ) - btn_errors = ft.FilledButton( - "Errors", - icon=ft.Icons.ERROR_OUTLINE, - on_click=show_errors, - ) - btn_ret = ft.FilledButton( - "Reticulum Logs", - icon=ft.Icons.TERMINAL, - on_click=show_ret_logs, + btn_status = ft.FilledButton( + "Status", + icon=ft.Icons.LAN, + on_click=show_status, + style=_blue_button_style(), ) btn_storage = ft.FilledButton( "Storage", icon=ft.Icons.STORAGE, on_click=show_storage_info, + style=_blue_button_style(), ) btn_refresh = ft.IconButton( icon=ft.Icons.REFRESH, @@ -474,14 +435,7 @@ def open_settings_tab(page: ft.Page, tab_manager): nav_card = ft.Container( content=ft.Row( - controls=[ - btn_config, - btn_appearance, - btn_errors, - btn_ret, - btn_storage, - btn_refresh, - ], + controls=[btn_config, btn_appearance, btn_status, btn_storage, btn_refresh], spacing=8, wrap=True, ), @@ -507,7 +461,6 @@ def open_settings_tab(page: ft.Page, tab_manager): padding=ft.padding.symmetric(horizontal=16, vertical=8), ) - content_placeholder.content = config_field settings_content = ft.Column( expand=True, spacing=16, @@ -526,7 +479,9 @@ def open_settings_tab(page: ft.Page, tab_manager): action_row, ], ) + tab_manager._add_tab_internal("Settings", settings_content) idx = len(tab_manager.manager.tabs) - 1 tab_manager.select_tab(idx) page.update() + diff --git a/tests/conftest.py b/tests/conftest.py index f61bddc..fe1b6df 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -76,6 +76,11 @@ def mock_storage_manager(): mock_storage.save_config.return_value = True mock_storage.get_config_path.return_value = Mock() mock_storage.get_reticulum_config_path.return_value = Mock() + mock_storage.load_app_settings.return_value = { + "horizontal_scroll": False, + "page_bgcolor": "#000000", + } + mock_storage.save_app_settings.return_value = True mock_storage.get_storage_info.return_value = { "storage_dir": "/mock/storage", "config_path": "/mock/storage/config.txt", diff --git a/tests/integration/test_app_integration.py b/tests/integration/test_app_integration.py index eac1669..c1b71c2 100644 --- a/tests/integration/test_app_integration.py +++ b/tests/integration/test_app_integration.py @@ -1,5 +1,6 @@ from unittest.mock import Mock +import flet as ft import pytest from ren_browser import app @@ -14,16 +15,21 @@ class TestAppIntegration: mock_page = Mock() mock_page.add = Mock() mock_page.update = Mock() - mock_page.run_thread = Mock() mock_page.controls = Mock() mock_page.controls.clear = Mock() + mock_page.width = 1024 + mock_page.window = Mock() + mock_page.window.maximized = False + mock_page.appbar = Mock() + mock_page.drawer = Mock() + mock_page.theme_mode = ft.ThemeMode.DARK await app.main(mock_page) - # Verify that the main function sets up the loading screen - mock_page.add.assert_called_once() + assert mock_page.add.call_count >= 1 + loader_call = mock_page.add.call_args_list[0][0][0] + assert isinstance(loader_call, ft.Container) mock_page.update.assert_called() - mock_page.run_thread.assert_called_once() def test_entry_points_exist(self): """Test that all expected entry points exist and are callable.""" diff --git a/tests/unit/test_app.py b/tests/unit/test_app.py index f6f910f..8d65e88 100644 --- a/tests/unit/test_app.py +++ b/tests/unit/test_app.py @@ -12,26 +12,34 @@ class TestApp: @pytest.mark.asyncio async def test_main_initializes_loader(self, mock_page, mock_rns): """Test that main function initializes with loading screen.""" - with patch("ren_browser.ui.ui.build_ui"): + with ( + patch("ren_browser.rns.initialize_reticulum", return_value=True), + patch("ren_browser.rns.get_reticulum_instance"), + patch("ren_browser.rns.get_config_path", return_value="/tmp/.reticulum"), + patch("ren_browser.app.build_ui"), + ): await app.main(mock_page) - mock_page.add.assert_called_once() + assert mock_page.add.call_count >= 1 + loader_call = mock_page.add.call_args_list[0][0][0] + assert isinstance(loader_call, ft.Container) mock_page.update.assert_called() - mock_page.run_thread.assert_called_once() @pytest.mark.asyncio async def test_main_function_structure(self, mock_page, mock_rns): """Test that main function sets up the expected structure.""" - await app.main(mock_page) + with ( + patch("ren_browser.rns.initialize_reticulum", return_value=True), + patch("ren_browser.rns.get_reticulum_instance"), + patch("ren_browser.rns.get_config_path"), + patch("ren_browser.app.build_ui"), + ): + await app.main(mock_page) - # Verify that main function adds content and sets up threading - mock_page.add.assert_called_once() + assert mock_page.add.call_count >= 1 + loader_call = mock_page.add.call_args_list[0][0][0] + assert isinstance(loader_call, ft.Container) mock_page.update.assert_called() - mock_page.run_thread.assert_called_once() - - # Verify that a function was passed to run_thread - init_function = mock_page.run_thread.call_args[0][0] - assert callable(init_function) def test_run_with_default_args(self, mock_rns): """Test run function with default arguments.""" diff --git a/tests/unit/test_ui.py b/tests/unit/test_ui.py index a6b60bb..28cbd4c 100644 --- a/tests/unit/test_ui.py +++ b/tests/unit/test_ui.py @@ -93,28 +93,46 @@ class TestBuildUI: class TestOpenSettingsTab: """Test cases for the open_settings_tab function.""" - def test_open_settings_tab_basic(self, mock_page): + def test_open_settings_tab_basic(self, mock_page, mock_storage_manager): """Test opening settings tab with basic functionality.""" mock_tab_manager = Mock() mock_tab_manager.manager.tabs = [] mock_tab_manager._add_tab_internal = Mock() mock_tab_manager.select_tab = Mock() - with patch("pathlib.Path.read_text", return_value="config content"): + mock_page.overlay = [] + + with ( + patch( + "ren_browser.ui.settings.get_storage_manager", + return_value=mock_storage_manager, + ), + patch("ren_browser.ui.settings.rns.get_config_path", return_value="/tmp/rns"), + patch("pathlib.Path.read_text", return_value="config content"), + ): open_settings_tab(mock_page, mock_tab_manager) mock_tab_manager._add_tab_internal.assert_called_once() mock_tab_manager.select_tab.assert_called_once() mock_page.update.assert_called() - def test_open_settings_tab_config_error(self, mock_page): + def test_open_settings_tab_config_error(self, mock_page, mock_storage_manager): """Test opening settings tab when config file cannot be read.""" mock_tab_manager = Mock() mock_tab_manager.manager.tabs = [] mock_tab_manager._add_tab_internal = Mock() mock_tab_manager.select_tab = Mock() - with patch("pathlib.Path.read_text", side_effect=Exception("File not found")): + mock_page.overlay = [] + + with ( + patch( + "ren_browser.ui.settings.get_storage_manager", + return_value=mock_storage_manager, + ), + patch("ren_browser.ui.settings.rns.get_config_path", return_value="/tmp/rns"), + patch("pathlib.Path.read_text", side_effect=Exception("File not found")), + ): open_settings_tab(mock_page, mock_tab_manager) mock_tab_manager._add_tab_internal.assert_called_once() @@ -123,16 +141,23 @@ class TestOpenSettingsTab: args = mock_tab_manager._add_tab_internal.call_args assert args[0][0] == "Settings" - def test_settings_save_config_success(self, mock_page): + def test_settings_save_config_success(self, mock_page, mock_storage_manager): """Test saving config successfully in settings.""" mock_tab_manager = Mock() mock_tab_manager.manager.tabs = [] mock_tab_manager._add_tab_internal = Mock() mock_tab_manager.select_tab = Mock() + mock_page.overlay = [] + with ( + patch( + "ren_browser.ui.settings.get_storage_manager", + return_value=mock_storage_manager, + ), + patch("ren_browser.ui.settings.rns.get_config_path", return_value="/tmp/rns"), patch("pathlib.Path.read_text", return_value="config"), - patch("pathlib.Path.write_text"), + patch("pathlib.Path.write_text") as mock_write, ): open_settings_tab(mock_page, mock_tab_manager) @@ -152,40 +177,68 @@ class TestOpenSettingsTab: break assert save_btn is not None + save_btn.on_click(None) + assert mock_write.called def test_settings_save_config_error(self, mock_page, mock_storage_manager): - """Test saving config with error in settings.""" + """Test saving config error path does not crash.""" mock_tab_manager = Mock() mock_tab_manager.manager.tabs = [] mock_tab_manager._add_tab_internal = Mock() mock_tab_manager.select_tab = Mock() - with patch( - "ren_browser.ui.settings.get_storage_manager", - return_value=mock_storage_manager, - ): - open_settings_tab(mock_page, mock_tab_manager) - - settings_content = mock_tab_manager._add_tab_internal.call_args[0][1] - assert settings_content is not None - - def test_settings_log_sections(self, mock_page, mock_storage_manager): - """Test that settings includes error logs and RNS logs sections.""" - mock_tab_manager = Mock() - mock_tab_manager.manager.tabs = [] - mock_tab_manager._add_tab_internal = Mock() - mock_tab_manager.select_tab = Mock() + mock_page.overlay = [] with ( patch( "ren_browser.ui.settings.get_storage_manager", return_value=mock_storage_manager, ), - patch("ren_browser.logs.ERROR_LOGS", ["Error 1", "Error 2"]), - patch("ren_browser.logs.RET_LOGS", ["RNS log 1", "RNS log 2"]), + patch("ren_browser.ui.settings.rns.get_config_path", return_value="/tmp/rns"), + patch("pathlib.Path.read_text", return_value="config"), + patch("pathlib.Path.write_text", side_effect=Exception("disk full")), ): open_settings_tab(mock_page, mock_tab_manager) - mock_tab_manager._add_tab_internal.assert_called_once() - args = mock_tab_manager._add_tab_internal.call_args - assert args[0][0] == "Settings" + settings_content = mock_tab_manager._add_tab_internal.call_args[0][1] + save_btn = None + for control in settings_content.controls: + if hasattr(control, "content") and hasattr(control.content, "controls"): + for sub_control in control.content.controls: + if ( + hasattr(sub_control, "text") + and sub_control.text == "Save Configuration" + ): + save_btn = sub_control + break + assert save_btn is not None + # Should not raise despite write failure + save_btn.on_click(None) + + def test_settings_status_section_present(self, mock_page, mock_storage_manager): + """Ensure the status navigation button is present.""" + mock_tab_manager = Mock() + mock_tab_manager.manager.tabs = [] + mock_tab_manager._add_tab_internal = Mock() + mock_tab_manager.select_tab = Mock() + + mock_page.overlay = [] + + with ( + patch( + "ren_browser.ui.settings.get_storage_manager", + return_value=mock_storage_manager, + ), + patch("ren_browser.ui.settings.rns.get_config_path", return_value="/tmp/rns"), + patch("pathlib.Path.read_text", return_value="config"), + ): + open_settings_tab(mock_page, mock_tab_manager) + + settings_content = mock_tab_manager._add_tab_internal.call_args[0][1] + nav_container = settings_content.controls[1] + button_labels = [ + ctrl.text + for ctrl in nav_container.content.controls + if hasattr(ctrl, "text") + ] + assert "Status" in button_labels