From 7023a7a232fdf4b6955d784ec1b0612f1ff74ff6 Mon Sep 17 00:00:00 2001 From: Ivan Date: Fri, 10 Jul 2026 00:57:43 -0500 Subject: [PATCH] feat(mcx-bugs): local delivery, tabs, toasts, search, report view/export/delete --- meshchatx/meshchat.py | 908 ++++++++++++++---- meshchatx/src/backend/bug_report_manager.py | 568 +++++++++++ .../data/plugins/mcx-bugs/backend/main.py | 40 + .../data/plugins/mcx-bugs/frontend/main.js | 716 ++++++++++++++ .../data/plugins/mcx-bugs/locales/en.json | 62 ++ .../backend/data/plugins/mcx-bugs/plugin.json | 57 ++ meshchatx/src/backend/plugin_manager.py | 81 +- meshchatx/src/backend/plugin_permissions.py | 12 + meshchatx/src/backend/rns_startup_recovery.py | 363 +++++++ meshchatx/src/frontend/components/Toast.vue | 2 +- .../components/plugins/PluginSlotNode.vue | 23 +- .../src/frontend/js/plugins/PluginHost.js | 23 +- .../src/frontend/js/plugins/pluginLabels.js | 10 +- .../src/frontend/js/plugins/pluginWorker.js | 16 + tests/backend/test_bug_report_manager.py | 305 ++++++ tests/backend/test_plugin_manager.py | 66 +- tests/frontend/pluginLabels.test.js | 4 +- 17 files changed, 3044 insertions(+), 212 deletions(-) create mode 100644 meshchatx/src/backend/bug_report_manager.py create mode 100644 meshchatx/src/backend/data/plugins/mcx-bugs/backend/main.py create mode 100644 meshchatx/src/backend/data/plugins/mcx-bugs/frontend/main.js create mode 100644 meshchatx/src/backend/data/plugins/mcx-bugs/locales/en.json create mode 100644 meshchatx/src/backend/data/plugins/mcx-bugs/plugin.json create mode 100644 meshchatx/src/backend/rns_startup_recovery.py create mode 100644 tests/backend/test_bug_report_manager.py diff --git a/meshchatx/meshchat.py b/meshchatx/meshchat.py index 4cca7485..82a4e718 100644 --- a/meshchatx/meshchat.py +++ b/meshchatx/meshchat.py @@ -25,6 +25,7 @@ import secrets import shutil import signal import socket +import sqlite3 import ssl import sys import tempfile @@ -122,6 +123,13 @@ from meshchatx.src.backend.map_manager import ( is_mbtiles_filename, is_path_within_dir, ) +from meshchatx.src.backend.map_overlay_export import OverlayExportError +from meshchatx.src.backend.map_overlay_manager import ( + CONFIG_CLAMPS, + clamp_overlay_config_value, +) +from meshchatx.src.backend.map_overlay_sources import OverlaySourceParseError +from meshchatx.src.backend.map_geo_validator import GeoValidationError from meshchatx.src.backend.markdown_renderer import MarkdownRenderer from meshchatx.src.backend.meshchat_utils import ( convert_db_favourite_to_dict, @@ -166,10 +174,15 @@ from meshchatx.src.backend.csrf import ( ) from meshchatx.src.backend.ip_allowlist import client_ip_allowed from meshchatx.src.backend.reticulum_config_guard import ( + ensure_safe_reticulum_runtime_flags, repair_unparseable_reticulum_config, reticulum_config_has_required_sections, ) from meshchatx.src.backend import i2p_support +from meshchatx.src.backend.rns_startup_recovery import ( + create_reticulum_with_recovery, + install_rns_panic_containment, +) from meshchatx.src.backend.websocket_config_guard import ( sanitize_websocket_config_update, websocket_type_requires_auth, @@ -308,9 +321,10 @@ def _create_reticulum_instance(config_dir: str, loglevel: int | None = None): ``signal.signal`` on the main thread, so deferred network setup must skip that registration when running in a background worker and install handlers later. - If the first init fails and the config still has an enabled I2P interface, - disable I2P and retry once so Android/desktop can recover without wiping - app data or the whole ``.reticulum`` tree. + On failure, progressively disables risky interfaces (I2P, unsupported RNode, + AutoInterface, etc.) and retries so Android/desktop can recover without + wiping app data or the whole ``.reticulum`` tree. ``RNS.panic`` is contained + so it cannot ``os._exit`` the MeshChatX process. """ kwargs = {} if loglevel is not None: @@ -334,18 +348,10 @@ def _create_reticulum_instance(config_dir: str, loglevel: int | None = None): finally: signal.signal = real_signal - try: - return _construct() - except Exception as first_exc: - config_path = os.path.join(config_dir, "config") - if not i2p_support.disable_all_i2p_in_config(config_path): - raise - print( - "Reticulum init failed with I2P enabled; disabled I2P interfaces " - f"and retrying. Original error: {first_exc}", - flush=True, - ) - return _construct() + return create_reticulum_with_recovery( + config_dir, + construct=_construct, + ) def _install_reticulum_signal_handlers() -> bool: @@ -469,6 +475,9 @@ class ReticulumMeshChat: self._startup_stage = "ready" if not defer_network_setup else "http" self._startup_error: str | None = None self._network_ready = not defer_network_setup + self._network_degraded = False + self._ui_ready = not defer_network_setup + self._rns_recovery_actions: list[str] = [] # track announce timestamps for rate calculation self.announce_timestamps = [] @@ -488,6 +497,9 @@ class ReticulumMeshChat: self.identity_manager = IdentityManager(self.storage_dir, identity_file_path) self.page_node_manager = PageNodeManager(self.storage_dir) self.plugin_manager = PluginManager(self.storage_dir, app=self) + from meshchatx.src.backend.bug_report_manager import BugReportManager + + self.bug_report_manager = BugReportManager(self) self.sideband_plugin_loader = SidebandPluginLoader(self) self._sideband_telemetry_thread = None self._sideband_telemetry_running = False @@ -589,6 +601,17 @@ class ReticulumMeshChat: if self.current_context: self.current_context.map_manager = value + @property + def map_overlay_manager(self): + return ( + self.current_context.map_overlay_manager if self.current_context else None + ) + + @map_overlay_manager.setter + def map_overlay_manager(self, value): + if self.current_context: + self.current_context.map_overlay_manager = value + @property def docs_manager(self): return self.current_context.docs_manager if self.current_context else None @@ -1324,6 +1347,7 @@ class ReticulumMeshChat: guard_rnode_interfaces_on_desktop(config_path) guard_invalid_rnode_txpower_in_config(config_path) i2p_support.guard_i2p_interfaces_in_config(config_path) + ensure_safe_reticulum_runtime_flags(config_path) def _set_startup_stage(self, stage: str, error: str | None = None) -> None: self._startup_stage = stage @@ -1333,11 +1357,23 @@ class ReticulumMeshChat: def _mark_network_ready(self) -> None: self._network_ready = True + self._network_degraded = False + self._ui_ready = True self._startup_stage = "ready" self._startup_error = None + self._rns_recovery_actions = [] self._network_ready_event.set() self._schedule_reticulum_signal_handlers() + def _mark_network_degraded(self, error: str) -> None: + """Keep HTTP/UI alive when the mesh stack cannot start.""" + self._network_ready = False + self._network_degraded = True + self._ui_ready = True + self._startup_stage = "failed" + self._startup_error = error + print(f"Network degraded: {error}", flush=True) + def _schedule_reticulum_signal_handlers(self) -> None: """Install RNS signal handlers on the main asyncio loop when possible.""" if threading.current_thread() is threading.main_thread(): @@ -1380,6 +1416,8 @@ class ReticulumMeshChat: "status": "failed", "stage": "failed", "network_ready": False, + "network_degraded": True, + "ui_ready": True, "listen_host": self.listen_host, "listen_port": self.listen_port, "https_enabled": self.use_https, @@ -1389,6 +1427,8 @@ class ReticulumMeshChat: } if self._startup_error: payload["error"] = self._startup_error + if self._rns_recovery_actions: + payload["recovery_actions"] = list(self._rns_recovery_actions) return payload ready = bool(self._network_ready) and bool( self.current_context and self.current_context.running, @@ -1398,6 +1438,8 @@ class ReticulumMeshChat: "status": "ok" if ready else "starting", "stage": stage, "network_ready": ready, + "network_degraded": False, + "ui_ready": True if ready else bool(self._ui_ready), "listen_host": self.listen_host, "listen_port": self.listen_port, "https_enabled": self.use_https, @@ -1471,7 +1513,7 @@ class ReticulumMeshChat: pass except Exception as exc: traceback.print_exc() - self._set_startup_stage("failed", str(exc)) + self._mark_network_degraded(str(exc)) if self.websocket_clients: try: AsyncUtils.run_async( @@ -1481,6 +1523,8 @@ class ReticulumMeshChat: "status": "failed", "stage": "failed", "network_ready": False, + "network_degraded": True, + "ui_ready": True, "error": str(exc), }, ), @@ -2398,12 +2442,18 @@ class ReticulumMeshChat: in_progress=False, ) - # Try to recover if possible + # Try to recover if possible without wiping storage. if not hasattr(self, "reticulum") and identity_to_restore is not None: try: self.setup_identity(identity_to_restore) - except Exception: - pass + self._mark_network_ready() + return False + except Exception as recover_exc: + self._mark_network_degraded( + f"RNS reload failed and recovery failed: {recover_exc}", + ) + else: + self._mark_network_degraded(f"RNS reload failed: {e}") return False @@ -3112,10 +3162,11 @@ class ReticulumMeshChat: and elapsed > 45.0 ) if propagation_sync_is_terminal(state): - if state not in {router.PR_IDLE, router.PR_COMPLETE}: + if state != router.PR_IDLE: self.stop_propagation_node_sync(context=ctx) with contextlib.suppress(Exception): router.propagation_transfer_state = router.PR_IDLE + router.propagation_transfer_progress = 0.0 ctx.config.lxmf_preferred_propagation_node_last_synced_at.set( int(time.time()) ) @@ -4451,6 +4502,39 @@ class ReticulumMeshChat: def exit_app(self, code=0): sys.exit(code) + def _require_identity_context_ready(self): + """Return an HTTP 503 response when identity/DB is not ready yet.""" + if not self.current_context or not getattr( + self.current_context, "running", False + ): + return web.json_response( + { + "message": "Identity context is still starting. Retry shortly.", + "stage": self._startup_stage, + "network_ready": bool(self._network_ready), + }, + status=503, + ) + if self.database is None or self.message_handler is None: + return web.json_response( + { + "message": "Database is still starting. Retry shortly.", + "stage": self._startup_stage, + "network_ready": bool(self._network_ready), + }, + status=503, + ) + if self.local_lxmf_destination is None: + return web.json_response( + { + "message": "Local LXMF destination is still starting. Retry shortly.", + "stage": self._startup_stage, + "network_ready": bool(self._network_ready), + }, + status=503, + ) + return None + def _require_outbound_http(self, feature: str) -> None: if self.config: ensure_outbound_http_allowed(self.config, feature=feature) @@ -5128,6 +5212,106 @@ class ReticulumMeshChat: async def status(request): return web.json_response(self._startup_status_payload()) + @routes.post("/api/v1/reticulum/recover") + async def reticulum_recover(request): + """Disable risky interfaces and retry network setup without wiping data.""" + if ( + self._network_ready + and self.current_context + and self.current_context.running + ): + return web.json_response( + { + "message": "Network stack is already running", + "status": self._startup_status_payload(), + }, + ) + + identity = self._pending_identity or self.identity + if identity is None: + return web.json_response( + {"message": "No identity available for recovery"}, + status=400, + ) + + config_path = self._reticulum_config_file_path() + actions: list[str] = [] + try: + data = await request.json() + except Exception: + data = {} + if not isinstance(data, dict): + data = {} + + disable_all = bool(data.get("disable_all_interfaces")) + named = data.get("disable_interfaces") + if isinstance(named, list) and named: + from meshchatx.src.backend.rns_startup_recovery import ( + disable_named_interfaces_in_config, + ) + + disabled = disable_named_interfaces_in_config( + config_path, + [str(n) for n in named], + ) + actions.extend(disabled) + elif disable_all: + from meshchatx.src.backend.rns_startup_recovery import ( + disable_named_interfaces_in_config, + list_enabled_interface_names, + ) + + names = list_enabled_interface_names(config_path) + disabled = disable_named_interfaces_in_config(config_path, names) + actions.extend(disabled) + else: + from meshchatx.src.backend.rns_startup_recovery import ( + apply_startup_recovery_step, + ) + + for attempt in range(4): + disabled = apply_startup_recovery_step( + config_path, + self._startup_error or "manual recover", + attempt=attempt, + ) + actions.extend(disabled) + if disabled: + break + + self._rns_recovery_actions = actions + self._startup_error = None + self._startup_stage = "starting" + self._network_degraded = False + self._ui_ready = True + self._network_ready = False + if hasattr(self, "reticulum"): + with contextlib.suppress(Exception): + delattr(self, "reticulum") + + try: + self.setup_identity(identity) + self._mark_network_ready() + return web.json_response( + { + "message": "Network stack recovered", + "disabled_interfaces": actions, + "status": self._startup_status_payload(), + }, + ) + except Exception as exc: + traceback.print_exc() + self._mark_network_degraded(str(exc)) + return web.json_response( + { + "message": "Recovery attempt failed", + "error": str(exc), + "disabled_interfaces": actions, + "status": self._startup_status_payload(), + }, + status=500, + ) + @routes.get("/api/v1/self-test") async def self_test(request): results = self.run_self_test() @@ -7918,7 +8102,7 @@ class ReticulumMeshChat: body=data, headers={ "Content-Type": "application/octet-stream", - "Content-Disposition": 'attachment; filename="identity"', + "Content-Disposition": 'attachment; filename="identity.bin"', }, ) except Exception as e: @@ -7952,29 +8136,32 @@ class ReticulumMeshChat: # multipart file upload if "multipart/form-data" in content_type: reader = await request.multipart() + identity_bytes = None + display_name = None field = await reader.next() - if field is None or field.name != "file": + while field is not None: + if field.name == "file": + with tempfile.NamedTemporaryFile(delete=False) as tmp: + while True: + chunk = await field.read_chunk() + if not chunk: + break + tmp.write(chunk) + temp_path = tmp.name + try: + with open(temp_path, "rb") as f: + identity_bytes = f.read() + finally: + with contextlib.suppress(OSError): + os.remove(temp_path) + elif field.name == "display_name": + display_name = (await field.text()).strip() or None + field = await reader.next() + if identity_bytes is None: return web.json_response( {"message": "Identity file is required"}, status=400, ) - with tempfile.NamedTemporaryFile(delete=False) as tmp: - while True: - chunk = await field.read_chunk() - if not chunk: - break - tmp.write(chunk) - temp_path = tmp.name - with open(temp_path, "rb") as f: - identity_bytes = f.read() - os.remove(temp_path) - display_name = None - next_field = await reader.next() - while next_field is not None: - if next_field.name == "display_name": - display_name = (await next_field.text()).strip() - break - next_field = await reader.next() result = self.restore_identity_from_bytes( identity_bytes, display_name=display_name, @@ -7998,6 +8185,13 @@ class ReticulumMeshChat: "identity": result, }, ) + except ValueError as e: + return web.json_response( + { + "message": str(e), + }, + status=400, + ) except Exception as e: return web.json_response( { @@ -11361,6 +11555,18 @@ class ReticulumMeshChat: @routes.get("/api/v1/lxmf/propagation-node/status") async def propagation_node_status(request): + router = self.message_router + if router is not None: + try: + state = router.propagation_transfer_state + # COMPLETE is terminal; expose idle so the UI does not keep + # looking "busy" after a finished auto/manual sync. + if state == router.PR_COMPLETE: + router.propagation_transfer_state = router.PR_IDLE + with contextlib.suppress(Exception): + router.propagation_transfer_progress = 0.0 + except Exception: + pass sync_metrics = self._collect_propagation_sync_metrics() return web.json_response( { @@ -11852,30 +12058,57 @@ class ReticulumMeshChat: # get path params destination_hash_str = request.match_info.get("destination_hash", "") - # convert destination hash to bytes - destination_hash = bytes.fromhex(destination_hash_str) + try: + destination_hash = bytes.fromhex(destination_hash_str) + except Exception: + return web.json_response( + {"message": "Ping failed. Invalid destination hash."}, + status=400, + ) - # determine how long until we should time out - timeout_seconds = int(request.query.get("timeout", 15)) - timeout_after_seconds = time.time() + timeout_seconds + try: + timeout_seconds = int(request.query.get("timeout", 15)) + except (TypeError, ValueError): + return web.json_response( + {"message": "Ping failed. Timeout must be an integer."}, + status=400, + ) + if timeout_seconds < 1: + return web.json_response( + {"message": "Ping failed. Timeout must be at least 1 second."}, + status=400, + ) + + # Split the budget so path discovery cannot consume the whole timeout. + path_budget_seconds = max(1, timeout_seconds // 2) + delivery_budget_seconds = max(1, timeout_seconds - path_budget_seconds) + path_deadline = time.time() + path_budget_seconds # request path if we don't have it if not RNS.Transport.has_path(destination_hash): RNS.Transport.request_path(destination_hash) - # wait until we have a path, or give up after the configured timeout + # wait until we have a path, or give up after the path budget while ( not RNS.Transport.has_path(destination_hash) - and time.time() < timeout_after_seconds + and time.time() < path_deadline ): await asyncio.sleep(0.1) + if not RNS.Transport.has_path(destination_hash): + return web.json_response( + { + "message": "Ping failed. Could not find path to destination.", + }, + status=503, + ) + # find destination identity (pass string hash, not bytes) destination_identity = self.recall_identity(destination_hash_str) if destination_identity is None: return web.json_response( { - "message": "Ping failed. Could not find path to destination.", + "message": "Ping failed. Could not recall destination identity.", }, status=503, ) @@ -11893,10 +12126,11 @@ class ReticulumMeshChat: packet = RNS.Packet(request_destination, b"") receipt = packet.send() - # wait until delivered, or give up after time out + delivery_deadline = time.time() + delivery_budget_seconds + # wait until delivered, or give up after the delivery budget while ( receipt.status != RNS.PacketReceipt.DELIVERED - and time.time() < timeout_after_seconds + and time.time() < delivery_deadline ): await asyncio.sleep(0.1) @@ -12280,6 +12514,22 @@ class ReticulumMeshChat: except Exception as e: return web.json_response({"message": str(e)}, status=500) + @routes.post("/api/v1/rncp/cancel") + async def rncp_cancel(request): + data = {} + with contextlib.suppress(Exception): + data = await request.json() + transfer_id = None + if isinstance(data, dict): + raw = data.get("transfer_id") + if isinstance(raw, str) and raw.strip(): + transfer_id = raw.strip() + try: + result = self.rncp_handler.cancel_transfer(transfer_id) + return web.json_response(result) + except Exception as e: + return web.json_response({"message": str(e)}, status=500) + # --- Plugin API --- @routes.get("/api/v1/plugins") @@ -12558,7 +12808,14 @@ class ReticulumMeshChat: return web.json_response({"message": str(e)}, status=400) except ValueError as e: return web.json_response({"message": str(e)}, status=400) - return web.FileResponse(path) + return web.FileResponse( + path, + headers={ + "Cache-Control": "no-cache, no-store, must-revalidate", + "Pragma": "no-cache", + "Expires": "0", + }, + ) # --- Page Node API --- @@ -12792,18 +13049,24 @@ class ReticulumMeshChat: @routes.get("/api/v1/rnpath/table") async def rnpath_table(request): - max_hops = request.query.get("max_hops") - if max_hops: - max_hops = int(max_hops) + def _optional_int(raw, field_name): + if raw in (None, ""): + return None + try: + return int(raw) + except (TypeError, ValueError): + raise ValueError(f"Invalid {field_name}") from None + + try: + max_hops = _optional_int(request.query.get("max_hops"), "max_hops") + hops = _optional_int(request.query.get("hops"), "hops") + page = int(request.query.get("page", 1)) + limit = int(request.query.get("limit", 50)) + except ValueError as e: + return web.json_response({"message": str(e)}, status=400) search = request.query.get("search") interface = request.query.get("interface") - hops = request.query.get("hops") - if hops: - hops = int(hops) - - page = int(request.query.get("page", 1)) - limit = int(request.query.get("limit", 50)) try: result = self.rnpath_handler.get_path_table( @@ -13572,12 +13835,18 @@ class ReticulumMeshChat: }, ) - except Exception: + except Exception as e: + detail = str(e).strip() or "Sending failed" + status = 503 + if isinstance(e, (ValueError, LookupError)): + status = 400 + elif isinstance(e, TimeoutError): + status = 503 return web.json_response( { - "message": "Sending failed", + "message": detail, }, - status=503, + status=status, ) @routes.post("/api/v1/lxmf-messages/reactions") @@ -13612,11 +13881,17 @@ class ReticulumMeshChat: }, ) except Exception as e: + detail = str(e).strip() or "Reaction failed" + status = 503 + if isinstance(e, (ValueError, LookupError)): + status = 400 + elif isinstance(e, TimeoutError): + status = 503 return web.json_response( { - "message": str(e), + "message": detail, }, - status=503, + status=status, ) # cancel sending lxmf message @@ -13895,6 +14170,10 @@ class ReticulumMeshChat: # get lxmf conversations @routes.get("/api/v1/lxmf/conversations") async def lxmf_conversations_get(request): + not_ready = self._require_identity_context_ready() + if not_ready is not None: + return not_ready + # get query params search_query = request.query.get("search", request.query.get("q", None)) filter_unread = parse_bool_query_param( @@ -13935,103 +14214,136 @@ class ReticulumMeshChat: except ValueError: offset = 0 - local_hash = self.local_lxmf_destination.hexhash + try: + local_hash = self.local_lxmf_destination.hexhash - db_conversations = await asyncio.to_thread( - self.message_handler.get_conversations, - local_hash, - search=search_query, - filter_unread=filter_unread, - filter_failed=filter_failed, - filter_has_attachments=filter_has_attachments, - folder_id=folder_id, - limit=limit, - offset=offset, - ) - - conversations = [] - for row in db_conversations: - other_user_hash = row["peer_hash"] - - display_name = None - if row["peer_app_data"]: - display_name = parse_lxmf_display_name( - app_data_base64=row["peer_app_data"], - default_value=None, - ) - if not display_name and row.get("contact_name"): - display_name = row["contact_name"] - if not display_name: - display_name = "Anonymous Peer" - - if self._lxmf_sieve_hides_peer( - other_user_hash, - message_title=row.get("title"), - message_content=row.get("content"), - ): - continue - - # user icon - user_icon = None - if row["icon_name"]: - user_icon = { - "icon_name": row["icon_name"], - "foreground_colour": row["foreground_colour"], - "background_colour": row["background_colour"], - } - - # contact image - contact_image = row.get("contact_image", None) - - is_unread = compute_lxmf_conversation_unread_from_latest_row(row) - - # Add extra check for notification viewed state if unread - if is_unread and filter_unread: - if self.database.messages.is_notification_viewed( - other_user_hash, - row["timestamp"], - ): - is_unread = False - if filter_unread: - continue # Skip this conversation if filtering unread and it's actually viewed - - # add to conversations - conversations.append( - { - "display_name": display_name, - "custom_display_name": row["custom_display_name"], - "contact_image": contact_image, - "destination_hash": other_user_hash, - "is_unread": is_unread, - "is_tracking": self.database.telemetry.is_tracking( - other_user_hash, - ), - "failed_messages_count": row["failed_count"], - "has_attachments": message_fields_have_attachments( - row["fields"], - ), - "latest_message_title": row["title"], - "latest_message_preview": lxmf_sidebar_preview_for_conversation_latest_row( - row, - local_hash=local_hash, - peer_display_name=( - row.get("custom_display_name") - or display_name - or "Anonymous Peer" - ), - ), - "latest_message_created_at": row["created_at"], - "lxmf_user_icon": user_icon, - "is_contact": bool(row.get("is_contact", 0)), - "updated_at": row["created_at"], - }, + db_conversations = await asyncio.to_thread( + self.message_handler.get_conversations, + local_hash, + search=search_query, + filter_unread=filter_unread, + filter_failed=filter_failed, + filter_has_attachments=filter_has_attachments, + folder_id=folder_id, + limit=limit, + offset=offset, ) - return web.json_response( - { - "conversations": conversations, - }, - ) + conversations = [] + for row in db_conversations: + if not isinstance(row, dict): + row = dict(row) + other_user_hash = row["peer_hash"] + + display_name = None + if row.get("peer_app_data"): + display_name = parse_lxmf_display_name( + app_data_base64=row["peer_app_data"], + default_value=None, + ) + if not display_name and row.get("contact_name"): + display_name = row["contact_name"] + if not display_name: + display_name = "Anonymous Peer" + + if self._lxmf_sieve_hides_peer( + other_user_hash, + message_title=row.get("title"), + message_content=row.get("content"), + ): + continue + + # user icon + user_icon = None + if row.get("icon_name"): + user_icon = { + "icon_name": row["icon_name"], + "foreground_colour": row["foreground_colour"], + "background_colour": row["background_colour"], + } + + # contact image + contact_image = row.get("contact_image", None) + + try: + is_unread = compute_lxmf_conversation_unread_from_latest_row( + row + ) + except Exception: + is_unread = False + + # Add extra check for notification viewed state if unread + if is_unread and filter_unread: + if self.database.messages.is_notification_viewed( + other_user_hash, + row["timestamp"], + ): + is_unread = False + if filter_unread: + continue # Skip this conversation if filtering unread and it's actually viewed + + has_attachments = bool( + row.get("has_attachments") in (1, True, "1") + or message_fields_have_attachments(row.get("fields")) + ) + + # add to conversations + conversations.append( + { + "display_name": display_name, + "custom_display_name": row["custom_display_name"], + "contact_image": contact_image, + "destination_hash": other_user_hash, + "is_unread": is_unread, + "is_tracking": self.database.telemetry.is_tracking( + other_user_hash, + ), + "failed_messages_count": row["failed_count"], + "has_attachments": has_attachments, + "latest_message_title": row["title"], + "latest_message_preview": lxmf_sidebar_preview_for_conversation_latest_row( + row, + local_hash=local_hash, + peer_display_name=( + row.get("custom_display_name") + or display_name + or "Anonymous Peer" + ), + ), + "latest_message_created_at": row["created_at"], + "lxmf_user_icon": user_icon, + "is_contact": bool(row.get("is_contact", 0)), + "updated_at": row["created_at"], + }, + ) + + return web.json_response( + { + "conversations": conversations, + }, + ) + except Exception as e: + RNS.log(f"Error in lxmf_conversations_get: {e}", RNS.LOG_ERROR) + detail = str(e).lower() + status = ( + 503 + if ( + isinstance(e, sqlite3.OperationalError) + or "unable to open database file" in detail + or "database is locked" in detail + ) + else 500 + ) + return web.json_response( + { + "message": ( + "Database temporarily unavailable. Retry shortly." + if status == 503 + else "Failed to load conversations" + ), + }, + status=status, + ) @routes.get("/api/v1/lxmf/folders") async def lxmf_folders_get(request): @@ -14313,6 +14625,9 @@ class ReticulumMeshChat: @routes.get("/api/v1/notifications") async def notifications_get(request): + not_ready = self._require_identity_context_ready() + if not_ready is not None: + return not_ready try: filter_unread = parse_bool_query_param( request.query.get("unread", "false"), @@ -14594,9 +14909,25 @@ class ReticulumMeshChat: ) except Exception as e: RNS.log(f"Error in notifications_get: {e}", RNS.LOG_ERROR) + detail = str(e).lower() + status = ( + 503 + if ( + isinstance(e, sqlite3.OperationalError) + or "unable to open database file" in detail + or "database is locked" in detail + ) + else 500 + ) return web.json_response( - {"error": "Internal error"}, - status=500, + { + "error": ( + "Database temporarily unavailable. Retry shortly." + if status == 503 + else "Internal error" + ), + }, + status=status, ) # get blocked destinations @@ -14910,6 +15241,196 @@ class ReticulumMeshChat: self.database.map_drawings.update_drawing(drawing_id, name, drawing_data) return web.json_response({"message": "Drawing updated successfully"}) + @routes.get("/api/v1/map/overlays") + async def list_map_overlays(request): + if not self.map_overlay_manager: + return web.json_response({"error": "unavailable"}, status=503) + identity_hash = self.identity.hash.hex() + overlays = self.map_overlay_manager.list_overlays(identity_hash) + return web.json_response({"overlays": overlays}) + + @routes.post("/api/v1/map/overlays") + async def create_map_overlays(request): + if not self.map_overlay_manager: + return web.json_response({"error": "unavailable"}, status=503) + try: + data = await request.json() + except (json.JSONDecodeError, ValueError): + return web.json_response({"error": "invalid_json"}, status=400) + identity_hash = self.identity.hash.hex() + try: + result = await self.map_overlay_manager.create_overlays( + identity_hash, + data, + ) + except OverlaySourceParseError as exc: + return web.json_response({"error": exc.code}, status=400) + except GeoValidationError as exc: + return web.json_response({"error": exc.code}, status=400) + return web.json_response(result) + + @routes.post("/api/v1/map/overlays/export") + async def export_map_overlays_many(request): + if not self.map_overlay_manager: + return web.json_response({"error": "unavailable"}, status=503) + try: + data = await request.json() + except (json.JSONDecodeError, ValueError): + return web.json_response({"error": "invalid_json"}, status=400) + fmt = str(data.get("format") or "geojson").lower() + ids = data.get("ids") or [] + if not isinstance(ids, list): + return web.json_response({"error": "missing_ids"}, status=400) + try: + overlay_ids = [int(i) for i in ids] + except (TypeError, ValueError): + return web.json_response({"error": "missing_ids"}, status=400) + identity_hash = self.identity.hash.hex() + try: + body, content_type, filename = self.map_overlay_manager.export_many( + identity_hash, + overlay_ids, + fmt, + ) + except OverlayExportError as exc: + status = 404 if exc.code == "cache_missing" else 400 + return web.json_response({"error": exc.code}, status=status) + return web.Response( + body=body, + headers={ + "Content-Type": content_type, + "Content-Disposition": f'attachment; filename="{filename}"', + }, + ) + + @routes.get("/api/v1/map/overlays/jobs/{job_id}") + async def get_map_overlay_job(request): + if not self.map_overlay_manager: + return web.json_response({"error": "unavailable"}, status=503) + job_id = request.match_info.get("job_id") + job = self.map_overlay_manager.get_job(job_id) + if not job: + return web.json_response({"error": "not_found"}, status=404) + return web.json_response(job) + + @routes.post("/api/v1/map/overlays/jobs/{job_id}/cancel") + async def cancel_map_overlay_job(request): + if not self.map_overlay_manager: + return web.json_response({"error": "unavailable"}, status=503) + job_id = request.match_info.get("job_id") + ok = self.map_overlay_manager.cancel_job(job_id) + if not ok: + return web.json_response({"error": "not_found"}, status=404) + return web.json_response({"cancelled": True}) + + @routes.post("/api/v1/map/overlays/{overlay_id}/refresh") + async def refresh_map_overlay(request): + if not self.map_overlay_manager: + return web.json_response({"error": "unavailable"}, status=503) + try: + overlay_id = int(request.match_info.get("overlay_id")) + except (TypeError, ValueError): + return web.json_response({"error": "not_found"}, status=404) + identity_hash = self.identity.hash.hex() + try: + result = await self.map_overlay_manager.refresh_overlay( + identity_hash, + overlay_id, + ) + except OverlaySourceParseError as exc: + status = 404 if exc.code == "not_found" else 400 + return web.json_response({"error": exc.code}, status=status) + return web.json_response(result) + + @routes.patch("/api/v1/map/overlays/{overlay_id}") + async def patch_map_overlay(request): + if not self.map_overlay_manager: + return web.json_response({"error": "unavailable"}, status=503) + try: + overlay_id = int(request.match_info.get("overlay_id")) + except (TypeError, ValueError): + return web.json_response({"error": "not_found"}, status=404) + try: + data = await request.json() + except (json.JSONDecodeError, ValueError): + return web.json_response({"error": "invalid_json"}, status=400) + identity_hash = self.identity.hash.hex() + try: + overlay = self.map_overlay_manager.patch_overlay( + identity_hash, + overlay_id, + data, + ) + except OverlaySourceParseError as exc: + status = 404 if exc.code == "not_found" else 400 + return web.json_response({"error": exc.code}, status=status) + return web.json_response({"overlay": overlay}) + + @routes.delete("/api/v1/map/overlays/{overlay_id}") + async def delete_map_overlay(request): + if not self.map_overlay_manager: + return web.json_response({"error": "unavailable"}, status=503) + try: + overlay_id = int(request.match_info.get("overlay_id")) + except (TypeError, ValueError): + return web.json_response({"error": "not_found"}, status=404) + identity_hash = self.identity.hash.hex() + ok = self.map_overlay_manager.delete_overlay(identity_hash, overlay_id) + if not ok: + return web.json_response({"error": "not_found"}, status=404) + return web.json_response({"deleted": True}) + + @routes.get("/api/v1/map/overlays/{overlay_id}/content") + async def get_map_overlay_content(request): + if not self.map_overlay_manager: + return web.json_response({"error": "unavailable"}, status=503) + try: + overlay_id = int(request.match_info.get("overlay_id")) + except (TypeError, ValueError): + return web.json_response({"error": "not_found"}, status=404) + identity_hash = self.identity.hash.hex() + cached = self.map_overlay_manager.read_cache_bytes( + identity_hash, overlay_id + ) + if not cached: + return web.json_response({"error": "cache_missing"}, status=404) + data, fmt = cached + from meshchatx.src.backend.map_overlay_export import CONTENT_TYPES + + return web.Response( + body=data, + headers={ + "Content-Type": CONTENT_TYPES.get(fmt, "application/octet-stream") + }, + ) + + @routes.get("/api/v1/map/overlays/{overlay_id}/export") + async def export_map_overlay(request): + if not self.map_overlay_manager: + return web.json_response({"error": "unavailable"}, status=503) + try: + overlay_id = int(request.match_info.get("overlay_id")) + except (TypeError, ValueError): + return web.json_response({"error": "not_found"}, status=404) + fmt = str(request.rel_url.query.get("format") or "geojson").lower() + identity_hash = self.identity.hash.hex() + try: + body, content_type, filename = self.map_overlay_manager.export_overlay( + identity_hash, + overlay_id, + fmt, + ) + except OverlayExportError as exc: + status = 404 if exc.code == "cache_missing" else 400 + return web.json_response({"error": exc.code}, status=status) + return web.Response( + body=body, + headers={ + "Content-Type": content_type, + "Content-Disposition": f'attachment; filename="{filename}"', + }, + ) + @routes.get("/api/v1/stickers") async def stickers_list(request): identity_hash = self.identity.hash.hex() @@ -16634,6 +17155,16 @@ class ReticulumMeshChat: if "map_nominatim_api_url" in data: self.config.map_nominatim_api_url.set(data["map_nominatim_api_url"]) + for overlay_key in CONFIG_CLAMPS: + if overlay_key in data: + try: + value = int(data[overlay_key]) + except (TypeError, ValueError): + continue + getattr(self.config, overlay_key).set( + clamp_overlay_config_value(overlay_key, value), + ) + # update location settings if "location_source" in data: self.config.location_source.set(data["location_source"]) @@ -17740,6 +18271,7 @@ class ReticulumMeshChat: "rules": [ { "id": rule["id"], + "name": rule.get("name") or "", "identity_hash": rule["identity_hash"], "forward_to_hash": rule["forward_to_hash"], "source_filter_hash": rule["source_filter_hash"], @@ -18717,6 +19249,16 @@ class ReticulumMeshChat: "map_default_zoom": ctx.config.map_default_zoom.get(), "map_tile_server_url": ctx.config.map_tile_server_url.get(), "map_nominatim_api_url": ctx.config.map_nominatim_api_url.get(), + "map_overlay_max_bytes": ctx.config.map_overlay_max_bytes.get(), + "map_overlay_max_features": ctx.config.map_overlay_max_features.get(), + "map_overlay_max_kmz_uncompressed_bytes": ctx.config.map_overlay_max_kmz_uncompressed_bytes.get(), + "map_overlay_max_sources": ctx.config.map_overlay_max_sources.get(), + "map_overlay_max_concurrent_jobs": ctx.config.map_overlay_max_concurrent_jobs.get(), + "map_overlay_path_timeout_seconds": ctx.config.map_overlay_path_timeout_seconds.get(), + "map_overlay_transfer_timeout_seconds": ctx.config.map_overlay_transfer_timeout_seconds.get(), + "map_overlay_job_timeout_seconds": ctx.config.map_overlay_job_timeout_seconds.get(), + "map_overlay_max_retries": ctx.config.map_overlay_max_retries.get(), + "map_overlay_retry_delay_seconds": ctx.config.map_overlay_retry_delay_seconds.get(), "do_not_disturb_enabled": ctx.config.do_not_disturb_enabled.get(), "telephone_enabled": ctx.config.telephone_enabled.get(), "telephone_allow_calls_from_contacts_only": ctx.config.telephone_allow_calls_from_contacts_only.get(), @@ -20567,17 +21109,41 @@ class ReticulumMeshChat: ) # convert destination hash to bytes - destination_hash_bytes = bytes.fromhex(destination_hash) + try: + destination_hash_bytes = bytes.fromhex(destination_hash) + except (TypeError, ValueError) as exc: + msg = "Invalid destination hash." + raise ValueError(msg) from exc - # Reticulum keeps a live path table; entries expire when peers move or links drop. - # We cannot replay "old" paths from the app layer — Transport.request_path refreshes discovery. - path_outcome = await self._await_transport_path(destination_hash_bytes) + wants_propagated = delivery_method == "propagated" + + # Direct/opportunistic need a live peer path. Propagated uses the + # propagation node, so skip the peer path wait (still record outcome). + if wants_propagated: + path_outcome = reticulum_pathfinding.OutboundPathOutcome( + False, "skipped_for_propagated", False + ) + else: + # Reticulum keeps a live path table; entries expire when peers move or links drop. + # We cannot replay "old" paths from the app layer — Transport.request_path refreshes discovery. + path_outcome = await self._await_transport_path(destination_hash_bytes) destination_identity = self.recall_identity(destination_hash) if destination_identity is None: - # we have to bail out of sending, since we don't have the identity/path yet - msg = "Could not find path to destination. Try again later." - raise Exception(msg) + msg = ( + "Could not recall destination identity. " + "Wait for an announce from this peer, then try again." + ) + raise LookupError(msg) + + # Direct/opportunistic delivery needs a live transport path. Propagated + # delivery can proceed without a peer path (it uses the propagation node). + if not wants_propagated and not path_outcome.path_available: + msg = ( + "No path to destination. " + "Use Path Finder or wait for a route, then try again." + ) + raise TimeoutError(msg) # create destination for recipients lxmf delivery address lxmf_destination = RNS.Destination( @@ -21244,7 +21810,7 @@ class ReticulumMeshChat: continue # send new message with failed message content - await self.send_message( + new_message = await self.send_message( failed_message["destination_hash"], failed_message["content"], image_field=image_field, @@ -21253,7 +21819,10 @@ class ReticulumMeshChat: context=ctx, ) - # remove original failed message from database + # Only drop the old failed row after a replacement was queued. + if new_message is None or getattr(new_message, "hash", None) is None: + continue + ctx.database.messages.delete_lxmf_message_by_hash( failed_message["hash"], ) @@ -21559,6 +22128,7 @@ def main(): # Initialize crash recovery system early to catch startup errors recovery = CrashRecovery() recovery.install() + install_rns_panic_containment() parser = argparse.ArgumentParser(description="ReticulumMeshChat") parser.add_argument( diff --git a/meshchatx/src/backend/bug_report_manager.py b/meshchatx/src/backend/bug_report_manager.py new file mode 100644 index 00000000..5e5aab11 --- /dev/null +++ b/meshchatx/src/backend/bug_report_manager.py @@ -0,0 +1,568 @@ +# SPDX-License-Identifier: 0BSD + +"""Bug report collector and sender over aspect ``mcx-bugs-v1``.""" + +from __future__ import annotations + +import json +import os +import threading +import time +from typing import Any + +import RNS + +from meshchatx.src.backend.announce_handler import AnnounceHandler + +BUG_ASPECT = "mcx-bugs-v1" +REPORT_PATH = "/report" +MAX_LOG_LINES = 500 +MAX_PAYLOAD_CHARS = 120_000 +MAX_STORED_REPORTS = 50 +MAX_COLLECTORS = 80 + + +class BugReportManager: + """Host-side collector destination and redacted log sender for plugins.""" + + def __init__(self, app: Any): + self.app = app + self._lock = threading.RLock() + self._destination: RNS.Destination | None = None + self._announce_handler: AnnounceHandler | None = None + self._collectors: dict[str, dict[str, Any]] = {} + self._reports: list[dict[str, Any]] = [] + self._active_links: list[Any] = [] + self._storage_dir: str | None = None + self._collector_name: str = "" + + def _identity(self): + ctx = getattr(self.app, "current_context", None) + if ctx is None: + return None + return getattr(ctx, "identity", None) + + def _ensure_storage_dir(self) -> str: + if self._storage_dir and os.path.isdir(self._storage_dir): + return self._storage_dir + base = getattr(self.app, "storage_dir", None) or os.getcwd() + path = os.path.join(base, "bug_reports") + os.makedirs(path, exist_ok=True) + self._storage_dir = path + return path + + def status(self) -> dict[str, Any]: + with self._lock: + dest = self._destination + return { + "aspect": BUG_ASPECT, + "collector_running": dest is not None, + "destination_hash": dest.hash.hex() if dest is not None else None, + "collector_name": self._collector_name, + "collectors": len(self._collectors), + "reports": len(self._reports), + } + + def start_collector(self, *, announce: bool = True) -> dict[str, Any]: + identity = self._identity() + if identity is None: + raise RuntimeError("identity is not available") + with self._lock: + if self._destination is not None: + if announce: + self._announce_locked() + return self.status() + app_name, aspects = RNS.Destination.app_and_aspects_from_name(BUG_ASPECT) + destination = RNS.Destination( + identity, + RNS.Destination.IN, + RNS.Destination.SINGLE, + app_name, + *aspects, + ) + destination.set_link_established_callback(self._on_link) + destination.register_request_handler( + REPORT_PATH, + response_generator=self._report_response, + allow=RNS.Destination.ALLOW_ALL, + ) + self._destination = destination + self._register_announce_handler() + dest_hex = destination.hash.hex() + self._collectors[dest_hex] = { + "destination_hash": dest_hex, + "aspect": BUG_ASPECT, + "name": "local", + "heard_at": time.time(), + "identity_hash": identity.hash.hex() + if hasattr(identity, "hash") + else None, + } + if announce: + self._announce_locked() + return self.status() + + def stop_collector(self) -> dict[str, Any]: + with self._lock: + for link in list(self._active_links): + try: + link.teardown() + except Exception: + pass + self._active_links.clear() + if self._destination is not None: + dest_hex = self._destination.hash.hex() + self._collectors.pop(dest_hex, None) + try: + self._destination.deregister_request_handler(REPORT_PATH) + except Exception: + pass + try: + RNS.Transport.deregister_destination(self._destination) + except Exception: + pass + self._destination = None + self._unregister_announce_handler() + return self.status() + + def announce(self) -> dict[str, Any]: + with self._lock: + if self._destination is None: + raise RuntimeError("collector is not running") + self._announce_locked() + return self.status() + + def set_collector_name(self, name: str) -> dict[str, Any]: + with self._lock: + self._collector_name = str(name)[:64] + return self.status() + + def _announce_locked(self) -> None: + assert self._destination is not None + display = "" + try: + ctx = getattr(self.app, "current_context", None) + config = getattr(ctx, "config", None) if ctx else None + if config is not None: + display = str(config.display_name.get() or "") + except Exception: + display = "" + name = self._collector_name + if not name: + name = display + app_data = json.dumps( + {"v": 1, "app": "meshchatx", "name": name[:64]}, + separators=(",", ":"), + ).encode("utf-8") + self._destination.announce(app_data=app_data) + + def _register_announce_handler(self) -> None: + if self._announce_handler is not None: + return + handler = AnnounceHandler(BUG_ASPECT, self._on_collector_announce) + RNS.Transport.register_announce_handler(handler) + self._announce_handler = handler + + def _unregister_announce_handler(self) -> None: + handler = self._announce_handler + if handler is None: + return + try: + RNS.Transport.deregister_announce_handler(handler) + except Exception: + try: + if handler in RNS.Transport.announce_handlers: + RNS.Transport.announce_handlers.remove(handler) + except Exception: + pass + self._announce_handler = None + + def ensure_discovery(self) -> None: + with self._lock: + self._register_announce_handler() + + def _on_collector_announce( + self, + aspect: str, + destination_hash, + announced_identity, + app_data, + announce_packet_hash, + ) -> None: + try: + dest_hex = ( + destination_hash.hex() + if isinstance(destination_hash, (bytes, bytearray)) + else str(destination_hash) + ) + name = "" + if isinstance(app_data, (bytes, bytearray)) and app_data: + try: + parsed = json.loads(app_data.decode("utf-8", errors="replace")) + if isinstance(parsed, dict): + name = str(parsed.get("name") or "")[:64] + except Exception: + name = app_data.decode("utf-8", errors="replace")[:64] + with self._lock: + self._collectors[dest_hex] = { + "destination_hash": dest_hex, + "aspect": aspect, + "name": name, + "heard_at": time.time(), + "identity_hash": ( + announced_identity.hash.hex() + if announced_identity is not None + and hasattr(announced_identity, "hash") + else None + ), + } + if len(self._collectors) > MAX_COLLECTORS: + oldest = sorted( + self._collectors.values(), + key=lambda item: item.get("heard_at") or 0, + ) + for entry in oldest[: len(self._collectors) - MAX_COLLECTORS]: + self._collectors.pop(entry["destination_hash"], None) + except Exception as exc: + print(f"bug report announce handling failed: {exc}") + + def list_collectors(self) -> dict[str, Any]: + self.ensure_discovery() + with self._lock: + items = sorted( + self._collectors.values(), + key=lambda item: item.get("heard_at") or 0, + reverse=True, + ) + return {"collectors": items, "aspect": BUG_ASPECT} + + def list_reports(self, *, limit: int = 20) -> dict[str, Any]: + limit = max(1, min(int(limit or 20), MAX_STORED_REPORTS)) + with self._lock: + return {"reports": list(self._reports[:limit])} + + def delete_report(self, index: int) -> dict[str, Any]: + index = int(index) + with self._lock: + if index < 0 or index >= len(self._reports): + raise IndexError("report index out of range") + removed = self._reports.pop(index) + stamp = int(removed.get("received_at") or time.time()) + directory = self._ensure_storage_dir() + path = os.path.join(directory, f"report-{stamp}-{os.getpid()}.json") + try: + os.remove(path) + except Exception: + pass + return {"ok": True} + + def clear_reports(self) -> dict[str, Any]: + with self._lock: + self._reports.clear() + directory = self._ensure_storage_dir() + try: + for name in os.listdir(directory): + if name.startswith("report-") and name.endswith(".json"): + os.remove(os.path.join(directory, name)) + except Exception: + pass + return {"ok": True} + + def _on_link(self, link) -> None: + with self._lock: + self._active_links.append(link) + link.set_link_closed_callback(self._on_link_closed) + + def _on_link_closed(self, link) -> None: + with self._lock: + if link in self._active_links: + self._active_links.remove(link) + + def _report_response( + self, + path, + data, + request_id, + link_id, + remote_identity, + requested_at, + ): + try: + if isinstance(data, (bytes, bytearray)): + text = bytes(data).decode("utf-8", errors="replace") + elif isinstance(data, str): + text = data + else: + text = "" + payload = json.loads(text) if text else {} + if not isinstance(payload, dict): + return {"ok": False, "error": "invalid payload"} + source = None + if remote_identity is not None and hasattr(remote_identity, "hash"): + source = remote_identity.hash.hex() + report = { + "received_at": time.time(), + "source": source, + "title": str(payload.get("title") or "")[:200], + "description": str(payload.get("description") or "")[:4000], + "log_text": str(payload.get("log_text") or "")[:MAX_PAYLOAD_CHARS], + "meta": payload.get("meta") + if isinstance(payload.get("meta"), dict) + else {}, + } + with self._lock: + self._reports.insert(0, report) + self._reports = self._reports[:MAX_STORED_REPORTS] + self._persist_report(report) + return {"ok": True} + except Exception as exc: + print(f"bug report receive failed: {exc}") + return {"ok": False, "error": str(exc)} + + def _persist_report(self, report: dict[str, Any]) -> None: + try: + directory = self._ensure_storage_dir() + stamp = int(report.get("received_at") or time.time()) + path = os.path.join(directory, f"report-{stamp}-{os.getpid()}.json") + with open(path, "w", encoding="utf-8") as handle: + json.dump(report, handle, indent=2) + except Exception as exc: + print(f"bug report persist failed: {exc}") + + def read_debug_logs( + self, + *, + limit: int = 200, + search: str | None = None, + level: str | None = None, + module: str | None = None, + ) -> dict[str, Any]: + limit = max(1, min(int(limit or 200), MAX_LOG_LINES)) + handler = None + try: + from meshchatx.src.backend import persistent_log_handler as plh + + handler = getattr(plh, "memory_log_handler", None) + except Exception: + handler = None + if handler is None: + handler = getattr(self.app, "memory_log_handler", None) + if handler is None: + database = getattr(self.app, "database", None) + if database is not None and hasattr(database, "debug_logs"): + logs = database.debug_logs.get_logs( + limit=limit, + search=search, + level=level, + module=module, + ) + total = database.debug_logs.get_total_count( + search=search, + level=level, + module=module, + ) + return {"logs": logs, "total": total, "limit": limit} + return {"logs": [], "total": 0, "limit": limit} + logs = handler.get_logs( + limit=limit, + search=search, + level=level, + module=module, + ) + total = handler.get_total_count( + search=search, + level=level, + module=module, + ) + return {"logs": logs, "total": total, "limit": limit} + + def preview_report(self, args: dict[str, Any]) -> dict[str, Any]: + limit = int(args.get("limit") or 200) + log_data = self.read_debug_logs(limit=limit) + lines = [] + for entry in log_data.get("logs") or []: + ts = entry.get("timestamp") + level = entry.get("level") or "" + module = entry.get("module") or "" + message = entry.get("message") or "" + lines.append(f"{ts}\t{level}\t{module}\t{message}") + log_text = "\n".join(lines) + if len(log_text) > MAX_PAYLOAD_CHARS: + log_text = log_text[:MAX_PAYLOAD_CHARS] + "\n[truncated]" + return { + "log_text": log_text, + "chars": len(log_text), + "line_count": len(lines), + "total_available": log_data.get("total") or 0, + } + + def _build_payload(self, args: dict[str, Any]) -> tuple[dict[str, Any], bytes]: + preview = self.preview_report(args) + title = str(args.get("title") or "MeshChatX bug report")[:200] + description = str(args.get("description") or "")[:4000] + payload = { + "v": 1, + "aspect": BUG_ASPECT, + "title": title, + "description": description, + "log_text": preview["log_text"], + "meta": { + "line_count": preview.get("line_count"), + "sent_at": time.time(), + }, + } + body = json.dumps(payload, separators=(",", ":")).encode("utf-8") + if len(body) > MAX_PAYLOAD_CHARS + 4096: + raise ValueError("report payload is too large") + return payload, body + + def _send_remote_report( + self, + dest_hex: str, + dest_hash: bytes, + body: bytes, + args: dict[str, Any], + ) -> dict[str, Any]: + identity = RNS.Identity.recall(dest_hash) + if identity is None: + raise LookupError( + "Could not recall collector identity. " + "Wait for an mcx-bugs-v1 announce or start a local collector." + ) + app_name, aspects = RNS.Destination.app_and_aspects_from_name(BUG_ASPECT) + destination = RNS.Destination( + identity, + RNS.Destination.OUT, + RNS.Destination.SINGLE, + app_name, + *aspects, + ) + if not RNS.Transport.has_path(dest_hash): + RNS.Transport.request_path(dest_hash) + deadline = time.time() + float(args.get("path_timeout") or 15) + while time.time() < deadline: + if RNS.Transport.has_path(dest_hash): + break + time.sleep(0.2) + if not RNS.Transport.has_path(dest_hash): + raise TimeoutError( + "No path to bug collector. " + "Start a collector locally to test, or wait for mesh announces." + ) + + link = RNS.Link(destination) + established = threading.Event() + response_event = threading.Event() + response_holder: dict[str, Any] = {"value": None, "error": None} + + def on_established(lnk): + established.set() + + def on_response(receipt): + response_holder["value"] = getattr(receipt, "response", None) + response_event.set() + + def on_failed(receipt=None): + response_holder["error"] = "request failed" + response_event.set() + + link.set_link_established_callback(on_established) + if not established.wait(timeout=float(args.get("link_timeout") or 20)): + try: + link.teardown() + except Exception: + pass + raise TimeoutError("Could not establish link to bug collector") + + receipt = link.request( + REPORT_PATH, + data=body, + response_callback=on_response, + failed_callback=on_failed, + timeout=float(args.get("request_timeout") or 30), + ) + if receipt is None: + try: + link.teardown() + except Exception: + pass + raise RuntimeError("Failed to send bug report request") + if not response_event.wait(timeout=float(args.get("request_timeout") or 30)): + try: + link.teardown() + except Exception: + pass + raise TimeoutError("Bug collector did not acknowledge the report") + try: + link.teardown() + except Exception: + pass + if response_holder.get("error"): + raise RuntimeError(response_holder["error"]) + return { + "ok": True, + "destination_hash": dest_hex, + "bytes": len(body), + "line_count": json.loads(body).get("meta", {}).get("line_count"), + "response": response_holder.get("value"), + } + + def _send_local_report(self, body: bytes) -> dict[str, Any]: + response = self._report_response( + path=REPORT_PATH, + data=body, + request_id=b"local", + link_id=None, + remote_identity=self._identity(), + requested_at=time.time(), + ) + if not isinstance(response, dict) or not response.get("ok"): + error = ( + response.get("error") + if isinstance(response, dict) + else "local delivery failed" + ) + raise RuntimeError(f"Local collector rejected the report: {error}") + return { + "ok": True, + "destination_hash": "local", + "bytes": len(body), + "line_count": json.loads(body).get("meta", {}).get("line_count"), + "response": response, + } + + def send_report(self, args: dict[str, Any]) -> dict[str, Any]: + dest_hex = args.get("destination_hash") + if not isinstance(dest_hex, str) or not dest_hex.strip(): + raise ValueError("destination_hash is required") + + if ( + len(dest_hex) < 32 + or len(dest_hex) > 64 + or len(dest_hex) % 2 != 0 + or any(ch not in "0123456789abcdefABCDEF" for ch in dest_hex) + ): + raise ValueError( + f"Invalid collector hash: '{dest_hex[:20]}{'...' if len(dest_hex) > 20 else ''}' " + f"must be 32–64 hex characters (even length)." + ) + try: + dest_hash = bytes.fromhex(dest_hex) + except ValueError as exc: + raise ValueError( + f"Invalid collector hash: '{dest_hex[:20]}...' " + f"must be 32–64 hex characters (even length)." + ) from exc + + _payload, body = self._build_payload(args) + + with self._lock: + is_local = ( + self._destination is not None + and self._destination.hash.hex().lower() == dest_hex.lower() + ) + + if is_local: + return self._send_local_report(body) + + return self._send_remote_report(dest_hex, dest_hash, body, args) diff --git a/meshchatx/src/backend/data/plugins/mcx-bugs/backend/main.py b/meshchatx/src/backend/data/plugins/mcx-bugs/backend/main.py new file mode 100644 index 00000000..9f1cce64 --- /dev/null +++ b/meshchatx/src/backend/data/plugins/mcx-bugs/backend/main.py @@ -0,0 +1,40 @@ +# SPDX-License-Identifier: 0BSD + +"""Thin Python backend for the bundled mcx-bugs plugin. + +Host manager capabilities do the real work. This module keeps activate/invoke +hooks so the package exercises the Python plugin runtime. +""" + +from __future__ import annotations + +from typing import Any + + +def activate(host) -> None: + host.storage_set("activated", "1") + host.log("mcx-bugs backend activated") + + +def deactivate() -> None: + return None + + +def invoke(method: str, args: dict[str, Any], host=None) -> Any: + if host is None: + host = args + args = method if isinstance(method, dict) else {} + method = "invoke" + args = args or {} + if method == "ping": + return {"ok": True, "activated": host.storage_get("activated")} + if method == "call": + capability = args.get("capability") + if not isinstance(capability, str) or not capability: + raise ValueError("capability is required") + return host.call_manager(capability, args.get("args") or {}) + raise ValueError(f"unknown method: {method}") + + +def on_hook(hook: str, payload: dict[str, Any], host) -> Any: + return None diff --git a/meshchatx/src/backend/data/plugins/mcx-bugs/frontend/main.js b/meshchatx/src/backend/data/plugins/mcx-bugs/frontend/main.js new file mode 100644 index 00000000..59732ddf --- /dev/null +++ b/meshchatx/src/backend/data/plugins/mcx-bugs/frontend/main.js @@ -0,0 +1,716 @@ +/** + * @param {{ t: (key: string) => string }} api + * @param {string} key + * @param {Record} [params] + */ +function formatLabel(api, key, params = {}) { + let text = api.t(key); + for (const [name, value] of Object.entries(params)) { + text = text.replace(`{${name}}`, String(value)); + } + return text; +} + +function shortHash(hash) { + if (!hash || hash.length < 12) { + return hash || "—"; + } + return `${hash.slice(0, 10)}…${hash.slice(-6)}`; +} + +function isHexHash(value) { + if (typeof value !== "string" || value.length === 0) { + return false; + } + if (value.length < 32 || value.length > 64 || value.length % 2 !== 0) { + return false; + } + return /^[0-9a-fA-F]+$/.test(value); +} + +function toast(api, message, type, duration) { + if (typeof api.toast === "function") { + api.toast(message, type || "info", duration); + } +} + +/** + * @param {{ t: (key: string) => string, invoke: Function, setUi: Function, onAction: Function, onRefresh: Function, onInput?: Function, getInputValue: Function, setInputValue?: Function, toast?: Function }} api + */ +export async function activate(api) { + let status = { + collector_running: false, + destination_hash: null, + collector_name: "", + collectors: 0, + reports: 0, + }; + let collectors = []; + let reports = []; + let preview = null; + let activeTab = "send"; + let selectedHash = ""; + let viewingReport = null; + let lastMessage = ""; + let lastMessageType = "info"; + + async function call(capability, args = {}) { + return api.invoke("call", { capability, args }); + } + + function setMessage(message, type = "info") { + lastMessage = message; + lastMessageType = type; + } + + function render() { + const running = Boolean(status.collector_running); + const destHash = status.destination_hash ? String(status.destination_hash) : ""; + + const searchQuery = api.getInputValue("collector-search") || ""; + const filteredCollectors = searchQuery + ? collectors.filter( + (c) => + (c.destination_hash || "").toLowerCase().includes(searchQuery.toLowerCase()) || + (c.name || "").toLowerCase().includes(searchQuery.toLowerCase()) + ) + : collectors; + + api.setUi({ + type: "column", + children: [ + { + type: "text", + variant: "title", + value: formatLabel(api, "title"), + }, + { + type: "text", + variant: "body", + value: formatLabel(api, "description"), + }, + { + type: "actions", + items: [ + { + type: "button", + id: "tab-send", + variant: activeTab === "send" ? undefined : "secondary", + label: formatLabel(api, "tab_send"), + }, + { + type: "button", + id: "tab-collect", + variant: activeTab === "collect" ? undefined : "secondary", + label: formatLabel(api, "tab_collect"), + }, + ], + }, + lastMessage + ? { + type: "text", + variant: + lastMessageType === "error" ? "stat" : lastMessageType === "success" ? "body" : "caption", + value: lastMessage, + } + : null, + activeTab === "send" + ? { + type: "section", + title: formatLabel(api, "sender_section"), + children: [ + { + type: "input", + id: "title", + label: formatLabel(api, "title_label"), + placeholder: formatLabel(api, "title_placeholder"), + value: api.getInputValue("title") || "", + }, + { + type: "input", + id: "description", + label: formatLabel(api, "description_label"), + placeholder: formatLabel(api, "description_placeholder"), + value: api.getInputValue("description") || "", + multiline: true, + }, + { + type: "input", + id: "collector-hash", + label: formatLabel(api, "collector_hash"), + placeholder: formatLabel(api, "collector_hash_placeholder"), + value: selectedHash, + }, + selectedHash + ? { + type: "text", + variant: "caption", + value: formatLabel(api, "selected_collector", { + hash: shortHash(selectedHash), + }), + } + : null, + { + type: "actions", + items: [ + { + type: "button", + id: "reset-destination", + variant: "secondary", + label: formatLabel(api, "reset_destination"), + }, + ...(running && destHash + ? [ + { + type: "button", + id: "use-local", + variant: "secondary", + label: formatLabel(api, "use_my_collector"), + }, + ] + : []), + { + type: "button", + id: "preview", + variant: "secondary", + label: formatLabel(api, "preview"), + }, + { + type: "button", + id: "send", + label: formatLabel(api, "send"), + }, + ], + }, + { + type: "input", + id: "collector-search", + label: formatLabel(api, "search_collectors"), + placeholder: formatLabel(api, "search_placeholder"), + value: api.getInputValue("collector-search") || "", + }, + filteredCollectors.length + ? { + type: "list", + variant: "cards", + items: filteredCollectors.map((entry) => ({ + type: "row", + variant: "card", + children: [ + { + type: "text", + variant: "mono", + value: shortHash(entry.destination_hash), + }, + { + type: "text", + variant: "caption", + value: entry.name || "—", + }, + { + type: "button", + id: `use-${entry.destination_hash}`, + variant: "secondary", + label: formatLabel(api, "use_collector"), + }, + ], + })), + } + : { + type: "text", + variant: "caption", + value: collectors.length + ? formatLabel(api, "no_search_results") + : formatLabel(api, "no_collectors"), + }, + { + type: "actions", + items: [ + { + type: "button", + id: "refresh", + variant: "secondary", + label: formatLabel(api, "refresh"), + }, + ], + }, + preview + ? { + type: "text", + variant: "mono", + value: preview.log_text ? String(preview.log_text).slice(0, 3000) : "", + } + : null, + ].filter(Boolean), + } + : activeTab === "collect" + ? { + type: "column", + children: [ + { + type: "section", + title: formatLabel(api, "collector_section"), + description: running + ? formatLabel(api, "status_running", { hash: shortHash(destHash) }) + : formatLabel(api, "status_idle"), + children: [ + { + type: "input", + id: "collector-name", + label: formatLabel(api, "collector_name_label"), + placeholder: formatLabel(api, "collector_name_placeholder"), + value: api.getInputValue("collector-name") || status.collector_name || "", + }, + { + type: "actions", + items: running + ? [ + { + type: "button", + id: "save-name", + variant: "secondary", + label: formatLabel(api, "save_name"), + }, + { + type: "button", + id: "announce", + variant: "secondary", + label: formatLabel(api, "announce"), + }, + { + type: "button", + id: "stop-collector", + variant: "danger", + label: formatLabel(api, "stop_collector"), + }, + ] + : [ + { + type: "button", + id: "start-collector", + label: formatLabel(api, "start_collector"), + }, + ], + }, + running + ? { + type: "text", + variant: "mono", + value: destHash, + } + : null, + ].filter(Boolean), + }, + { + type: "section", + title: formatLabel(api, "reports_section"), + children: [ + reports.length + ? { + type: "list", + variant: "cards", + items: reports.map((entry, idx) => ({ + type: "row", + variant: "card", + children: [ + { + type: "column", + children: [ + { + type: "text", + variant: "subtitle", + value: entry.title || "—", + }, + { + type: "text", + variant: "caption", + value: formatLabel(api, "report_from", { + source: shortHash(entry.source), + }), + }, + { + type: "text", + variant: "body", + value: + String(entry.description || "").slice( + 0, + 160 + ) || "—", + }, + ], + }, + { + type: "actions", + items: [ + { + type: "button", + id: `view-${idx}`, + variant: "secondary", + label: formatLabel(api, "view"), + }, + { + type: "button", + id: `copy-${idx}`, + variant: "secondary", + label: formatLabel(api, "copy"), + }, + { + type: "button", + id: `export-${idx}`, + variant: "secondary", + label: formatLabel(api, "export"), + }, + { + type: "button", + id: `delete-${idx}`, + variant: "danger", + label: formatLabel(api, "delete"), + }, + ], + }, + ], + })), + } + : { + type: "text", + variant: "caption", + value: formatLabel(api, "no_reports"), + }, + reports.length + ? { + type: "actions", + items: [ + { + type: "button", + id: "clear-reports", + variant: "danger", + label: formatLabel(api, "clear_all"), + }, + ], + } + : null, + ].filter(Boolean), + }, + viewingReport + ? { + type: "section", + title: formatLabel(api, "view_report_section"), + children: [ + { + type: "actions", + items: [ + { + type: "button", + id: "close-view", + variant: "secondary", + label: formatLabel(api, "close"), + }, + { + type: "button", + id: "copy-view", + variant: "secondary", + label: formatLabel(api, "copy"), + }, + ], + }, + { + type: "text", + variant: "mono", + value: JSON.stringify(viewingReport, null, 2).slice(0, 4000), + }, + ], + } + : null, + ].filter(Boolean), + } + : null, + ].filter(Boolean), + }); + } + + function downloadJson(data, filename) { + const text = JSON.stringify(data, null, 2); + if (typeof api.download === "function") { + api.download(filename, text); + } else { + toast(api, "Download not supported in this environment", "error"); + } + } + + async function refresh() { + status = (await call("bugReport.status")) || status; + const listed = await call("bugReport.listCollectors"); + collectors = listed?.collectors || []; + const received = await call("bugReport.listReports", { limit: 20 }); + reports = received?.reports || []; + render(); + } + + api.onAction(async (actionId) => { + try { + if (actionId === "tab-send") { + activeTab = "send"; + render(); + return; + } + if (actionId === "tab-collect") { + activeTab = "collect"; + await refresh(); + render(); + return; + } + if (typeof actionId === "string" && actionId.startsWith("use-") && actionId !== "use-local") { + const hash = actionId.slice(4); + if (isHexHash(hash)) { + selectedHash = hash.toLowerCase(); + setMessage( + formatLabel(api, "destination_set", { + hash: shortHash(selectedHash), + }), + "success" + ); + toast( + api, + formatLabel(api, "destination_set", { + hash: shortHash(selectedHash), + }), + "success" + ); + } else { + setMessage(formatLabel(api, "invalid_hash", { value: hash }), "error"); + toast(api, formatLabel(api, "invalid_hash", { value: hash }), "error"); + } + render(); + return; + } + if (actionId === "use-local") { + const fresh = await call("bugReport.status"); + status = fresh || status; + const localHash = status.destination_hash ? String(status.destination_hash) : ""; + if (isHexHash(localHash)) { + selectedHash = localHash.toLowerCase(); + setMessage(formatLabel(api, "local_set") + ` (${shortHash(selectedHash)})`, "success"); + toast(api, formatLabel(api, "local_set") + ` (${shortHash(selectedHash)})`, "success"); + } else { + const msg = "No local collector running. Start one first."; + setMessage(msg, "warning"); + toast(api, msg, "warning"); + } + render(); + return; + } + if (actionId === "reset-destination") { + selectedHash = ""; + setMessage(formatLabel(api, "destination_reset"), "info"); + toast(api, formatLabel(api, "destination_reset"), "info"); + render(); + return; + } + if (actionId === "refresh") { + await refresh(); + return; + } + if (actionId === "preview") { + preview = await call("bugReport.preview", { limit: 200 }); + setMessage( + formatLabel(api, "preview_stats", { + lines: preview?.line_count || 0, + chars: preview?.chars || 0, + }), + "info" + ); + toast( + api, + formatLabel(api, "preview_stats", { + lines: preview?.line_count || 0, + chars: preview?.chars || 0, + }), + "info" + ); + render(); + return; + } + if (actionId === "send") { + if (!isHexHash(selectedHash)) { + const fresh = await call("bugReport.status"); + status = fresh || status; + const localHash = status.destination_hash ? String(status.destination_hash) : ""; + if (isHexHash(localHash)) { + selectedHash = localHash.toLowerCase(); + } + } + if (!isHexHash(selectedHash)) { + const msg = formatLabel(api, "invalid_hash", { + value: selectedHash || "(empty)", + }); + setMessage(msg, "error"); + toast(api, msg, "error"); + render(); + return; + } + const result = await call("bugReport.send", { + destination_hash: selectedHash, + title: api.getInputValue("title") || "", + description: api.getInputValue("description") || "", + limit: 200, + }); + const msg = formatLabel(api, "send_ok", { + bytes: result?.bytes || 0, + }); + setMessage(msg, "success"); + toast(api, msg, "success"); + await refresh(); + return; + } + if (actionId === "start-collector") { + const name = api.getInputValue("collector-name") || ""; + if (name) { + await call("bugReport.setCollectorName", { name }); + } + status = await call("bugReport.startCollector", { + announce: true, + }); + const msg = formatLabel(api, "collector_started"); + setMessage(msg, "success"); + toast(api, msg, "success"); + await refresh(); + return; + } + if (actionId === "save-name") { + const name = api.getInputValue("collector-name") || ""; + status = await call("bugReport.setCollectorName", { name }); + await call("bugReport.announce"); + const msg = formatLabel(api, "name_saved"); + setMessage(msg, "success"); + toast(api, msg, "success"); + await refresh(); + return; + } + if (actionId === "stop-collector") { + status = await call("bugReport.stopCollector"); + const msg = formatLabel(api, "collector_stopped"); + setMessage(msg, "info"); + toast(api, msg, "info"); + await refresh(); + return; + } + if (actionId === "announce") { + status = await call("bugReport.announce"); + const msg = formatLabel(api, "announce_ok"); + setMessage(msg, "info"); + toast(api, msg, "info"); + render(); + return; + } + if (typeof actionId === "string" && actionId.startsWith("view-")) { + const idx = parseInt(actionId.slice(5), 10); + viewingReport = reports[idx] || null; + if (viewingReport) { + activeTab = "collect"; + } + render(); + return; + } + if (actionId === "close-view") { + viewingReport = null; + render(); + return; + } + if (actionId === "copy-view") { + if (viewingReport) { + const text = JSON.stringify(viewingReport, null, 2); + try { + await navigator.clipboard.writeText(text); + const msg = formatLabel(api, "report_copied"); + setMessage(msg, "success"); + toast(api, msg, "success"); + } catch (err) { + const msg = formatLabel(api, "copy_failed", { + error: String(err), + }); + setMessage(msg, "error"); + toast(api, msg, "error"); + } + } + render(); + return; + } + if (typeof actionId === "string" && actionId.startsWith("delete-")) { + const idx = parseInt(actionId.slice(7), 10); + const entry = reports[idx]; + await call("bugReport.deleteReport", { index: idx }); + const msg = formatLabel(api, "report_deleted", { + title: entry?.title || "", + }); + setMessage(msg, "info"); + toast(api, msg, "info"); + await refresh(); + return; + } + if (typeof actionId === "string" && actionId.startsWith("copy-")) { + const idx = parseInt(actionId.slice(5), 10); + const entry = reports[idx]; + const text = JSON.stringify(entry, null, 2); + try { + await navigator.clipboard.writeText(text); + const msg = formatLabel(api, "report_copied"); + setMessage(msg, "success"); + toast(api, msg, "success"); + } catch (err) { + const msg = formatLabel(api, "copy_failed", { + error: String(err), + }); + setMessage(msg, "error"); + toast(api, msg, "error"); + } + render(); + return; + } + if (typeof actionId === "string" && actionId.startsWith("export-")) { + const idx = parseInt(actionId.slice(7), 10); + const entry = reports[idx]; + const stamp = entry.received_at + ? new Date(typeof entry.received_at === "number" ? entry.received_at * 1000 : entry.received_at) + .toISOString() + .replace(/[:.]/g, "-") + : Date.now(); + const filename = `bug-report-${stamp}.json`; + downloadJson(entry, filename); + const msg = formatLabel(api, "report_exported", { + filename, + }); + setMessage(msg, "success"); + toast(api, msg, "success"); + render(); + return; + } + if (actionId === "clear-reports") { + await call("bugReport.clearReports"); + const msg = formatLabel(api, "reports_cleared"); + setMessage(msg, "info"); + toast(api, msg, "info"); + await refresh(); + return; + } + } catch (error) { + const message = error?.message || String(error); + setMessage(message, "error"); + toast(api, message, "error"); + render(); + } + }); + + api.onRefresh(refresh); + if (typeof api.onInput === "function") { + api.onInput((id) => { + if (id === "collector-search") { + render(); + } + if (id === "collector-hash") { + selectedHash = (api.getInputValue("collector-hash") || "").trim().toLowerCase(); + render(); + } + }); + } + await refresh(); +} diff --git a/meshchatx/src/backend/data/plugins/mcx-bugs/locales/en.json b/meshchatx/src/backend/data/plugins/mcx-bugs/locales/en.json new file mode 100644 index 00000000..ddc87845 --- /dev/null +++ b/meshchatx/src/backend/data/plugins/mcx-bugs/locales/en.json @@ -0,0 +1,62 @@ +{ + "nav": "Bug Reports", + "title": "Bug Reports", + "description": "Collect or send MeshChatX debug logs over the mesh using aspect mcx-bugs-v1.", + "tab_send": "Send", + "tab_collect": "Collect", + "sender_section": "Send Report", + "collector_section": "Run Collector", + "collectors_section": "Heard Collectors", + "reports_section": "Received Reports", + "preview_section": "Log Preview", + "title_label": "Title", + "title_placeholder": "Short summary of the problem", + "description_label": "Description", + "description_placeholder": "What happened, steps to reproduce, expected result", + "collector_hash": "Collector destination hash", + "collector_hash_placeholder": "Paste an mcx-bugs-v1 destination hash", + "selected_collector": "Selected: {hash}", + "reset_destination": "Reset", + "refresh": "Refresh", + "preview": "Preview logs", + "send": "Send report", + "start_collector": "Start collector", + "stop_collector": "Stop collector", + "announce": "Announce now", + "status_idle": "Collector is stopped.", + "status_running": "Collector running at {hash}", + "no_collectors": "No mcx-bugs-v1 collectors heard yet.", + "no_search_results": "No collectors match the search.", + "search_collectors": "Search collectors", + "search_placeholder": "Filter by hash or name", + "no_reports": "No reports received yet.", + "no_preview": "Preview logs to review what will be sent.", + "preview_stats": "{lines} lines, {chars} characters", + "send_ok": "Report sent ({bytes} bytes).", + "invalid_hash": "\"{value}\" is not a valid collector hash. Click 'Use my collector' to auto-fill, or paste a 32–64 character hex hash.", + "collector_started": "Collector started.", + "collector_stopped": "Collector stopped.", + "announce_ok": "Collector announced as mcx-bugs-v1.", + "use_collector": "Use", + "use_my_collector": "Use my collector", + "local_set": "Set to local collector.", + "report_from": "From {source}", + "destination_set": "Collector set to {hash}.", + "destination_reset": "Collector hash cleared.", + "collector_name_label": "Collector display name", + "collector_name_placeholder": "Name shown in announces (optional)", + "save_name": "Save name & announce", + "name_saved": "Name saved and announced.", + "copy": "Copy", + "export": "Export", + "delete": "Delete", + "view": "View", + "close": "Close", + "view_report_section": "Report Details", + "clear_all": "Clear all reports", + "report_deleted": "Report \"{title}\" deleted.", + "report_copied": "Report copied to clipboard.", + "copy_failed": "Copy failed: {error}", + "report_exported": "Exported as {filename}.", + "reports_cleared": "All reports cleared." +} diff --git a/meshchatx/src/backend/data/plugins/mcx-bugs/plugin.json b/meshchatx/src/backend/data/plugins/mcx-bugs/plugin.json new file mode 100644 index 00000000..76a7a2c7 --- /dev/null +++ b/meshchatx/src/backend/data/plugins/mcx-bugs/plugin.json @@ -0,0 +1,57 @@ +{ + "id": "com.meshchatx.mcx-bugs", + "version": "1.0.0", + "apiVersion": 1, + "name": "Bug Reports", + "description": "Send redacted debug logs to an mcx-bugs-v1 collector, or run a collector yourself.", + "frontend": { + "entry": "frontend/main.js", + "type": "js" + }, + "backend": { + "entry": "backend/main.py", + "type": "python" + }, + "i18n": { + "directory": "locales", + "defaultLocale": "en" + }, + "contributes": { + "navItems": [ + { + "id": "mcx-bugs", + "route": { "name": "plugin-mcx-bugs" }, + "icon": "bug-outline", + "labelKey": "nav" + } + ], + "toolsPageEntries": [ + { + "name": "mcx-bugs", + "route": { "name": "plugin-mcx-bugs" }, + "icon": "bug-outline", + "iconBg": "tool-card__icon bg-amber-50 text-amber-700 dark:bg-amber-900/30 dark:text-amber-200", + "titleKey": "title", + "descriptionKey": "description" + } + ] + }, + "permissions": { + "managers": [ + "debugLog.read", + "bugReport.status", + "bugReport.listCollectors", + "bugReport.listReports", + "bugReport.deleteReport", + "bugReport.clearReports", + "bugReport.preview", + "bugReport.send", + "bugReport.startCollector", + "bugReport.stopCollector", + "bugReport.announce", + "bugReport.setCollectorName" + ], + "storage": "isolated", + "network": "none" + } +} diff --git a/meshchatx/src/backend/plugin_manager.py b/meshchatx/src/backend/plugin_manager.py index 539d2ab2..511370ae 100644 --- a/meshchatx/src/backend/plugin_manager.py +++ b/meshchatx/src/backend/plugin_manager.py @@ -873,6 +873,30 @@ class PluginManager: raise PermissionError(f"capability not granted: {capability}") if capability == "destinationPath.read": return self._destination_path_read(args) + if capability == "debugLog.read": + return self._debug_log_read(args) + if capability == "bugReport.status": + return self._bug_report_call("status", args) + if capability == "bugReport.listCollectors": + return self._bug_report_call("list_collectors", args) + if capability == "bugReport.listReports": + return self._bug_report_call("list_reports", args) + if capability == "bugReport.deleteReport": + return self._bug_report_call("delete_report", args) + if capability == "bugReport.clearReports": + return self._bug_report_call("clear_reports", args) + if capability == "bugReport.preview": + return self._bug_report_call("preview_report", args) + if capability == "bugReport.send": + return self._bug_report_call("send_report", args) + if capability == "bugReport.startCollector": + return self._bug_report_call("start_collector", args) + if capability == "bugReport.stopCollector": + return self._bug_report_call("stop_collector", args) + if capability == "bugReport.announce": + return self._bug_report_call("announce", args) + if capability == "bugReport.setCollectorName": + return self._bug_report_call("set_collector_name", args) if capability == "rnsLink.open": return self._rns_link_open(args) if capability == "rnsLink.identify": @@ -885,6 +909,52 @@ class PluginManager: return self._rns_link_close(args) raise ValueError(f"unknown capability: {capability}") + def _require_bug_report_manager(self): + if not self.app: + raise RuntimeError("app is not available") + manager = getattr(self.app, "bug_report_manager", None) + if manager is None: + from meshchatx.src.backend.bug_report_manager import BugReportManager + + manager = BugReportManager(self.app) + self.app.bug_report_manager = manager + return manager + + def _debug_log_read(self, args: dict[str, Any]) -> dict[str, Any]: + manager = self._require_bug_report_manager() + return manager.read_debug_logs( + limit=int(args.get("limit") or 200), + search=args.get("search"), + level=args.get("level"), + module=args.get("module"), + ) + + def _bug_report_call(self, method: str, args: dict[str, Any]) -> Any: + manager = self._require_bug_report_manager() + if method == "status": + return manager.status() + if method == "list_collectors": + return manager.list_collectors() + if method == "list_reports": + return manager.list_reports(limit=int(args.get("limit") or 20)) + if method == "delete_report": + return manager.delete_report(int(args.get("index") or 0)) + if method == "clear_reports": + return manager.clear_reports() + if method == "preview_report": + return manager.preview_report(args or {}) + if method == "send_report": + return manager.send_report(args or {}) + if method == "start_collector": + return manager.start_collector(announce=bool(args.get("announce", True))) + if method == "stop_collector": + return manager.stop_collector() + if method == "announce": + return manager.announce() + if method == "set_collector_name": + return manager.set_collector_name(str(args.get("name") or "")) + raise ValueError(f"unknown bug report method: {method}") + def _require_rns_link_manager(self): if not self.app: raise RuntimeError("app is not available") @@ -1433,6 +1503,12 @@ class PluginManager: def install_bundled_examples(self) -> None: if not self._plugins_runtime_enabled(): return + obsolete = "com.meshchatx.mesh-observatory" + if obsolete in self._plugins: + try: + self.remove(obsolete) + except Exception: + pass bundled_root = os.path.join(os.path.dirname(__file__), "data", "plugins") if not os.path.isdir(bundled_root): return @@ -1443,7 +1519,4 @@ class PluginManager: manifest_path = os.path.join(source, "plugin.json") if not os.path.isfile(manifest_path): continue - with open(manifest_path, encoding="utf-8") as handle: - manifest = json.load(handle) - if manifest.get("id") not in self._plugins: - self.install_from_directory(source) + self.install_from_directory(source) diff --git a/meshchatx/src/backend/plugin_permissions.py b/meshchatx/src/backend/plugin_permissions.py index 7d0d327e..322bf1c8 100644 --- a/meshchatx/src/backend/plugin_permissions.py +++ b/meshchatx/src/backend/plugin_permissions.py @@ -19,6 +19,18 @@ KNOWN_HOOKS = frozenset( KNOWN_MANAGERS = frozenset( { "destinationPath.read", + "debugLog.read", + "bugReport.status", + "bugReport.listCollectors", + "bugReport.listReports", + "bugReport.deleteReport", + "bugReport.clearReports", + "bugReport.preview", + "bugReport.send", + "bugReport.startCollector", + "bugReport.stopCollector", + "bugReport.announce", + "bugReport.setCollectorName", "rnsLink.open", "rnsLink.identify", "rnsLink.request", diff --git a/meshchatx/src/backend/rns_startup_recovery.py b/meshchatx/src/backend/rns_startup_recovery.py new file mode 100644 index 00000000..54733149 --- /dev/null +++ b/meshchatx/src/backend/rns_startup_recovery.py @@ -0,0 +1,363 @@ +# SPDX-License-Identifier: 0BSD + +"""Contain RNS process-killing exits and recover from bad interface configs. + +Reticulum's ``RNS.panic()`` calls ``os._exit(255)``, which kills the whole +MeshChatX process (fatal on Android where Python runs in-process). Interface +init failures can also leave the app unable to start until the user wipes +storage. This module: + +1. Replaces ``RNS.panic`` / ``RNS.exit`` with catchable exceptions +2. Forces ``panic_on_interface_error = No`` in the Reticulum config +3. Progressively disables risky interfaces and retries RNS construction +""" + +from __future__ import annotations + +import logging +import os +import re +from typing import Any, Callable + +logger = logging.getLogger(__name__) + +_TRUE_STRINGS = ("true", "yes", "1", "on", "y") +_PANIC_PATCHED = False +_ORIGINAL_PANIC = None +_ORIGINAL_EXIT = None + +# Prefer disabling these types first when init fails without a named culprit. +_HIGH_RISK_TYPES = ( + "I2PInterface", + "RNodeMultiInterface", + "RNodeInterface", + "RNodeIPInterface", + "AutoInterface", + "SerialInterface", + "KISSInterface", + "AX25KISSInterface", + "PipeInterface", +) + + +class RnsPanicError(RuntimeError): + """Raised instead of ``os._exit`` when RNS would panic or hard-exit.""" + + +def install_rns_panic_containment(*, force: bool = False) -> bool: + """Replace RNS panic/exit with exceptions so the HTTP process can survive. + + Safe to call multiple times. Returns True when the patch was applied (or + was already applied). + """ + global _PANIC_PATCHED, _ORIGINAL_PANIC, _ORIGINAL_EXIT + if _PANIC_PATCHED and not force: + return True + try: + import RNS + except Exception as exc: + logger.warning("Could not import RNS for panic containment: %s", exc) + return False + + if _ORIGINAL_PANIC is None: + _ORIGINAL_PANIC = getattr(RNS, "panic", None) + if _ORIGINAL_EXIT is None: + _ORIGINAL_EXIT = getattr(RNS, "exit", None) + + def _contained_panic(*_args, **_kwargs): + message = "RNS.panic() was called" + if _args: + message = f"RNS.panic(): {_args[0]}" + logger.error(message) + raise RnsPanicError(message) + + def _contained_exit(code: int = 0): + message = f"RNS.exit({code}) was called" + logger.error(message) + try: + if hasattr(RNS, "Reticulum") and hasattr(RNS.Reticulum, "exit_handler"): + RNS.Reticulum.exit_handler() + except Exception as exc: + logger.warning("RNS exit_handler during contained exit failed: %s", exc) + if code != 0: + raise RnsPanicError(message) + + RNS.panic = _contained_panic + RNS.exit = _contained_exit + _PANIC_PATCHED = True + logger.info("Installed RNS panic/exit containment (os._exit disabled for RNS)") + return True + + +def ensure_panic_on_interface_error_disabled(config_path: str) -> bool: + """Force ``panic_on_interface_error = No`` so interface faults cannot kill RNS.""" + if not os.path.isfile(config_path): + return False + try: + from RNS.vendor.configobj import ConfigObj + + cfg = ConfigObj(config_path) + except Exception: + return False + + reticulum = cfg.get("reticulum") + if not isinstance(reticulum, dict): + reticulum = {} + cfg["reticulum"] = reticulum + + current = str(reticulum.get("panic_on_interface_error", "No")).strip().lower() + if current in ("no", "false", "0", "off", ""): + if "panic_on_interface_error" not in reticulum: + reticulum["panic_on_interface_error"] = "No" + try: + cfg.write() + except Exception: + return False + return True + return False + + reticulum["panic_on_interface_error"] = "No" + try: + cfg.write() + except Exception as exc: + logger.warning( + "Failed to disable panic_on_interface_error in %s: %s", + config_path, + exc, + ) + return False + logger.warning( + "Disabled panic_on_interface_error in %s so interface errors cannot " + "kill the MeshChatX process", + config_path, + ) + return True + + +def _is_enabled(iface: dict) -> bool: + for key in ("interface_enabled", "enabled"): + if key in iface and str(iface.get(key, "")).strip().lower() in _TRUE_STRINGS: + return True + return False + + +def _disable_iface(iface: dict) -> None: + iface["interface_enabled"] = "false" + if "enabled" in iface: + iface["enabled"] = "false" + + +def list_enabled_interface_names(config_path: str) -> list[str]: + if not os.path.isfile(config_path): + return [] + try: + from RNS.vendor.configobj import ConfigObj + + cfg = ConfigObj(config_path) + except Exception: + return [] + interfaces = cfg.get("interfaces") + if not isinstance(interfaces, dict): + return [] + return [ + name + for name, iface in interfaces.items() + if isinstance(iface, dict) and _is_enabled(iface) + ] + + +def disable_named_interfaces_in_config( + config_path: str, + names: list[str] | set[str], +) -> list[str]: + """Disable the named interfaces. Returns names that were actually disabled.""" + if not names or not os.path.isfile(config_path): + return [] + try: + from RNS.vendor.configobj import ConfigObj + + cfg = ConfigObj(config_path) + except Exception: + return [] + interfaces = cfg.get("interfaces") + if not isinstance(interfaces, dict): + return [] + disabled: list[str] = [] + wanted = {str(n) for n in names} + for name, iface in interfaces.items(): + if name not in wanted or not isinstance(iface, dict): + continue + if not _is_enabled(iface): + continue + _disable_iface(iface) + disabled.append(name) + logger.warning('Disabled interface "%s" during RNS startup recovery', name) + if not disabled: + return [] + try: + cfg.write() + except Exception as exc: + logger.warning("Failed to write interface recovery config: %s", exc) + return [] + return disabled + + +def disable_interfaces_by_type( + config_path: str, + iface_types: tuple[str, ...] | list[str], + *, + limit: int | None = None, +) -> list[str]: + if not os.path.isfile(config_path): + return [] + try: + from RNS.vendor.configobj import ConfigObj + + cfg = ConfigObj(config_path) + except Exception: + return [] + interfaces = cfg.get("interfaces") + if not isinstance(interfaces, dict): + return [] + type_set = {str(t) for t in iface_types} + disabled: list[str] = [] + for name, iface in interfaces.items(): + if not isinstance(iface, dict) or not _is_enabled(iface): + continue + if str(iface.get("type") or "").strip() not in type_set: + continue + _disable_iface(iface) + disabled.append(name) + logger.warning( + 'Disabled %s interface "%s" during RNS startup recovery', + iface.get("type"), + name, + ) + if limit is not None and len(disabled) >= limit: + break + if not disabled: + return [] + try: + cfg.write() + except Exception as exc: + logger.warning("Failed to write typed interface recovery config: %s", exc) + return [] + return disabled + + +def extract_interface_names_from_error(error: BaseException | str) -> list[str]: + """Best-effort parse of interface section names from an RNS error string.""" + text = str(error) + found: list[str] = [] + patterns = ( + r'interface\s+"([^"]+)"', + r"interface\s+'([^']+)'", + r"Interface\[([^\]]+)\]", + r"I2PInterface\[([^\]]+)\]", + r"AutoInterface\[([^\]]+)\]", + r"TCPClientInterface\[([^\]]+)\]", + r"TCPServerInterface\[([^\]]+)\]", + r"RNodeInterface\[([^\]]+)\]", + r"The interface name \"([^\"]+)\" was already used", + ) + for pattern in patterns: + for match in re.finditer(pattern, text, flags=re.IGNORECASE): + name = match.group(1).strip() + if name and name not in found: + found.append(name) + return found + + +def apply_startup_recovery_step( + config_path: str, + error: BaseException | str, + *, + attempt: int, +) -> list[str]: + """Disable something that might be blocking RNS init. Returns disabled names. + + Steps escalate with *attempt*: + 0. Named interfaces from the error (if any), else I2P + 1. RNode / serial / kiss family + 2. AutoInterface + 3. Any remaining enabled high-risk interface (one at a time) + """ + from meshchatx.src.backend import i2p_support + from meshchatx.src.backend.rnode_support import ( + disable_rnode_interfaces_in_config, + _is_chaquopy_android, + ) + + disabled: list[str] = [] + named = extract_interface_names_from_error(error) + if named: + disabled.extend(disable_named_interfaces_in_config(config_path, named)) + if disabled: + return disabled + + if attempt <= 0: + if i2p_support.disable_all_i2p_in_config(config_path): + # Names unknown here; report a synthetic marker for logs/tests. + disabled.append("__i2p__") + return disabled + + if attempt == 1: + if disable_rnode_interfaces_in_config( + config_path, + is_android=_is_chaquopy_android(), + ): + disabled.append("__rnode__") + more = disable_interfaces_by_type( + config_path, + ("SerialInterface", "KISSInterface", "AX25KISSInterface", "PipeInterface"), + ) + disabled.extend(more) + return disabled + + if attempt == 2: + more = disable_interfaces_by_type(config_path, ("AutoInterface",)) + disabled.extend(more) + return disabled + + # Final attempts: peel off one high-risk enabled interface at a time. + for iface_type in _HIGH_RISK_TYPES: + more = disable_interfaces_by_type(config_path, (iface_type,), limit=1) + if more: + disabled.extend(more) + break + return disabled + + +def create_reticulum_with_recovery( + config_dir: str, + *, + construct: Callable[[], Any], + max_attempts: int = 5, +) -> Any: + """Construct RNS, progressively disabling bad interfaces on failure.""" + install_rns_panic_containment() + config_path = os.path.join(config_dir, "config") + ensure_panic_on_interface_error_disabled(config_path) + + last_exc: Exception | None = None + for attempt in range(max_attempts): + try: + return construct() + except Exception as exc: + last_exc = exc + disabled = apply_startup_recovery_step( + config_path, + exc, + attempt=attempt, + ) + if not disabled: + break + print( + "Reticulum init failed; disabled " + f"{', '.join(disabled)} and retrying " + f"(attempt {attempt + 1}/{max_attempts}). " + f"Error: {exc}", + flush=True, + ) + assert last_exc is not None + raise last_exc diff --git a/meshchatx/src/frontend/components/Toast.vue b/meshchatx/src/frontend/components/Toast.vue index 3c857d61..d2297f72 100644 --- a/meshchatx/src/frontend/components/Toast.vue +++ b/meshchatx/src/frontend/components/Toast.vue @@ -9,7 +9,7 @@ v-for="toast in toasts" :key="toast.id" ref="toastRefs" - class="pointer-events-auto flex items-center p-4 w-full sm:min-w-[300px] sm:max-w-md rounded-xl shadow-lg border backdrop-blur-md transition-all duration-300 select-none touch-pan-y" + class="pointer-events-auto flex items-center p-4 w-full sm:min-w-[300px] sm:max-w-md rounded-xl shadow-lg border backdrop-blur-md transition-all duration-300 select-text touch-pan-y" :class="[toastClass(toast.type), toast.swipeClass]" :style="toastSwipeStyle(toast)" @touchstart="onTouchStart($event, toast)" diff --git a/meshchatx/src/frontend/components/plugins/PluginSlotNode.vue b/meshchatx/src/frontend/components/plugins/PluginSlotNode.vue index 8f5ffee5..64b05e5f 100644 --- a/meshchatx/src/frontend/components/plugins/PluginSlotNode.vue +++ b/meshchatx/src/frontend/components/plugins/PluginSlotNode.vue @@ -9,7 +9,15 @@ +