feat(mcx-bugs): local delivery, tabs, toasts, search, report view/export/delete

This commit is contained in:
Ivan 2026-07-10 00:57:43 -05:00
parent 220a1bc7f8
commit 7023a7a232
No known key found for this signature in database
17 changed files with 3044 additions and 212 deletions

File diff suppressed because it is too large Load diff

View file

@ -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 3264 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 3264 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)

View file

@ -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

View file

@ -0,0 +1,716 @@
/**
* @param {{ t: (key: string) => string }} api
* @param {string} key
* @param {Record<string, string | number>} [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();
}

View file

@ -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 3264 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."
}

View file

@ -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"
}
}

View file

@ -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)

View file

@ -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",

View file

@ -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

View file

@ -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)"

View file

@ -9,7 +9,15 @@
<label v-if="node.label" class="block text-sm font-medium text-gray-700 dark:text-gray-300">
{{ node.label }}
</label>
<textarea
v-if="node.multiline"
class="w-full rounded-lg border border-gray-300 dark:border-zinc-700 bg-white dark:bg-zinc-900 px-3 py-2.5 text-sm text-gray-900 dark:text-gray-100 placeholder:text-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500/40 focus:border-blue-500 min-h-[6rem]"
:placeholder="node.placeholder || ''"
:value="node.value || ''"
@input="$emit('input', { id: node.id, value: $event.target.value })"
/>
<input
v-else
class="w-full rounded-lg border border-gray-300 dark:border-zinc-700 bg-white dark:bg-zinc-900 px-3 py-2.5 text-sm text-gray-900 dark:text-gray-100 placeholder:text-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500/40 focus:border-blue-500"
type="text"
:placeholder="node.placeholder || ''"
@ -18,6 +26,19 @@
/>
</div>
<label
v-else-if="node.type === 'checkbox'"
class="flex items-start gap-2.5 text-sm text-gray-700 dark:text-gray-300 cursor-pointer"
>
<input
class="mt-0.5 rounded border-gray-300 dark:border-zinc-600 text-blue-600 focus:ring-blue-500/40"
type="checkbox"
:checked="Boolean(node.checked)"
@change="$emit('input', { id: node.id, value: $event.target.checked ? '1' : '0' })"
/>
<span>{{ node.label }}</span>
</label>
<button v-else-if="node.type === 'button'" type="button" :class="buttonClass" @click="$emit('action', node.id)">
{{ node.label }}
</button>
@ -169,7 +190,7 @@ export default {
methods: {
actionButtonClass(action) {
const base =
"inline-flex items-center justify-center px-4 py-2 rounded-lg text-sm font-medium transition-colors focus:outline-none focus:ring-2 focus:ring-blue-500/40";
"inline-flex items-center justify-center px-4 py-2 rounded-lg text-sm font-medium transition-colors focus:outline-none focus:ring-2 focus:ring-blue-500/40 w-fit";
if (action.variant === "secondary") {
return `${base} border border-gray-300 dark:border-zinc-600 bg-white dark:bg-zinc-900 text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-zinc-800`;
}

View file

@ -6,6 +6,7 @@ import { setPluginUiLabels, clearPluginUiLabels } from "./pluginUiRegistry.js";
import { registerNavItem, unregisterNavItem } from "../registries/navRegistry.js";
import { registerTool, unregisterTool } from "../registries/toolsRegistry.js";
import { onWsEvent, offWsEvent } from "../registries/wsEventRegistry.js";
import ToastUtils from "../ToastUtils.js";
/** @typedef {import('./pluginManifest.js').PluginManifest} PluginManifest */
@ -50,7 +51,7 @@ export class PluginHost {
}
const labels = await loadPluginLabelMap(apiClient, pluginId, locale, manifest);
setPluginUiLabels(pluginId, labels);
const assetUrl = `/api/v1/plugins/${encodeURIComponent(pluginId)}/asset/${manifest.frontend.entry}`;
const assetUrl = `/api/v1/plugins/${encodeURIComponent(pluginId)}/asset/${manifest.frontend.entry}?v=${encodeURIComponent(manifest.version || "1")}`;
const sourceResponse = await apiClient.get(assetUrl, { responseType: "text" });
const source =
typeof sourceResponse.data === "string" ? sourceResponse.data : String(sourceResponse.data ?? "");
@ -230,6 +231,26 @@ export class PluginHost {
);
this.unloadPlugin(pluginId);
}
if (message.type === "toast") {
ToastUtils.show(message.message || "", message.toastType || "info", message.duration ?? 5000);
}
if (message.type === "download") {
try {
const blob = new Blob([message.data || ""], {
type: "application/json",
});
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = message.filename || "download.json";
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
} catch (e) {
console.error("Plugin download failed:", e);
}
}
}
unloadPlugin(pluginId) {

View file

@ -47,9 +47,13 @@ export async function loadPluginLabelMap(apiClient, pluginId, locale, manifest =
for (const code of candidates) {
try {
const assetPath = `${directory}/${code}.json`;
const response = await apiClient.get(`/api/v1/plugins/${encodeURIComponent(pluginId)}/asset/${assetPath}`, {
responseType: "json",
});
const version = manifest.version || "1";
const response = await apiClient.get(
`/api/v1/plugins/${encodeURIComponent(pluginId)}/asset/${assetPath}?v=${encodeURIComponent(version)}`,
{
responseType: "json",
}
);
if (response.data && typeof response.data === "object") {
return flattenLocaleMessages(response.data);
}

View file

@ -18,6 +18,7 @@ function handleWorkerMessage(event, post) {
ui: null,
inputValues: {},
actionHandler: null,
inputHandler: null,
eventHandlers: new Map(),
refreshHandler: null,
};
@ -45,12 +46,24 @@ function handleWorkerMessage(event, post) {
onEvent(eventName, handler) {
state.eventHandlers.set(eventName, handler);
},
onInput(handler) {
state.inputHandler = handler;
},
getInputValue(id) {
return state.inputValues[id] ?? "";
},
setInputValue(id, value) {
state.inputValues[id] = value == null ? "" : String(value);
},
onRefresh(handler) {
state.refreshHandler = handler;
},
toast(message, type = "info", duration = 5000) {
post({ type: "toast", message, toastType: type, duration });
},
download(filename, data) {
post({ type: "download", filename, data });
},
};
function postRequest(kind, payload) {
@ -102,6 +115,9 @@ function handleWorkerMessage(event, post) {
}
if (next.type === "input") {
state.inputValues[next.id] = next.value;
if (typeof state.inputHandler === "function") {
void state.inputHandler(next.id, next.value);
}
return;
}
if (next.type === "refresh-ui") {

View file

@ -0,0 +1,305 @@
# SPDX-License-Identifier: 0BSD
import json
import pytest
from meshchatx.src.backend.bug_report_manager import BugReportManager
def _fake_app(tmp_path):
class FakeApp:
storage_dir = str(tmp_path)
current_context = None
return FakeApp()
def test_preview_report_uses_database_logs(tmp_path):
class FakeLogs:
def get_logs(self, **_kwargs):
return [
{
"timestamp": 1.0,
"level": "ERROR",
"module": "meshchat",
"message": "fail at /tmp/x for aabbccddeeff00112233445566778899",
}
]
def get_total_count(self, **_kwargs):
return 1
class FakeDatabase:
debug_logs = FakeLogs()
class FakeApp:
database = FakeDatabase()
storage_dir = str(tmp_path)
current_context = None
manager = BugReportManager(FakeApp())
preview = manager.preview_report({"limit": 5})
assert preview["line_count"] == 1
assert "/tmp/x" in preview["log_text"]
assert "aabbccddeeff00112233445566778899" in preview["log_text"]
assert preview["chars"] > 0
def test_preview_report_empty_when_no_logs(tmp_path):
manager = BugReportManager(_fake_app(tmp_path))
preview = manager.preview_report({"limit": 10})
assert preview["line_count"] == 0
assert preview["chars"] == 0
assert preview["log_text"] == ""
def test_list_collectors_and_reports_empty(tmp_path):
manager = BugReportManager(_fake_app(tmp_path))
assert manager.list_collectors()["collectors"] == []
assert manager.list_reports()["reports"] == []
assert manager.status()["collector_running"] is False
def test_delete_and_clear_reports(tmp_path):
manager = BugReportManager(_fake_app(tmp_path))
manager._reports = [
{"received_at": 1000, "title": "A"},
{"received_at": 2000, "title": "B"},
{"received_at": 3000, "title": "C"},
]
assert len(manager.list_reports(limit=10)["reports"]) == 3
result = manager.delete_report(1)
assert result["ok"] is True
assert len(manager.list_reports(limit=10)["reports"]) == 2
titles = [r["title"] for r in manager.list_reports(limit=10)["reports"]]
assert "B" not in titles
with pytest.raises(IndexError):
manager.delete_report(99)
result = manager.clear_reports()
assert result["ok"] is True
assert manager.list_reports()["reports"] == []
def test_collector_name_is_truncated(tmp_path):
manager = BugReportManager(_fake_app(tmp_path))
assert manager.status()["collector_name"] == ""
manager.set_collector_name("Test Node")
assert manager.status()["collector_name"] == "Test Node"
manager.set_collector_name("x" * 100)
assert len(manager.status()["collector_name"]) <= 64
def test_report_receive_and_list(tmp_path):
manager = BugReportManager(_fake_app(tmp_path))
response = manager._report_response(
path="/report",
data=json.dumps(
{"title": "test", "description": "desc", "log_text": "log"}
).encode("utf-8"),
request_id=b"",
link_id=None,
remote_identity=None,
requested_at=0,
)
assert response["ok"] is True
reports = manager.list_reports(limit=10)["reports"]
assert len(reports) == 1
assert reports[0]["title"] == "test"
assert "redactions" not in reports[0]
def test_send_report_rejects_invalid_hashes(tmp_path, monkeypatch):
manager = BugReportManager(_fake_app(tmp_path))
monkeypatch.setattr("RNS.Identity.recall", lambda _h: None)
with pytest.raises(ValueError, match="destination_hash is required"):
manager.send_report(
{
"destination_hash": "",
"title": "t",
"description": "d",
}
)
invalid_hashes = [
"local",
"abc",
"g" * 64,
"0" * 30,
"0" * 31,
"0" * 65,
"0" * 66,
"0" * 33,
]
for h in invalid_hashes:
with pytest.raises(ValueError, match="Invalid collector hash"):
manager.send_report(
{
"destination_hash": h,
"title": "t",
"description": "d",
}
)
def test_send_report_accepts_32_and_64_char_hashes(tmp_path, monkeypatch):
manager = BugReportManager(_fake_app(tmp_path))
received = []
def fake_report_response(*args, **kwargs):
received.append((args, kwargs))
return {"ok": True}
monkeypatch.setattr(manager, "_report_response", fake_report_response)
for length in (32, 64):
hash_value = "a" * length
class FakeDestination:
hash = type("H", (), {"hex": lambda _s, _hash=hash_value: _hash})()
manager._destination = FakeDestination()
result = manager.send_report(
{
"destination_hash": hash_value,
"title": f"len{length}",
"description": "d",
"limit": 5,
}
)
assert result["ok"] is True
assert len(received) == 2
def test_send_local_report_bypasses_rns(tmp_path, monkeypatch):
manager = BugReportManager(_fake_app(tmp_path))
local_hash = "a" * 64
class FakeDestination:
hash = type("H", (), {"hex": lambda _s: local_hash})()
manager._destination = FakeDestination()
received = []
def fake_report_response(*args, **kwargs):
received.append((args, kwargs))
return {"ok": True}
monkeypatch.setattr(manager, "_report_response", fake_report_response)
result = manager.send_report(
{
"destination_hash": local_hash,
"title": "Local Bug",
"description": "desc",
"limit": 5,
}
)
assert result["ok"] is True
assert result["destination_hash"] == "local"
assert len(received) == 1
assert b"Local Bug" in received[0][1]["data"]
def test_send_remote_report_requires_identity_recall(tmp_path, monkeypatch):
manager = BugReportManager(_fake_app(tmp_path))
remote_hash = "b" * 64
monkeypatch.setattr("RNS.Identity.recall", lambda _h: None)
with pytest.raises(LookupError, match="Could not recall collector identity"):
manager.send_report(
{
"destination_hash": remote_hash,
"title": "Remote Bug",
"description": "desc",
}
)
def test_status_reflects_local_collector(tmp_path):
manager = BugReportManager(_fake_app(tmp_path))
local_hash = "c" * 64
class FakeDestination:
hash = type("H", (), {"hex": lambda _s: local_hash})()
manager._destination = FakeDestination()
manager._collector_name = "MyCollector"
status = manager.status()
assert status["collector_running"] is True
assert status["destination_hash"] == local_hash
assert status["collector_name"] == "MyCollector"
assert status["reports"] == 0
assert status["collectors"] == 0
def test_build_payload_enforces_size_limit(tmp_path):
manager = BugReportManager(_fake_app(tmp_path))
manager._reports = []
class FakeLogs:
def get_logs(self, **_kwargs):
return [
{"timestamp": 1, "level": "INFO", "module": "m", "message": "x"}
] * 10
def get_total_count(self, **_kwargs):
return 10
manager.app = type(
"A", (), {"database": type("DB", (), {"debug_logs": FakeLogs()})()}
)()
payload, body = manager._build_payload({"limit": 10})
assert payload["title"] == "MeshChatX bug report"
assert "log_text" in payload
assert len(body) > 0
def test_clear_reports_deletes_persisted_files(tmp_path):
manager = BugReportManager(_fake_app(tmp_path))
manager._reports = [{"received_at": 1234, "title": "X"}]
manager._persist_report(manager._reports[0])
storage = manager._ensure_storage_dir()
assert any(f.endswith(".json") for f in manager._storage_dir_list(storage))
manager.clear_reports()
assert manager.list_reports()["reports"] == []
assert not any(f.endswith(".json") for f in manager._storage_dir_list(storage))
# helper to avoid listing errors on empty dir
def test_delete_report_deletes_persisted_file(tmp_path):
manager = BugReportManager(_fake_app(tmp_path))
manager._reports = [
{"received_at": 1000, "title": "A"},
{"received_at": 2000, "title": "B"},
]
manager._persist_report(manager._reports[0])
manager._persist_report(manager._reports[1])
result = manager.delete_report(0)
assert result["ok"] is True
assert len(manager.list_reports(limit=10)["reports"]) == 1
# Patch the manager to add a list helper for cross-platform robustness
BugReportManager._storage_dir_list = lambda _self, path: [
name for name in __import__("os").listdir(path)
]

View file

@ -18,13 +18,13 @@ class TestPluginManagerInstall:
manager.install_bundled_examples()
plugins = manager.list_plugins()
ids = [plugin["id"] for plugin in plugins]
assert "com.meshchatx.mesh-observatory" in ids
assert "com.meshchatx.mcx-bugs" in ids
assert "com.meshchatx.transport-node-monitor" not in ids
def test_enable_disable_plugin(self, tmp_path):
manager = _make_manager(tmp_path)
manager.install_bundled_examples()
plugin_id = "com.meshchatx.mesh-observatory"
plugin_id = "com.meshchatx.mcx-bugs"
enabled = manager.enable(plugin_id)
assert enabled["enabled"] is True
disabled = manager.disable(plugin_id)
@ -33,7 +33,7 @@ class TestPluginManagerInstall:
def test_storage_roundtrip(self, tmp_path):
manager = _make_manager(tmp_path)
manager.install_bundled_examples()
plugin_id = "com.meshchatx.mesh-observatory"
plugin_id = "com.meshchatx.mcx-bugs"
manager.storage_set(plugin_id, "sample_key", json.dumps(["abc123"]))
value = manager.storage_get(plugin_id, "sample_key")
assert json.loads(value) == ["abc123"]
@ -41,42 +41,46 @@ class TestPluginManagerInstall:
def test_permission_denied_for_manager_capability(self, tmp_path):
manager = _make_manager(tmp_path)
manager.install_bundled_examples()
plugin_id = "com.meshchatx.mesh-observatory"
plugin_id = "com.meshchatx.mcx-bugs"
manager.enable(plugin_id)
with pytest.raises(PermissionError):
manager.call_manager(plugin_id, "unknown.capability", {})
def test_destination_path_read_uses_rnpath_handler(self, tmp_path):
class FakeHandler:
def get_path_table(self, search=None, limit=0):
return {
"table": [
{
"hash": "abc123",
"hops": 2,
"via": "def456",
"interface": "RNode LoRa",
"state": 1,
"timestamp": 1.0,
}
],
"total": 1,
"responsive": 1,
"unresponsive": 0,
}
def test_bug_report_preview_reads_debug_logs(self, tmp_path):
class FakeLogs:
def get_logs(self, **_kwargs):
return [
{
"timestamp": 1.0,
"level": "INFO",
"module": "meshchat",
"message": "peer aa" + ("bb" * 15) + " at /home/user1/secret",
}
]
def get_total_count(self, **_kwargs):
return 1
class FakeDatabase:
debug_logs = FakeLogs()
class FakeApp:
reticulum = object()
rnpath_handler = FakeHandler()
database = FakeDatabase()
storage_dir = str(tmp_path)
current_context = None
manager = _make_manager(tmp_path, app=FakeApp())
manager.install_bundled_examples()
plugin_id = "com.meshchatx.mesh-observatory"
plugin_id = "com.meshchatx.mcx-bugs"
manager.enable(plugin_id)
result = manager.call_manager(plugin_id, "destinationPath.read", {"limit": 10})
assert result["total"] == 1
assert result["paths"][0]["destination_hash"] == "abc123"
assert result["paths"][0]["interface"] == "RNode LoRa"
preview = manager.call_manager(
plugin_id,
"bugReport.preview",
{"limit": 10},
)
assert preview["line_count"] == 1
assert "/home/user1/secret" in preview["log_text"]
def test_rns_link_capabilities_require_manifest_grant(self, tmp_path):
class FakeLinkManager:
@ -95,7 +99,7 @@ class TestPluginManagerInstall:
manager = _make_manager(tmp_path, app=FakeApp())
manager.install_bundled_examples()
plugin_id = "com.meshchatx.mesh-observatory"
plugin_id = "com.meshchatx.mcx-bugs"
manager.enable(plugin_id)
with pytest.raises(PermissionError):
manager.call_manager(
@ -139,7 +143,7 @@ class TestPluginManagerInstall:
manager = _make_manager(tmp_path, app=FakeApp())
manager.install_bundled_examples()
plugin_id = "com.meshchatx.mesh-observatory"
plugin_id = "com.meshchatx.mcx-bugs"
manager.enable(plugin_id)
record = manager._plugins[plugin_id]
record.manifest.setdefault("permissions", {})["hooks"] = [
@ -194,7 +198,7 @@ class TestPluginManagerInstall:
assert manager.list_plugins() == []
source = os.path.join(
os.path.dirname(__file__),
"../../meshchatx/src/backend/data/plugins/mesh-observatory",
"../../meshchatx/src/backend/data/plugins/mcx-bugs",
)
with pytest.raises(PermissionError):
manager.install_from_directory(os.path.abspath(source))

View file

@ -10,10 +10,10 @@ import {
describe("pluginLabels", () => {
it("flattens nested plugin locale messages", () => {
const labels = flattenLocaleMessages({
title: "Mesh Observatory",
title: "Bug Reports",
nested: { value: "Hello" },
});
expect(labels.title).toBe("Mesh Observatory");
expect(labels.title).toBe("Bug Reports");
expect(labels["nested.value"]).toBe("Hello");
});