mirror of
https://github.com/Quad4-Software/MeshChatX.git
synced 2026-08-18 09:49:09 -04:00
feat(mcx-bugs): local delivery, tabs, toasts, search, report view/export/delete
This commit is contained in:
parent
220a1bc7f8
commit
7023a7a232
17 changed files with 3044 additions and 212 deletions
File diff suppressed because it is too large
Load diff
568
meshchatx/src/backend/bug_report_manager.py
Normal file
568
meshchatx/src/backend/bug_report_manager.py
Normal 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 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)
|
||||
40
meshchatx/src/backend/data/plugins/mcx-bugs/backend/main.py
Normal file
40
meshchatx/src/backend/data/plugins/mcx-bugs/backend/main.py
Normal 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
|
||||
716
meshchatx/src/backend/data/plugins/mcx-bugs/frontend/main.js
Normal file
716
meshchatx/src/backend/data/plugins/mcx-bugs/frontend/main.js
Normal 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();
|
||||
}
|
||||
62
meshchatx/src/backend/data/plugins/mcx-bugs/locales/en.json
Normal file
62
meshchatx/src/backend/data/plugins/mcx-bugs/locales/en.json
Normal 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 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."
|
||||
}
|
||||
57
meshchatx/src/backend/data/plugins/mcx-bugs/plugin.json
Normal file
57
meshchatx/src/backend/data/plugins/mcx-bugs/plugin.json
Normal 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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
363
meshchatx/src/backend/rns_startup_recovery.py
Normal file
363
meshchatx/src/backend/rns_startup_recovery.py
Normal 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
|
||||
|
|
@ -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)"
|
||||
|
|
|
|||
|
|
@ -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`;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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") {
|
||||
|
|
|
|||
305
tests/backend/test_bug_report_manager.py
Normal file
305
tests/backend/test_bug_report_manager.py
Normal 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)
|
||||
]
|
||||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue