numerous improvements
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -52,28 +52,37 @@ class AnnounceManager:
|
|||||||
limit=None,
|
limit=None,
|
||||||
offset=0,
|
offset=0,
|
||||||
):
|
):
|
||||||
sql = "SELECT * FROM announces WHERE 1=1"
|
sql = """
|
||||||
|
SELECT a.*, c.custom_image as contact_image
|
||||||
|
FROM announces a
|
||||||
|
LEFT JOIN contacts c ON (
|
||||||
|
a.identity_hash = c.remote_identity_hash OR
|
||||||
|
a.destination_hash = c.lxmf_address OR
|
||||||
|
a.destination_hash = c.lxst_address
|
||||||
|
)
|
||||||
|
WHERE 1=1
|
||||||
|
"""
|
||||||
params = []
|
params = []
|
||||||
|
|
||||||
if aspect:
|
if aspect:
|
||||||
sql += " AND aspect = ?"
|
sql += " AND a.aspect = ?"
|
||||||
params.append(aspect)
|
params.append(aspect)
|
||||||
if identity_hash:
|
if identity_hash:
|
||||||
sql += " AND identity_hash = ?"
|
sql += " AND a.identity_hash = ?"
|
||||||
params.append(identity_hash)
|
params.append(identity_hash)
|
||||||
if destination_hash:
|
if destination_hash:
|
||||||
sql += " AND destination_hash = ?"
|
sql += " AND a.destination_hash = ?"
|
||||||
params.append(destination_hash)
|
params.append(destination_hash)
|
||||||
if query:
|
if query:
|
||||||
like_term = f"%{query}%"
|
like_term = f"%{query}%"
|
||||||
sql += " AND (destination_hash LIKE ? OR identity_hash LIKE ?)"
|
sql += " AND (a.destination_hash LIKE ? OR a.identity_hash LIKE ?)"
|
||||||
params.extend([like_term, like_term])
|
params.extend([like_term, like_term])
|
||||||
if blocked_identity_hashes:
|
if blocked_identity_hashes:
|
||||||
placeholders = ", ".join(["?"] * len(blocked_identity_hashes))
|
placeholders = ", ".join(["?"] * len(blocked_identity_hashes))
|
||||||
sql += f" AND identity_hash NOT IN ({placeholders})"
|
sql += f" AND a.identity_hash NOT IN ({placeholders})"
|
||||||
params.extend(blocked_identity_hashes)
|
params.extend(blocked_identity_hashes)
|
||||||
|
|
||||||
sql += " ORDER BY updated_at DESC"
|
sql += " ORDER BY a.updated_at DESC"
|
||||||
|
|
||||||
if limit is not None:
|
if limit is not None:
|
||||||
sql += " LIMIT ? OFFSET ?"
|
sql += " LIMIT ? OFFSET ?"
|
||||||
|
|||||||
@@ -9,8 +9,7 @@ class AsyncUtils:
|
|||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def apply_asyncio_313_patch():
|
def apply_asyncio_313_patch():
|
||||||
"""
|
"""Apply a patch for asyncio on Python 3.13 to avoid a bug in sendfile with SSL.
|
||||||
Apply a patch for asyncio on Python 3.13 to avoid a bug in sendfile with SSL.
|
|
||||||
See: https://github.com/python/cpython/issues/124448
|
See: https://github.com/python/cpython/issues/124448
|
||||||
And: https://github.com/aio-libs/aiohttp/issues/8863
|
And: https://github.com/aio-libs/aiohttp/issues/8863
|
||||||
"""
|
"""
|
||||||
@@ -23,14 +22,25 @@ class AsyncUtils:
|
|||||||
original_sendfile = asyncio.base_events.BaseEventLoop.sendfile
|
original_sendfile = asyncio.base_events.BaseEventLoop.sendfile
|
||||||
|
|
||||||
async def patched_sendfile(
|
async def patched_sendfile(
|
||||||
self, transport, file, offset=0, count=None, *, fallback=True
|
self,
|
||||||
|
transport,
|
||||||
|
file,
|
||||||
|
offset=0,
|
||||||
|
count=None,
|
||||||
|
*,
|
||||||
|
fallback=True,
|
||||||
):
|
):
|
||||||
if transport.get_extra_info("sslcontext"):
|
if transport.get_extra_info("sslcontext"):
|
||||||
raise NotImplementedError(
|
raise NotImplementedError(
|
||||||
"sendfile is broken on SSL transports in Python 3.13"
|
"sendfile is broken on SSL transports in Python 3.13",
|
||||||
)
|
)
|
||||||
return await original_sendfile(
|
return await original_sendfile(
|
||||||
self, transport, file, offset, count, fallback=fallback
|
self,
|
||||||
|
transport,
|
||||||
|
file,
|
||||||
|
offset,
|
||||||
|
count,
|
||||||
|
fallback=fallback,
|
||||||
)
|
)
|
||||||
|
|
||||||
asyncio.base_events.BaseEventLoop.sendfile = patched_sendfile
|
asyncio.base_events.BaseEventLoop.sendfile = patched_sendfile
|
||||||
|
|||||||
279
meshchatx/src/backend/bot_handler.py
Normal file
279
meshchatx/src/backend/bot_handler.py
Normal file
@@ -0,0 +1,279 @@
|
|||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
import RNS
|
||||||
|
|
||||||
|
logger = logging.getLogger("meshchatx.bots")
|
||||||
|
|
||||||
|
|
||||||
|
class BotHandler:
|
||||||
|
def __init__(self, identity_path, config_manager=None):
|
||||||
|
self.identity_path = os.path.abspath(identity_path)
|
||||||
|
self.config_manager = config_manager
|
||||||
|
self.bots_dir = os.path.join(self.identity_path, "bots")
|
||||||
|
os.makedirs(self.bots_dir, exist_ok=True)
|
||||||
|
self.running_bots = {}
|
||||||
|
self.state_file = os.path.join(self.bots_dir, "bots_state.json")
|
||||||
|
self.bots_state: list[dict] = []
|
||||||
|
self._load_state()
|
||||||
|
self.runner_path = os.path.join(
|
||||||
|
os.path.dirname(__file__),
|
||||||
|
"bot_process.py",
|
||||||
|
)
|
||||||
|
|
||||||
|
def _load_state(self):
|
||||||
|
try:
|
||||||
|
with open(self.state_file, encoding="utf-8") as f:
|
||||||
|
self.bots_state = json.load(f)
|
||||||
|
# Ensure all storage paths are absolute
|
||||||
|
for entry in self.bots_state:
|
||||||
|
if "storage_dir" in entry:
|
||||||
|
entry["storage_dir"] = os.path.abspath(entry["storage_dir"])
|
||||||
|
except FileNotFoundError:
|
||||||
|
self.bots_state = []
|
||||||
|
except Exception:
|
||||||
|
self.bots_state = []
|
||||||
|
|
||||||
|
def _save_state(self):
|
||||||
|
try:
|
||||||
|
with open(self.state_file, "w", encoding="utf-8") as f:
|
||||||
|
json.dump(self.bots_state, f, indent=2)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def get_available_templates(self):
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"id": "echo",
|
||||||
|
"name": "Echo Bot",
|
||||||
|
"description": "Repeats any message it receives.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "note",
|
||||||
|
"name": "Note Bot",
|
||||||
|
"description": "Store and retrieve notes using JSON storage.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "reminder",
|
||||||
|
"name": "Reminder Bot",
|
||||||
|
"description": "Set and receive reminders using SQLite storage.",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
def restore_enabled_bots(self):
|
||||||
|
for entry in list(self.bots_state):
|
||||||
|
if entry.get("enabled"):
|
||||||
|
try:
|
||||||
|
self.start_bot(
|
||||||
|
template_id=entry["template_id"],
|
||||||
|
name=entry["name"],
|
||||||
|
bot_id=entry["id"],
|
||||||
|
storage_dir=entry["storage_dir"],
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("Failed to restore bot %s: %s", entry.get("id"), exc)
|
||||||
|
|
||||||
|
def get_status(self):
|
||||||
|
bots = []
|
||||||
|
for bot_id, bot_info in self.running_bots.items():
|
||||||
|
instance = bot_info["instance"]
|
||||||
|
bots.append(
|
||||||
|
{
|
||||||
|
"id": bot_id,
|
||||||
|
"template": bot_info["template"],
|
||||||
|
"name": instance.bot.config.name
|
||||||
|
if instance and instance.bot
|
||||||
|
else "Unknown",
|
||||||
|
"address": RNS.prettyhexrep(instance.bot.local.hash)
|
||||||
|
if instance and instance.bot and instance.bot.local
|
||||||
|
else "Unknown",
|
||||||
|
"full_address": RNS.hexrep(instance.bot.local.hash, delimit=False)
|
||||||
|
if instance and instance.bot and instance.bot.local
|
||||||
|
else None,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"has_lxmfy": True,
|
||||||
|
"detection_error": None,
|
||||||
|
"running_bots": bots,
|
||||||
|
"bots": self.bots_state,
|
||||||
|
}
|
||||||
|
|
||||||
|
def start_bot(self, template_id, name=None, bot_id=None, storage_dir=None):
|
||||||
|
# Reuse existing entry or create new
|
||||||
|
entry = None
|
||||||
|
if bot_id:
|
||||||
|
for e in self.bots_state:
|
||||||
|
if e.get("id") == bot_id:
|
||||||
|
entry = e
|
||||||
|
break
|
||||||
|
if entry is None:
|
||||||
|
bot_id = bot_id or uuid.uuid4().hex
|
||||||
|
bot_storage_dir = storage_dir or os.path.join(self.bots_dir, bot_id)
|
||||||
|
bot_storage_dir = os.path.abspath(bot_storage_dir)
|
||||||
|
entry = {
|
||||||
|
"id": bot_id,
|
||||||
|
"template_id": template_id,
|
||||||
|
"name": name or f"{template_id.title()} Bot",
|
||||||
|
"storage_dir": bot_storage_dir,
|
||||||
|
"enabled": True,
|
||||||
|
"pid": None,
|
||||||
|
}
|
||||||
|
self.bots_state.append(entry)
|
||||||
|
else:
|
||||||
|
bot_storage_dir = entry["storage_dir"]
|
||||||
|
entry["template_id"] = template_id
|
||||||
|
entry["name"] = name or entry.get("name") or f"{template_id.title()} Bot"
|
||||||
|
entry["enabled"] = True
|
||||||
|
|
||||||
|
os.makedirs(bot_storage_dir, exist_ok=True)
|
||||||
|
|
||||||
|
cmd = [
|
||||||
|
sys.executable,
|
||||||
|
self.runner_path,
|
||||||
|
"--template",
|
||||||
|
template_id,
|
||||||
|
"--name",
|
||||||
|
entry["name"],
|
||||||
|
"--storage",
|
||||||
|
bot_storage_dir,
|
||||||
|
]
|
||||||
|
|
||||||
|
proc = subprocess.Popen(cmd, cwd=bot_storage_dir) # noqa: S603
|
||||||
|
entry["pid"] = proc.pid
|
||||||
|
self._save_state()
|
||||||
|
|
||||||
|
self.running_bots[bot_id] = {
|
||||||
|
"instance": None,
|
||||||
|
"thread": None,
|
||||||
|
"stop_event": None,
|
||||||
|
"template": template_id,
|
||||||
|
"pid": proc.pid,
|
||||||
|
}
|
||||||
|
logger.info(f"Started bot {bot_id} (template: {template_id}) pid={proc.pid}")
|
||||||
|
return bot_id
|
||||||
|
|
||||||
|
def stop_bot(self, bot_id):
|
||||||
|
entry = None
|
||||||
|
for e in self.bots_state:
|
||||||
|
if e.get("id") == bot_id:
|
||||||
|
entry = e
|
||||||
|
break
|
||||||
|
if entry is None:
|
||||||
|
return False
|
||||||
|
|
||||||
|
pid = entry.get("pid")
|
||||||
|
if pid:
|
||||||
|
try:
|
||||||
|
if sys.platform.startswith("win"):
|
||||||
|
subprocess.run(
|
||||||
|
["taskkill", "/PID", str(pid), "/T", "/F"],
|
||||||
|
check=False,
|
||||||
|
timeout=5,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
os.kill(pid, 15)
|
||||||
|
# brief wait
|
||||||
|
time.sleep(0.5)
|
||||||
|
# optional force kill if still alive
|
||||||
|
try:
|
||||||
|
os.kill(pid, 0)
|
||||||
|
os.kill(pid, 9)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning(
|
||||||
|
"Failed to terminate bot %s pid %s: %s",
|
||||||
|
bot_id,
|
||||||
|
pid,
|
||||||
|
exc,
|
||||||
|
)
|
||||||
|
|
||||||
|
entry["pid"] = None
|
||||||
|
entry["enabled"] = False
|
||||||
|
self._save_state()
|
||||||
|
if bot_id in self.running_bots:
|
||||||
|
del self.running_bots[bot_id]
|
||||||
|
logger.info("Stopped bot %s", bot_id)
|
||||||
|
return True
|
||||||
|
|
||||||
|
def restart_bot(self, bot_id):
|
||||||
|
entry = None
|
||||||
|
for e in self.bots_state:
|
||||||
|
if e.get("id") == bot_id:
|
||||||
|
entry = e
|
||||||
|
break
|
||||||
|
if entry is None:
|
||||||
|
raise ValueError(f"Unknown bot: {bot_id}")
|
||||||
|
self.stop_bot(bot_id)
|
||||||
|
return self.start_bot(
|
||||||
|
template_id=entry["template_id"],
|
||||||
|
name=entry["name"],
|
||||||
|
bot_id=bot_id,
|
||||||
|
storage_dir=entry["storage_dir"],
|
||||||
|
)
|
||||||
|
|
||||||
|
def delete_bot(self, bot_id):
|
||||||
|
# Stop it first
|
||||||
|
self.stop_bot(bot_id)
|
||||||
|
|
||||||
|
# Remove from state
|
||||||
|
entry = None
|
||||||
|
for i, e in enumerate(self.bots_state):
|
||||||
|
if e.get("id") == bot_id:
|
||||||
|
entry = e
|
||||||
|
del self.bots_state[i]
|
||||||
|
break
|
||||||
|
|
||||||
|
if entry:
|
||||||
|
# Delete storage dir
|
||||||
|
storage_dir = entry.get("storage_dir")
|
||||||
|
if storage_dir and os.path.exists(storage_dir):
|
||||||
|
try:
|
||||||
|
shutil.rmtree(storage_dir)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning(
|
||||||
|
"Failed to delete storage dir for bot %s: %s", bot_id, exc
|
||||||
|
)
|
||||||
|
|
||||||
|
self._save_state()
|
||||||
|
logger.info("Deleted bot %s", bot_id)
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
def get_bot_identity_path(self, bot_id):
|
||||||
|
entry = None
|
||||||
|
for e in self.bots_state:
|
||||||
|
if e.get("id") == bot_id:
|
||||||
|
entry = e
|
||||||
|
break
|
||||||
|
|
||||||
|
if not entry:
|
||||||
|
return None
|
||||||
|
|
||||||
|
storage_dir = entry.get("storage_dir")
|
||||||
|
if not storage_dir:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# LXMFy stores identity in the 'config' subdirectory by default
|
||||||
|
id_path = os.path.join(storage_dir, "config", "identity")
|
||||||
|
if os.path.exists(id_path):
|
||||||
|
return id_path
|
||||||
|
|
||||||
|
# Fallback to direct identity file if it was moved or configured differently
|
||||||
|
id_path_alt = os.path.join(storage_dir, "identity")
|
||||||
|
if os.path.exists(id_path_alt):
|
||||||
|
return id_path_alt
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
def stop_all(self):
|
||||||
|
for bot_id in list(self.running_bots.keys()):
|
||||||
|
self.stop_bot(bot_id)
|
||||||
45
meshchatx/src/backend/bot_process.py
Normal file
45
meshchatx/src/backend/bot_process.py
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
import argparse
|
||||||
|
import os
|
||||||
|
|
||||||
|
from meshchatx.src.backend.bot_templates import (
|
||||||
|
EchoBotTemplate,
|
||||||
|
NoteBotTemplate,
|
||||||
|
ReminderBotTemplate,
|
||||||
|
)
|
||||||
|
|
||||||
|
TEMPLATE_MAP = {
|
||||||
|
"echo": EchoBotTemplate,
|
||||||
|
"note": NoteBotTemplate,
|
||||||
|
"reminder": ReminderBotTemplate,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser()
|
||||||
|
parser.add_argument("--template", required=True, choices=TEMPLATE_MAP.keys())
|
||||||
|
parser.add_argument("--name", required=True)
|
||||||
|
parser.add_argument("--storage", required=True)
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
os.makedirs(args.storage, exist_ok=True)
|
||||||
|
os.chdir(args.storage)
|
||||||
|
|
||||||
|
BotCls = TEMPLATE_MAP[args.template]
|
||||||
|
# LXMFy hardcodes its config directory to os.path.join(os.getcwd(), 'config').
|
||||||
|
# By chdir'ing into args.storage, we ensure 'config' and data are kept within that folder.
|
||||||
|
bot_instance = BotCls(name=args.name, storage_path=args.storage, test_mode=False)
|
||||||
|
|
||||||
|
# Optional immediate announce for reachability
|
||||||
|
try:
|
||||||
|
if hasattr(bot_instance.bot, "announce_enabled"):
|
||||||
|
bot_instance.bot.announce_enabled = True
|
||||||
|
if hasattr(bot_instance.bot, "_announce"):
|
||||||
|
bot_instance.bot._announce()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
bot_instance.run()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
265
meshchatx/src/backend/bot_templates.py
Normal file
265
meshchatx/src/backend/bot_templates.py
Normal file
@@ -0,0 +1,265 @@
|
|||||||
|
import re
|
||||||
|
import time
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
|
from lxmfy import IconAppearance, LXMFBot, pack_icon_appearance_field
|
||||||
|
|
||||||
|
HAS_LXMFY = True
|
||||||
|
|
||||||
|
|
||||||
|
class StoppableBot:
|
||||||
|
def __init__(self):
|
||||||
|
self._stop_event = None
|
||||||
|
|
||||||
|
def set_stop_event(self, stop_event):
|
||||||
|
self._stop_event = stop_event
|
||||||
|
|
||||||
|
def should_stop(self):
|
||||||
|
return self._stop_event and self._stop_event.is_set()
|
||||||
|
|
||||||
|
|
||||||
|
class EchoBotTemplate(StoppableBot):
|
||||||
|
def __init__(self, name="Echo Bot", storage_path=None, test_mode=False):
|
||||||
|
super().__init__()
|
||||||
|
|
||||||
|
self.bot = LXMFBot(
|
||||||
|
name=name,
|
||||||
|
announce=600,
|
||||||
|
command_prefix="",
|
||||||
|
first_message_enabled=True,
|
||||||
|
test_mode=test_mode,
|
||||||
|
storage_path=storage_path,
|
||||||
|
)
|
||||||
|
self.setup_commands()
|
||||||
|
self.setup_message_handlers()
|
||||||
|
|
||||||
|
icon_data = IconAppearance(
|
||||||
|
icon_name="forum",
|
||||||
|
fg_color=b"\xad\xd8\xe6",
|
||||||
|
bg_color=b"\x3b\x59\x98",
|
||||||
|
)
|
||||||
|
self.icon_lxmf_field = pack_icon_appearance_field(icon_data)
|
||||||
|
|
||||||
|
def setup_message_handlers(self):
|
||||||
|
@self.bot.on_message()
|
||||||
|
def echo_non_command_messages(sender, message):
|
||||||
|
if self.should_stop():
|
||||||
|
return True
|
||||||
|
content = message.content.decode("utf-8").strip()
|
||||||
|
if not content:
|
||||||
|
return False
|
||||||
|
|
||||||
|
command_name = content.split()[0]
|
||||||
|
if command_name in self.bot.commands:
|
||||||
|
return False
|
||||||
|
|
||||||
|
self.bot.send(
|
||||||
|
sender,
|
||||||
|
content,
|
||||||
|
lxmf_fields=self.icon_lxmf_field,
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
|
||||||
|
def setup_commands(self):
|
||||||
|
@self.bot.command(name="echo", description="Echo back your message")
|
||||||
|
def echo(ctx):
|
||||||
|
if self.should_stop():
|
||||||
|
return
|
||||||
|
if ctx.args:
|
||||||
|
ctx.reply(" ".join(ctx.args), lxmf_fields=self.icon_lxmf_field)
|
||||||
|
else:
|
||||||
|
ctx.reply("Usage: echo <message>", lxmf_fields=self.icon_lxmf_field)
|
||||||
|
|
||||||
|
@self.bot.on_first_message()
|
||||||
|
def welcome(sender, message):
|
||||||
|
if self.should_stop():
|
||||||
|
return True
|
||||||
|
content = message.content.decode("utf-8").strip()
|
||||||
|
self.bot.send(
|
||||||
|
sender,
|
||||||
|
f"Hi! I'm an echo bot, You said: {content}\n\n"
|
||||||
|
"Try: echo <message> to make me repeat things!",
|
||||||
|
lxmf_fields=self.icon_lxmf_field,
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
|
||||||
|
def run(self):
|
||||||
|
self.bot.scheduler.start()
|
||||||
|
try:
|
||||||
|
while not self.should_stop():
|
||||||
|
for _ in range(self.bot.queue.qsize()):
|
||||||
|
lxm = self.bot.queue.get()
|
||||||
|
if self.bot.router:
|
||||||
|
self.bot.router.handle_outbound(lxm)
|
||||||
|
time.sleep(1)
|
||||||
|
finally:
|
||||||
|
self.bot.cleanup()
|
||||||
|
|
||||||
|
|
||||||
|
class NoteBotTemplate(StoppableBot):
|
||||||
|
def __init__(self, name="Note Bot", storage_path=None, test_mode=False):
|
||||||
|
super().__init__()
|
||||||
|
|
||||||
|
self.bot = LXMFBot(
|
||||||
|
name=name,
|
||||||
|
announce=600,
|
||||||
|
command_prefix="/",
|
||||||
|
storage_type="json",
|
||||||
|
storage_path=storage_path or "data/notes",
|
||||||
|
test_mode=test_mode,
|
||||||
|
)
|
||||||
|
self.setup_commands()
|
||||||
|
|
||||||
|
def setup_commands(self):
|
||||||
|
@self.bot.command(name="note", description="Save a note")
|
||||||
|
def save_note(ctx):
|
||||||
|
if self.should_stop():
|
||||||
|
return
|
||||||
|
if not ctx.args:
|
||||||
|
ctx.reply("Usage: /note <your note>")
|
||||||
|
return
|
||||||
|
|
||||||
|
note = {
|
||||||
|
"text": " ".join(ctx.args),
|
||||||
|
"timestamp": datetime.now().isoformat(),
|
||||||
|
"tags": [w[1:] for w in ctx.args if w.startswith("#")],
|
||||||
|
}
|
||||||
|
|
||||||
|
notes = self.bot.storage.get(f"notes:{ctx.sender}", [])
|
||||||
|
notes.append(note)
|
||||||
|
self.bot.storage.set(f"notes:{ctx.sender}", notes)
|
||||||
|
ctx.reply("Note saved!")
|
||||||
|
|
||||||
|
@self.bot.command(name="notes", description="List your notes")
|
||||||
|
def list_notes(ctx):
|
||||||
|
if self.should_stop():
|
||||||
|
return
|
||||||
|
notes = self.bot.storage.get(f"notes:{ctx.sender}", [])
|
||||||
|
if not notes:
|
||||||
|
ctx.reply("You haven't saved any notes yet!")
|
||||||
|
return
|
||||||
|
|
||||||
|
if not ctx.args:
|
||||||
|
response = "Your Notes:\n"
|
||||||
|
for i, note in enumerate(notes[-10:], 1):
|
||||||
|
tags = (
|
||||||
|
" ".join(f"#{tag}" for tag in note["tags"])
|
||||||
|
if note["tags"]
|
||||||
|
else ""
|
||||||
|
)
|
||||||
|
response += f"{i}. {note['text']} {tags}\n"
|
||||||
|
if len(notes) > 10:
|
||||||
|
response += f"\nShowing last 10 of {len(notes)} notes. Use /notes all to see all."
|
||||||
|
ctx.reply(response)
|
||||||
|
elif ctx.args[0] == "all":
|
||||||
|
response = "All Your Notes:\n"
|
||||||
|
for i, note in enumerate(notes, 1):
|
||||||
|
tags = (
|
||||||
|
" ".join(f"#{tag}" for tag in note["tags"])
|
||||||
|
if note["tags"]
|
||||||
|
else ""
|
||||||
|
)
|
||||||
|
response += f"{i}. {note['text']} {tags}\n"
|
||||||
|
ctx.reply(response)
|
||||||
|
|
||||||
|
def run(self):
|
||||||
|
self.bot.scheduler.start()
|
||||||
|
try:
|
||||||
|
while not self.should_stop():
|
||||||
|
for _ in range(self.bot.queue.qsize()):
|
||||||
|
lxm = self.bot.queue.get()
|
||||||
|
if self.bot.router:
|
||||||
|
self.bot.router.handle_outbound(lxm)
|
||||||
|
time.sleep(1)
|
||||||
|
finally:
|
||||||
|
self.bot.cleanup()
|
||||||
|
|
||||||
|
|
||||||
|
class ReminderBotTemplate(StoppableBot):
|
||||||
|
def __init__(self, name="Reminder Bot", storage_path=None, test_mode=False):
|
||||||
|
super().__init__()
|
||||||
|
|
||||||
|
self.bot = LXMFBot(
|
||||||
|
name=name,
|
||||||
|
announce=600,
|
||||||
|
command_prefix="/",
|
||||||
|
storage_type="sqlite",
|
||||||
|
storage_path=storage_path or "data/reminders.db",
|
||||||
|
test_mode=test_mode,
|
||||||
|
)
|
||||||
|
self.setup_commands()
|
||||||
|
self.bot.scheduler.add_task(
|
||||||
|
"check_reminders",
|
||||||
|
self._check_reminders,
|
||||||
|
"*/1 * * * *",
|
||||||
|
)
|
||||||
|
|
||||||
|
def setup_commands(self):
|
||||||
|
@self.bot.command(name="remind", description="Set a reminder")
|
||||||
|
def remind(ctx):
|
||||||
|
if self.should_stop():
|
||||||
|
return
|
||||||
|
if not ctx.args or len(ctx.args) < 2:
|
||||||
|
ctx.reply(
|
||||||
|
"Usage: /remind <time> <message>\nExample: /remind 1h30m Buy groceries",
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
time_str = ctx.args[0].lower()
|
||||||
|
message = " ".join(ctx.args[1:])
|
||||||
|
|
||||||
|
total_minutes = 0
|
||||||
|
time_parts = re.findall(r"(\d+)([dhm])", time_str)
|
||||||
|
|
||||||
|
for value, unit in time_parts:
|
||||||
|
if unit == "d":
|
||||||
|
total_minutes += int(value) * 24 * 60
|
||||||
|
elif unit == "h":
|
||||||
|
total_minutes += int(value) * 60
|
||||||
|
elif unit == "m":
|
||||||
|
total_minutes += int(value)
|
||||||
|
|
||||||
|
if total_minutes == 0:
|
||||||
|
ctx.reply("Invalid time format. Use combinations of d, h, m")
|
||||||
|
return
|
||||||
|
|
||||||
|
remind_time = datetime.now() + timedelta(minutes=total_minutes)
|
||||||
|
reminder = {
|
||||||
|
"user": ctx.sender,
|
||||||
|
"message": message,
|
||||||
|
"time": remind_time.timestamp(),
|
||||||
|
"created": time.time(),
|
||||||
|
}
|
||||||
|
|
||||||
|
reminders = self.bot.storage.get("reminders", [])
|
||||||
|
reminders.append(reminder)
|
||||||
|
self.bot.storage.set("reminders", reminders)
|
||||||
|
ctx.reply(
|
||||||
|
f"I'll remind you about '{message}' at {remind_time.strftime('%Y-%m-%d %H:%M:%S')}",
|
||||||
|
)
|
||||||
|
|
||||||
|
def _check_reminders(self):
|
||||||
|
if self.should_stop():
|
||||||
|
return
|
||||||
|
reminders = self.bot.storage.get("reminders", [])
|
||||||
|
current_time = time.time()
|
||||||
|
due_reminders = [r for r in reminders if r["time"] <= current_time]
|
||||||
|
remaining = [r for r in reminders if r["time"] > current_time]
|
||||||
|
|
||||||
|
for reminder in due_reminders:
|
||||||
|
self.bot.send(reminder["user"], f"Reminder: {reminder['message']}")
|
||||||
|
|
||||||
|
if due_reminders:
|
||||||
|
self.bot.storage.set("reminders", remaining)
|
||||||
|
|
||||||
|
def run(self):
|
||||||
|
self.bot.scheduler.start()
|
||||||
|
try:
|
||||||
|
while not self.should_stop():
|
||||||
|
for _ in range(self.bot.queue.qsize()):
|
||||||
|
lxm = self.bot.queue.get()
|
||||||
|
if self.bot.router:
|
||||||
|
self.bot.router.handle_outbound(lxm)
|
||||||
|
time.sleep(1)
|
||||||
|
finally:
|
||||||
|
self.bot.cleanup()
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
import time
|
import time
|
||||||
from typing import List, Dict, Any
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
class CommunityInterfacesManager:
|
class CommunityInterfacesManager:
|
||||||
@@ -67,7 +67,8 @@ class CommunityInterfacesManager:
|
|||||||
# but that requires Reticulum to be running with a configured interface to that target.
|
# but that requires Reticulum to be running with a configured interface to that target.
|
||||||
# For "suggested" interfaces, we just check if they are reachable.
|
# For "suggested" interfaces, we just check if they are reachable.
|
||||||
reader, writer = await asyncio.wait_for(
|
reader, writer = await asyncio.wait_for(
|
||||||
asyncio.open_connection(host, port), timeout=3.0
|
asyncio.open_connection(host, port),
|
||||||
|
timeout=3.0,
|
||||||
)
|
)
|
||||||
writer.close()
|
writer.close()
|
||||||
await writer.wait_closed()
|
await writer.wait_closed()
|
||||||
@@ -90,7 +91,7 @@ class CommunityInterfacesManager:
|
|||||||
}
|
}
|
||||||
self.last_check = time.time()
|
self.last_check = time.time()
|
||||||
|
|
||||||
async def get_interfaces(self) -> List[Dict[str, Any]]:
|
async def get_interfaces(self) -> list[dict[str, Any]]:
|
||||||
# If cache is old or empty, update it
|
# If cache is old or empty, update it
|
||||||
if time.time() - self.last_check > self.check_interval or not self.status_cache:
|
if time.time() - self.last_check > self.check_interval or not self.status_cache:
|
||||||
# We don't want to block the request, so we could do this in background
|
# We don't want to block the request, so we could do this in background
|
||||||
@@ -100,14 +101,15 @@ class CommunityInterfacesManager:
|
|||||||
results = []
|
results = []
|
||||||
for iface in self.interfaces:
|
for iface in self.interfaces:
|
||||||
status = self.status_cache.get(
|
status = self.status_cache.get(
|
||||||
iface["name"], {"online": False, "last_check": 0}
|
iface["name"],
|
||||||
|
{"online": False, "last_check": 0},
|
||||||
)
|
)
|
||||||
results.append(
|
results.append(
|
||||||
{
|
{
|
||||||
**iface,
|
**iface,
|
||||||
"online": status["online"],
|
"online": status["online"],
|
||||||
"last_check": status["last_check"],
|
"last_check": status["last_check"],
|
||||||
}
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
# Sort so online ones are first
|
# Sort so online ones are first
|
||||||
|
|||||||
@@ -60,6 +60,8 @@ class ConfigManager:
|
|||||||
"lxmf_preferred_propagation_node_last_synced_at",
|
"lxmf_preferred_propagation_node_last_synced_at",
|
||||||
None,
|
None,
|
||||||
)
|
)
|
||||||
|
self.lxmf_address_hash = self.StringConfig(self, "lxmf_address_hash", None)
|
||||||
|
self.lxst_address_hash = self.StringConfig(self, "lxst_address_hash", None)
|
||||||
self.lxmf_local_propagation_node_enabled = self.BoolConfig(
|
self.lxmf_local_propagation_node_enabled = self.BoolConfig(
|
||||||
self,
|
self,
|
||||||
"lxmf_local_propagation_node_enabled",
|
"lxmf_local_propagation_node_enabled",
|
||||||
@@ -119,7 +121,9 @@ class ConfigManager:
|
|||||||
False,
|
False,
|
||||||
)
|
)
|
||||||
self.gitea_base_url = self.StringConfig(
|
self.gitea_base_url = self.StringConfig(
|
||||||
self, "gitea_base_url", "https://git.quad4.io"
|
self,
|
||||||
|
"gitea_base_url",
|
||||||
|
"https://git.quad4.io",
|
||||||
)
|
)
|
||||||
self.docs_download_urls = self.StringConfig(
|
self.docs_download_urls = self.StringConfig(
|
||||||
self,
|
self,
|
||||||
@@ -159,7 +163,9 @@ class ConfigManager:
|
|||||||
self.voicemail_tts_speed = self.IntConfig(self, "voicemail_tts_speed", 130)
|
self.voicemail_tts_speed = self.IntConfig(self, "voicemail_tts_speed", 130)
|
||||||
self.voicemail_tts_pitch = self.IntConfig(self, "voicemail_tts_pitch", 45)
|
self.voicemail_tts_pitch = self.IntConfig(self, "voicemail_tts_pitch", 45)
|
||||||
self.voicemail_tts_voice = self.StringConfig(
|
self.voicemail_tts_voice = self.StringConfig(
|
||||||
self, "voicemail_tts_voice", "en-us+f3"
|
self,
|
||||||
|
"voicemail_tts_voice",
|
||||||
|
"en-us+f3",
|
||||||
)
|
)
|
||||||
self.voicemail_tts_word_gap = self.IntConfig(self, "voicemail_tts_word_gap", 5)
|
self.voicemail_tts_word_gap = self.IntConfig(self, "voicemail_tts_word_gap", 5)
|
||||||
|
|
||||||
|
|||||||
@@ -130,7 +130,7 @@ class Database:
|
|||||||
self._checkpoint_wal()
|
self._checkpoint_wal()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(
|
print(
|
||||||
f"Warning: WAL checkpoint during vacuum failed (non-critical): {e}"
|
f"Warning: WAL checkpoint during vacuum failed (non-critical): {e}",
|
||||||
)
|
)
|
||||||
|
|
||||||
self.execute_sql("VACUUM")
|
self.execute_sql("VACUUM")
|
||||||
@@ -226,7 +226,7 @@ class Database:
|
|||||||
os.makedirs(snapshot_dir, exist_ok=True)
|
os.makedirs(snapshot_dir, exist_ok=True)
|
||||||
# Ensure name is safe for filesystem
|
# Ensure name is safe for filesystem
|
||||||
safe_name = "".join(
|
safe_name = "".join(
|
||||||
[c for c in name if c.isalnum() or c in (" ", ".", "-", "_")]
|
[c for c in name if c.isalnum() or c in (" ", ".", "-", "_")],
|
||||||
).strip()
|
).strip()
|
||||||
if not safe_name:
|
if not safe_name:
|
||||||
safe_name = "unnamed_snapshot"
|
safe_name = "unnamed_snapshot"
|
||||||
@@ -251,9 +251,10 @@ class Database:
|
|||||||
"path": full_path,
|
"path": full_path,
|
||||||
"size": stats.st_size,
|
"size": stats.st_size,
|
||||||
"created_at": datetime.fromtimestamp(
|
"created_at": datetime.fromtimestamp(
|
||||||
stats.st_mtime, UTC
|
stats.st_mtime,
|
||||||
|
UTC,
|
||||||
).isoformat(),
|
).isoformat(),
|
||||||
}
|
},
|
||||||
)
|
)
|
||||||
return sorted(snapshots, key=lambda x: x["created_at"], reverse=True)
|
return sorted(snapshots, key=lambda x: x["created_at"], reverse=True)
|
||||||
|
|
||||||
|
|||||||
@@ -6,19 +6,34 @@ class ContactsDAO:
|
|||||||
self.provider = provider
|
self.provider = provider
|
||||||
|
|
||||||
def add_contact(
|
def add_contact(
|
||||||
self, name, remote_identity_hash, preferred_ringtone_id=None, custom_image=None
|
self,
|
||||||
|
name,
|
||||||
|
remote_identity_hash,
|
||||||
|
lxmf_address=None,
|
||||||
|
lxst_address=None,
|
||||||
|
preferred_ringtone_id=None,
|
||||||
|
custom_image=None,
|
||||||
):
|
):
|
||||||
self.provider.execute(
|
self.provider.execute(
|
||||||
"""
|
"""
|
||||||
INSERT INTO contacts (name, remote_identity_hash, preferred_ringtone_id, custom_image)
|
INSERT INTO contacts (name, remote_identity_hash, lxmf_address, lxst_address, preferred_ringtone_id, custom_image)
|
||||||
VALUES (?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?, ?)
|
||||||
ON CONFLICT(remote_identity_hash) DO UPDATE SET
|
ON CONFLICT(remote_identity_hash) DO UPDATE SET
|
||||||
name = EXCLUDED.name,
|
name = EXCLUDED.name,
|
||||||
|
lxmf_address = COALESCE(EXCLUDED.lxmf_address, contacts.lxmf_address),
|
||||||
|
lxst_address = COALESCE(EXCLUDED.lxst_address, contacts.lxst_address),
|
||||||
preferred_ringtone_id = EXCLUDED.preferred_ringtone_id,
|
preferred_ringtone_id = EXCLUDED.preferred_ringtone_id,
|
||||||
custom_image = EXCLUDED.custom_image,
|
custom_image = EXCLUDED.custom_image,
|
||||||
updated_at = CURRENT_TIMESTAMP
|
updated_at = CURRENT_TIMESTAMP
|
||||||
""",
|
""",
|
||||||
(name, remote_identity_hash, preferred_ringtone_id, custom_image),
|
(
|
||||||
|
name,
|
||||||
|
remote_identity_hash,
|
||||||
|
lxmf_address,
|
||||||
|
lxst_address,
|
||||||
|
preferred_ringtone_id,
|
||||||
|
custom_image,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
def get_contacts(self, search=None, limit=100, offset=0):
|
def get_contacts(self, search=None, limit=100, offset=0):
|
||||||
@@ -26,10 +41,17 @@ class ContactsDAO:
|
|||||||
return self.provider.fetchall(
|
return self.provider.fetchall(
|
||||||
"""
|
"""
|
||||||
SELECT * FROM contacts
|
SELECT * FROM contacts
|
||||||
WHERE name LIKE ? OR remote_identity_hash LIKE ?
|
WHERE name LIKE ? OR remote_identity_hash LIKE ? OR lxmf_address LIKE ? OR lxst_address LIKE ?
|
||||||
ORDER BY name ASC LIMIT ? OFFSET ?
|
ORDER BY name ASC LIMIT ? OFFSET ?
|
||||||
""",
|
""",
|
||||||
(f"%{search}%", f"%{search}%", limit, offset),
|
(
|
||||||
|
f"%{search}%",
|
||||||
|
f"%{search}%",
|
||||||
|
f"%{search}%",
|
||||||
|
f"%{search}%",
|
||||||
|
limit,
|
||||||
|
offset,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
return self.provider.fetchall(
|
return self.provider.fetchall(
|
||||||
"SELECT * FROM contacts ORDER BY name ASC LIMIT ? OFFSET ?",
|
"SELECT * FROM contacts ORDER BY name ASC LIMIT ? OFFSET ?",
|
||||||
@@ -47,6 +69,8 @@ class ContactsDAO:
|
|||||||
contact_id,
|
contact_id,
|
||||||
name=None,
|
name=None,
|
||||||
remote_identity_hash=None,
|
remote_identity_hash=None,
|
||||||
|
lxmf_address=None,
|
||||||
|
lxst_address=None,
|
||||||
preferred_ringtone_id=None,
|
preferred_ringtone_id=None,
|
||||||
custom_image=None,
|
custom_image=None,
|
||||||
clear_image=False,
|
clear_image=False,
|
||||||
@@ -60,6 +84,12 @@ class ContactsDAO:
|
|||||||
if remote_identity_hash is not None:
|
if remote_identity_hash is not None:
|
||||||
updates.append("remote_identity_hash = ?")
|
updates.append("remote_identity_hash = ?")
|
||||||
params.append(remote_identity_hash)
|
params.append(remote_identity_hash)
|
||||||
|
if lxmf_address is not None:
|
||||||
|
updates.append("lxmf_address = ?")
|
||||||
|
params.append(lxmf_address)
|
||||||
|
if lxst_address is not None:
|
||||||
|
updates.append("lxst_address = ?")
|
||||||
|
params.append(lxst_address)
|
||||||
if preferred_ringtone_id is not None:
|
if preferred_ringtone_id is not None:
|
||||||
updates.append("preferred_ringtone_id = ?")
|
updates.append("preferred_ringtone_id = ?")
|
||||||
params.append(preferred_ringtone_id)
|
params.append(preferred_ringtone_id)
|
||||||
@@ -82,6 +112,6 @@ class ContactsDAO:
|
|||||||
|
|
||||||
def get_contact_by_identity_hash(self, remote_identity_hash):
|
def get_contact_by_identity_hash(self, remote_identity_hash):
|
||||||
return self.provider.fetchone(
|
return self.provider.fetchone(
|
||||||
"SELECT * FROM contacts WHERE remote_identity_hash = ?",
|
"SELECT * FROM contacts WHERE remote_identity_hash = ? OR lxmf_address = ? OR lxst_address = ?",
|
||||||
(remote_identity_hash,),
|
(remote_identity_hash, remote_identity_hash, remote_identity_hash),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
from datetime import UTC, datetime
|
from datetime import UTC, datetime
|
||||||
|
|
||||||
from .provider import DatabaseProvider
|
from .provider import DatabaseProvider
|
||||||
|
|
||||||
|
|
||||||
@@ -24,7 +25,13 @@ class DebugLogsDAO:
|
|||||||
)
|
)
|
||||||
|
|
||||||
def get_logs(
|
def get_logs(
|
||||||
self, limit=100, offset=0, search=None, level=None, module=None, is_anomaly=None
|
self,
|
||||||
|
limit=100,
|
||||||
|
offset=0,
|
||||||
|
search=None,
|
||||||
|
level=None,
|
||||||
|
module=None,
|
||||||
|
is_anomaly=None,
|
||||||
):
|
):
|
||||||
sql = "SELECT * FROM debug_logs WHERE 1=1"
|
sql = "SELECT * FROM debug_logs WHERE 1=1"
|
||||||
params = []
|
params = []
|
||||||
@@ -83,7 +90,8 @@ class DebugLogsDAO:
|
|||||||
if row:
|
if row:
|
||||||
cutoff_ts = row["timestamp"]
|
cutoff_ts = row["timestamp"]
|
||||||
self.provider.execute(
|
self.provider.execute(
|
||||||
"DELETE FROM debug_logs WHERE timestamp < ?", (cutoff_ts,)
|
"DELETE FROM debug_logs WHERE timestamp < ?",
|
||||||
|
(cutoff_ts,),
|
||||||
)
|
)
|
||||||
|
|
||||||
def get_anomalies(self, limit=50):
|
def get_anomalies(self, limit=50):
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
from datetime import UTC, datetime
|
from datetime import UTC, datetime
|
||||||
|
|
||||||
from .provider import DatabaseProvider
|
from .provider import DatabaseProvider
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -69,13 +69,12 @@ class DatabaseProvider:
|
|||||||
self.connection.commit()
|
self.connection.commit()
|
||||||
elif commit is False:
|
elif commit is False:
|
||||||
pass
|
pass
|
||||||
else:
|
# Default behavior: if we're in a manual transaction, don't commit automatically
|
||||||
# Default behavior: if we're in a manual transaction, don't commit automatically
|
elif not self.connection.in_transaction:
|
||||||
if not self.connection.in_transaction:
|
# In autocommit mode, non-DML statements don't start transactions.
|
||||||
# In autocommit mode, non-DML statements don't start transactions.
|
# DML statements might if they are part of a BEGIN block.
|
||||||
# DML statements might if they are part of a BEGIN block.
|
# Actually, in isolation_level=None, NOTHING starts a transaction unless we say BEGIN.
|
||||||
# Actually, in isolation_level=None, NOTHING starts a transaction unless we say BEGIN.
|
pass
|
||||||
pass
|
|
||||||
return cursor
|
return cursor
|
||||||
|
|
||||||
def begin(self):
|
def begin(self):
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ from .provider import DatabaseProvider
|
|||||||
|
|
||||||
|
|
||||||
class DatabaseSchema:
|
class DatabaseSchema:
|
||||||
LATEST_VERSION = 34
|
LATEST_VERSION = 35
|
||||||
|
|
||||||
def __init__(self, provider: DatabaseProvider):
|
def __init__(self, provider: DatabaseProvider):
|
||||||
self.provider = provider
|
self.provider = provider
|
||||||
@@ -63,21 +63,20 @@ class DatabaseSchema:
|
|||||||
|
|
||||||
# Use the connection directly to avoid any middle-ware issues
|
# Use the connection directly to avoid any middle-ware issues
|
||||||
res = self._safe_execute(
|
res = self._safe_execute(
|
||||||
f"ALTER TABLE {table_name} ADD COLUMN {column_name} {stmt_type}"
|
f"ALTER TABLE {table_name} ADD COLUMN {column_name} {stmt_type}",
|
||||||
)
|
)
|
||||||
return res is not None
|
return res is not None
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
# Log but don't crash, we might be able to continue
|
# Log but don't crash, we might be able to continue
|
||||||
print(
|
print(
|
||||||
f"Unexpected error adding column {column_name} to {table_name}: {e}"
|
f"Unexpected error adding column {column_name} to {table_name}: {e}",
|
||||||
)
|
)
|
||||||
return False
|
return False
|
||||||
return True
|
return True
|
||||||
return True
|
return True
|
||||||
|
|
||||||
def _sync_table_columns(self, table_name, create_sql):
|
def _sync_table_columns(self, table_name, create_sql):
|
||||||
"""
|
"""Parses a CREATE TABLE statement and ensures all columns exist in the actual table.
|
||||||
Parses a CREATE TABLE statement and ensures all columns exist in the actual table.
|
|
||||||
This is a robust way to handle legacy tables that are missing columns.
|
This is a robust way to handle legacy tables that are missing columns.
|
||||||
"""
|
"""
|
||||||
# Find the first '(' and the last ')'
|
# Find the first '(' and the last ')'
|
||||||
@@ -111,7 +110,7 @@ class DatabaseSchema:
|
|||||||
definition = definition.strip()
|
definition = definition.strip()
|
||||||
# Skip table-level constraints
|
# Skip table-level constraints
|
||||||
if not definition or definition.upper().startswith(
|
if not definition or definition.upper().startswith(
|
||||||
("PRIMARY KEY", "FOREIGN KEY", "UNIQUE", "CHECK")
|
("PRIMARY KEY", "FOREIGN KEY", "UNIQUE", "CHECK"),
|
||||||
):
|
):
|
||||||
continue
|
continue
|
||||||
|
|
||||||
@@ -365,6 +364,8 @@ class DatabaseSchema:
|
|||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
name TEXT,
|
name TEXT,
|
||||||
remote_identity_hash TEXT UNIQUE,
|
remote_identity_hash TEXT UNIQUE,
|
||||||
|
lxmf_address TEXT,
|
||||||
|
lxst_address TEXT,
|
||||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||||
)
|
)
|
||||||
@@ -923,6 +924,15 @@ class DatabaseSchema:
|
|||||||
"ALTER TABLE crawl_tasks ADD COLUMN updated_at DATETIME DEFAULT CURRENT_TIMESTAMP",
|
"ALTER TABLE crawl_tasks ADD COLUMN updated_at DATETIME DEFAULT CURRENT_TIMESTAMP",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if current_version < 35:
|
||||||
|
# Add lxmf_address and lxst_address to contacts
|
||||||
|
self._safe_execute(
|
||||||
|
"ALTER TABLE contacts ADD COLUMN lxmf_address TEXT DEFAULT NULL",
|
||||||
|
)
|
||||||
|
self._safe_execute(
|
||||||
|
"ALTER TABLE contacts ADD COLUMN lxst_address TEXT DEFAULT NULL",
|
||||||
|
)
|
||||||
|
|
||||||
# Update version in config
|
# Update version in config
|
||||||
self._safe_execute(
|
self._safe_execute(
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -1,13 +1,14 @@
|
|||||||
|
import html
|
||||||
|
import io
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
import shutil
|
import shutil
|
||||||
import threading
|
import threading
|
||||||
import zipfile
|
import zipfile
|
||||||
import io
|
|
||||||
import html
|
|
||||||
|
|
||||||
import requests
|
import requests
|
||||||
|
|
||||||
from meshchatx.src.backend.markdown_renderer import MarkdownRenderer
|
from meshchatx.src.backend.markdown_renderer import MarkdownRenderer
|
||||||
|
|
||||||
|
|
||||||
@@ -46,12 +47,13 @@ class DocsManager:
|
|||||||
self._update_current_link()
|
self._update_current_link()
|
||||||
|
|
||||||
except OSError as e:
|
except OSError as e:
|
||||||
logging.error(f"Failed to create documentation directories: {e}")
|
logging.exception(f"Failed to create documentation directories: {e}")
|
||||||
self.last_error = str(e)
|
self.last_error = str(e)
|
||||||
|
|
||||||
# Initial population of MeshChatX docs
|
# Initial population of MeshChatX docs
|
||||||
if os.path.exists(self.meshchatx_docs_dir) and os.access(
|
if os.path.exists(self.meshchatx_docs_dir) and os.access(
|
||||||
self.meshchatx_docs_dir, os.W_OK
|
self.meshchatx_docs_dir,
|
||||||
|
os.W_OK,
|
||||||
):
|
):
|
||||||
self.populate_meshchatx_docs()
|
self.populate_meshchatx_docs()
|
||||||
|
|
||||||
@@ -115,7 +117,7 @@ class DocsManager:
|
|||||||
version_file = os.path.join(self.docs_dir, ".version")
|
version_file = os.path.join(self.docs_dir, ".version")
|
||||||
if os.path.exists(version_file):
|
if os.path.exists(version_file):
|
||||||
try:
|
try:
|
||||||
with open(version_file, "r") as f:
|
with open(version_file) as f:
|
||||||
return f.read().strip()
|
return f.read().strip()
|
||||||
except OSError:
|
except OSError:
|
||||||
pass
|
pass
|
||||||
@@ -142,7 +144,7 @@ class DocsManager:
|
|||||||
# Project root is 3 levels up
|
# Project root is 3 levels up
|
||||||
this_dir = os.path.dirname(os.path.abspath(__file__))
|
this_dir = os.path.dirname(os.path.abspath(__file__))
|
||||||
search_paths.append(
|
search_paths.append(
|
||||||
os.path.abspath(os.path.join(this_dir, "..", "..", "..", "docs"))
|
os.path.abspath(os.path.join(this_dir, "..", "..", "..", "docs")),
|
||||||
)
|
)
|
||||||
|
|
||||||
src_docs = None
|
src_docs = None
|
||||||
@@ -163,13 +165,13 @@ class DocsManager:
|
|||||||
|
|
||||||
# Only copy if source and destination are different
|
# Only copy if source and destination are different
|
||||||
if os.path.abspath(src_path) != os.path.abspath(
|
if os.path.abspath(src_path) != os.path.abspath(
|
||||||
dest_path
|
dest_path,
|
||||||
) and os.access(self.meshchatx_docs_dir, os.W_OK):
|
) and os.access(self.meshchatx_docs_dir, os.W_OK):
|
||||||
shutil.copy2(src_path, dest_path)
|
shutil.copy2(src_path, dest_path)
|
||||||
|
|
||||||
# Also pre-render to HTML for easy sharing/viewing
|
# Also pre-render to HTML for easy sharing/viewing
|
||||||
try:
|
try:
|
||||||
with open(src_path, "r", encoding="utf-8") as f:
|
with open(src_path, encoding="utf-8") as f:
|
||||||
content = f.read()
|
content = f.read()
|
||||||
|
|
||||||
html_content = MarkdownRenderer.render(content)
|
html_content = MarkdownRenderer.render(content)
|
||||||
@@ -199,9 +201,9 @@ class DocsManager:
|
|||||||
) as f:
|
) as f:
|
||||||
f.write(full_html)
|
f.write(full_html)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logging.error(f"Failed to render {file} to HTML: {e}")
|
logging.exception(f"Failed to render {file} to HTML: {e}")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logging.error(f"Failed to populate MeshChatX docs: {e}")
|
logging.exception(f"Failed to populate MeshChatX docs: {e}")
|
||||||
|
|
||||||
def get_status(self):
|
def get_status(self):
|
||||||
return {
|
return {
|
||||||
@@ -228,15 +230,15 @@ class DocsManager:
|
|||||||
if not os.path.exists(self.meshchatx_docs_dir):
|
if not os.path.exists(self.meshchatx_docs_dir):
|
||||||
return docs
|
return docs
|
||||||
|
|
||||||
for file in os.listdir(self.meshchatx_docs_dir):
|
docs.extend(
|
||||||
if file.endswith((".md", ".txt")):
|
{
|
||||||
docs.append(
|
"name": file,
|
||||||
{
|
"path": file,
|
||||||
"name": file,
|
"type": "markdown" if file.endswith(".md") else "text",
|
||||||
"path": file,
|
}
|
||||||
"type": "markdown" if file.endswith(".md") else "text",
|
for file in os.listdir(self.meshchatx_docs_dir)
|
||||||
}
|
if file.endswith((".md", ".txt"))
|
||||||
)
|
)
|
||||||
return sorted(docs, key=lambda x: x["name"])
|
return sorted(docs, key=lambda x: x["name"])
|
||||||
|
|
||||||
def get_doc_content(self, path):
|
def get_doc_content(self, path):
|
||||||
@@ -244,7 +246,7 @@ class DocsManager:
|
|||||||
if not os.path.exists(full_path):
|
if not os.path.exists(full_path):
|
||||||
return None
|
return None
|
||||||
|
|
||||||
with open(full_path, "r", encoding="utf-8", errors="ignore") as f:
|
with open(full_path, encoding="utf-8", errors="ignore") as f:
|
||||||
content = f.read()
|
content = f.read()
|
||||||
|
|
||||||
if path.endswith(".md"):
|
if path.endswith(".md"):
|
||||||
@@ -253,12 +255,11 @@ class DocsManager:
|
|||||||
"html": MarkdownRenderer.render(content),
|
"html": MarkdownRenderer.render(content),
|
||||||
"type": "markdown",
|
"type": "markdown",
|
||||||
}
|
}
|
||||||
else:
|
return {
|
||||||
return {
|
"content": content,
|
||||||
"content": content,
|
"html": f"<pre class='whitespace-pre-wrap font-mono'>{html.escape(content)}</pre>",
|
||||||
"html": f"<pre class='whitespace-pre-wrap font-mono'>{html.escape(content)}</pre>",
|
"type": "text",
|
||||||
"type": "text",
|
}
|
||||||
}
|
|
||||||
|
|
||||||
def export_docs(self):
|
def export_docs(self):
|
||||||
"""Creates a zip of all docs and returns the bytes."""
|
"""Creates a zip of all docs and returns the bytes."""
|
||||||
@@ -269,7 +270,8 @@ class DocsManager:
|
|||||||
for file in files:
|
for file in files:
|
||||||
file_path = os.path.join(root, file)
|
file_path = os.path.join(root, file)
|
||||||
rel_path = os.path.join(
|
rel_path = os.path.join(
|
||||||
"reticulum-docs", os.path.relpath(file_path, self.docs_dir)
|
"reticulum-docs",
|
||||||
|
os.path.relpath(file_path, self.docs_dir),
|
||||||
)
|
)
|
||||||
zip_file.write(file_path, rel_path)
|
zip_file.write(file_path, rel_path)
|
||||||
|
|
||||||
@@ -300,7 +302,9 @@ class DocsManager:
|
|||||||
file_path = os.path.join(self.meshchatx_docs_dir, file)
|
file_path = os.path.join(self.meshchatx_docs_dir, file)
|
||||||
try:
|
try:
|
||||||
with open(
|
with open(
|
||||||
file_path, "r", encoding="utf-8", errors="ignore"
|
file_path,
|
||||||
|
encoding="utf-8",
|
||||||
|
errors="ignore",
|
||||||
) as f:
|
) as f:
|
||||||
content = f.read()
|
content = f.read()
|
||||||
if query in content.lower():
|
if query in content.lower():
|
||||||
@@ -320,10 +324,10 @@ class DocsManager:
|
|||||||
"path": f"/meshchatx-docs/{file}",
|
"path": f"/meshchatx-docs/{file}",
|
||||||
"snippet": snippet,
|
"snippet": snippet,
|
||||||
"source": "MeshChatX",
|
"source": "MeshChatX",
|
||||||
}
|
},
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logging.error(f"Error searching MeshChatX doc {file}: {e}")
|
logging.exception(f"Error searching MeshChatX doc {file}: {e}")
|
||||||
|
|
||||||
# 2. Search Reticulum Docs
|
# 2. Search Reticulum Docs
|
||||||
if self.has_docs():
|
if self.has_docs():
|
||||||
@@ -405,7 +409,7 @@ class DocsManager:
|
|||||||
"path": f"/reticulum-docs/{rel_path}",
|
"path": f"/reticulum-docs/{rel_path}",
|
||||||
"snippet": snippet,
|
"snippet": snippet,
|
||||||
"source": "Reticulum",
|
"source": "Reticulum",
|
||||||
}
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
if len(results) >= 25: # Limit results
|
if len(results) >= 25: # Limit results
|
||||||
@@ -469,7 +473,7 @@ class DocsManager:
|
|||||||
downloaded_size += len(chunk)
|
downloaded_size += len(chunk)
|
||||||
if total_size > 0:
|
if total_size > 0:
|
||||||
self.download_progress = int(
|
self.download_progress = int(
|
||||||
(downloaded_size / total_size) * 90
|
(downloaded_size / total_size) * 90,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Extract
|
# Extract
|
||||||
|
|||||||
@@ -1,28 +1,31 @@
|
|||||||
import os
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import os
|
||||||
import threading
|
import threading
|
||||||
|
|
||||||
import RNS
|
import RNS
|
||||||
from meshchatx.src.backend.database import Database
|
|
||||||
from meshchatx.src.backend.integrity_manager import IntegrityManager
|
from meshchatx.src.backend.announce_handler import AnnounceHandler
|
||||||
from meshchatx.src.backend.config_manager import ConfigManager
|
|
||||||
from meshchatx.src.backend.message_handler import MessageHandler
|
|
||||||
from meshchatx.src.backend.announce_manager import AnnounceManager
|
from meshchatx.src.backend.announce_manager import AnnounceManager
|
||||||
from meshchatx.src.backend.archiver_manager import ArchiverManager
|
from meshchatx.src.backend.archiver_manager import ArchiverManager
|
||||||
from meshchatx.src.backend.map_manager import MapManager
|
from meshchatx.src.backend.bot_handler import BotHandler
|
||||||
|
from meshchatx.src.backend.community_interfaces import CommunityInterfacesManager
|
||||||
|
from meshchatx.src.backend.config_manager import ConfigManager
|
||||||
|
from meshchatx.src.backend.database import Database
|
||||||
from meshchatx.src.backend.docs_manager import DocsManager
|
from meshchatx.src.backend.docs_manager import DocsManager
|
||||||
|
from meshchatx.src.backend.forwarding_manager import ForwardingManager
|
||||||
|
from meshchatx.src.backend.integrity_manager import IntegrityManager
|
||||||
|
from meshchatx.src.backend.map_manager import MapManager
|
||||||
|
from meshchatx.src.backend.meshchat_utils import create_lxmf_router
|
||||||
|
from meshchatx.src.backend.message_handler import MessageHandler
|
||||||
from meshchatx.src.backend.nomadnet_utils import NomadNetworkManager
|
from meshchatx.src.backend.nomadnet_utils import NomadNetworkManager
|
||||||
from meshchatx.src.backend.telephone_manager import TelephoneManager
|
|
||||||
from meshchatx.src.backend.voicemail_manager import VoicemailManager
|
|
||||||
from meshchatx.src.backend.ringtone_manager import RingtoneManager
|
from meshchatx.src.backend.ringtone_manager import RingtoneManager
|
||||||
from meshchatx.src.backend.rncp_handler import RNCPHandler
|
from meshchatx.src.backend.rncp_handler import RNCPHandler
|
||||||
from meshchatx.src.backend.rnstatus_handler import RNStatusHandler
|
|
||||||
from meshchatx.src.backend.rnpath_handler import RNPathHandler
|
from meshchatx.src.backend.rnpath_handler import RNPathHandler
|
||||||
from meshchatx.src.backend.rnprobe_handler import RNProbeHandler
|
from meshchatx.src.backend.rnprobe_handler import RNProbeHandler
|
||||||
|
from meshchatx.src.backend.rnstatus_handler import RNStatusHandler
|
||||||
|
from meshchatx.src.backend.telephone_manager import TelephoneManager
|
||||||
from meshchatx.src.backend.translator_handler import TranslatorHandler
|
from meshchatx.src.backend.translator_handler import TranslatorHandler
|
||||||
from meshchatx.src.backend.forwarding_manager import ForwardingManager
|
from meshchatx.src.backend.voicemail_manager import VoicemailManager
|
||||||
from meshchatx.src.backend.meshchat_utils import create_lxmf_router
|
|
||||||
from meshchatx.src.backend.announce_handler import AnnounceHandler
|
|
||||||
from meshchatx.src.backend.community_interfaces import CommunityInterfacesManager
|
|
||||||
|
|
||||||
|
|
||||||
class IdentityContext:
|
class IdentityContext:
|
||||||
@@ -71,12 +74,15 @@ class IdentityContext:
|
|||||||
self.rnstatus_handler = None
|
self.rnstatus_handler = None
|
||||||
self.rnprobe_handler = None
|
self.rnprobe_handler = None
|
||||||
self.translator_handler = None
|
self.translator_handler = None
|
||||||
|
self.bot_handler = None
|
||||||
self.forwarding_manager = None
|
self.forwarding_manager = None
|
||||||
self.community_interfaces_manager = None
|
self.community_interfaces_manager = None
|
||||||
self.local_lxmf_destination = None
|
self.local_lxmf_destination = None
|
||||||
self.announce_handlers = []
|
self.announce_handlers = []
|
||||||
self.integrity_manager = IntegrityManager(
|
self.integrity_manager = IntegrityManager(
|
||||||
self.storage_path, self.database_path, self.identity_hash
|
self.storage_path,
|
||||||
|
self.database_path,
|
||||||
|
self.identity_hash,
|
||||||
)
|
)
|
||||||
|
|
||||||
self.running = False
|
self.running = False
|
||||||
@@ -102,7 +108,7 @@ class IdentityContext:
|
|||||||
is_ok, issues = self.integrity_manager.check_integrity()
|
is_ok, issues = self.integrity_manager.check_integrity()
|
||||||
if not is_ok:
|
if not is_ok:
|
||||||
print(
|
print(
|
||||||
f"INTEGRITY WARNING for {self.identity_hash}: {', '.join(issues)}"
|
f"INTEGRITY WARNING for {self.identity_hash}: {', '.join(issues)}",
|
||||||
)
|
)
|
||||||
if not hasattr(self.app, "integrity_issues"):
|
if not hasattr(self.app, "integrity_issues"):
|
||||||
self.app.integrity_issues = []
|
self.app.integrity_issues = []
|
||||||
@@ -120,7 +126,7 @@ class IdentityContext:
|
|||||||
if not self.app.auto_recover and not getattr(self.app, "emergency", False):
|
if not self.app.auto_recover and not getattr(self.app, "emergency", False):
|
||||||
raise
|
raise
|
||||||
print(
|
print(
|
||||||
f"Database initialization failed for {self.identity_hash}, attempting recovery: {exc}"
|
f"Database initialization failed for {self.identity_hash}, attempting recovery: {exc}",
|
||||||
)
|
)
|
||||||
if not getattr(self.app, "emergency", False):
|
if not getattr(self.app, "emergency", False):
|
||||||
self.app._run_startup_auto_recovery()
|
self.app._run_startup_auto_recovery()
|
||||||
@@ -151,8 +157,8 @@ class IdentityContext:
|
|||||||
self.app.get_public_path(),
|
self.app.get_public_path(),
|
||||||
project_root=os.path.dirname(
|
project_root=os.path.dirname(
|
||||||
os.path.dirname(
|
os.path.dirname(
|
||||||
os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
|
||||||
)
|
),
|
||||||
),
|
),
|
||||||
storage_dir=self.storage_path,
|
storage_dir=self.storage_path,
|
||||||
)
|
)
|
||||||
@@ -197,7 +203,7 @@ class IdentityContext:
|
|||||||
|
|
||||||
# Register delivery callback
|
# Register delivery callback
|
||||||
self.message_router.register_delivery_callback(
|
self.message_router.register_delivery_callback(
|
||||||
lambda msg: self.app.on_lxmf_delivery(msg, context=self)
|
lambda msg: self.app.on_lxmf_delivery(msg, context=self),
|
||||||
)
|
)
|
||||||
|
|
||||||
# 5. Initialize Handlers and Managers
|
# 5. Initialize Handlers and Managers
|
||||||
@@ -224,6 +230,15 @@ class IdentityContext:
|
|||||||
enabled=translator_enabled,
|
enabled=translator_enabled,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
self.bot_handler = BotHandler(
|
||||||
|
identity_path=self.storage_path,
|
||||||
|
config_manager=self.config,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
self.bot_handler.restore_enabled_bots()
|
||||||
|
except Exception as exc:
|
||||||
|
print(f"Failed to restore bots: {exc}")
|
||||||
|
|
||||||
# Initialize managers
|
# Initialize managers
|
||||||
self.telephone_manager = TelephoneManager(
|
self.telephone_manager = TelephoneManager(
|
||||||
self.identity,
|
self.identity,
|
||||||
@@ -236,17 +251,19 @@ class IdentityContext:
|
|||||||
)
|
)
|
||||||
self.telephone_manager.on_initiation_status_callback = (
|
self.telephone_manager.on_initiation_status_callback = (
|
||||||
lambda status, target: self.app.on_telephone_initiation_status(
|
lambda status, target: self.app.on_telephone_initiation_status(
|
||||||
status, target, context=self
|
status,
|
||||||
|
target,
|
||||||
|
context=self,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
self.telephone_manager.register_ringing_callback(
|
self.telephone_manager.register_ringing_callback(
|
||||||
lambda call: self.app.on_incoming_telephone_call(call, context=self)
|
lambda call: self.app.on_incoming_telephone_call(call, context=self),
|
||||||
)
|
)
|
||||||
self.telephone_manager.register_established_callback(
|
self.telephone_manager.register_established_callback(
|
||||||
lambda call: self.app.on_telephone_call_established(call, context=self)
|
lambda call: self.app.on_telephone_call_established(call, context=self),
|
||||||
)
|
)
|
||||||
self.telephone_manager.register_ended_callback(
|
self.telephone_manager.register_ended_callback(
|
||||||
lambda call: self.app.on_telephone_call_ended(call, context=self)
|
lambda call: self.app.on_telephone_call_ended(call, context=self),
|
||||||
)
|
)
|
||||||
|
|
||||||
# Only initialize telephone hardware/profile if not in emergency mode
|
# Only initialize telephone hardware/profile if not in emergency mode
|
||||||
@@ -287,7 +304,7 @@ class IdentityContext:
|
|||||||
):
|
):
|
||||||
if not self.docs_manager.has_docs():
|
if not self.docs_manager.has_docs():
|
||||||
print(
|
print(
|
||||||
f"Triggering initial documentation download for {self.identity_hash}..."
|
f"Triggering initial documentation download for {self.identity_hash}...",
|
||||||
)
|
)
|
||||||
self.docs_manager.update_docs()
|
self.docs_manager.update_docs()
|
||||||
self.config.initial_docs_download_attempted.set(True)
|
self.config.initial_docs_download_attempted.set(True)
|
||||||
@@ -338,13 +355,23 @@ class IdentityContext:
|
|||||||
AnnounceHandler(
|
AnnounceHandler(
|
||||||
"lxst.telephony",
|
"lxst.telephony",
|
||||||
lambda aspect, dh, ai, ad, aph: self.app.on_telephone_announce_received(
|
lambda aspect, dh, ai, ad, aph: self.app.on_telephone_announce_received(
|
||||||
aspect, dh, ai, ad, aph, context=self
|
aspect,
|
||||||
|
dh,
|
||||||
|
ai,
|
||||||
|
ad,
|
||||||
|
aph,
|
||||||
|
context=self,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
AnnounceHandler(
|
AnnounceHandler(
|
||||||
"lxmf.delivery",
|
"lxmf.delivery",
|
||||||
lambda aspect, dh, ai, ad, aph: self.app.on_lxmf_announce_received(
|
lambda aspect, dh, ai, ad, aph: self.app.on_lxmf_announce_received(
|
||||||
aspect, dh, ai, ad, aph, context=self
|
aspect,
|
||||||
|
dh,
|
||||||
|
ai,
|
||||||
|
ad,
|
||||||
|
aph,
|
||||||
|
context=self,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
AnnounceHandler(
|
AnnounceHandler(
|
||||||
@@ -354,7 +381,12 @@ class IdentityContext:
|
|||||||
ai,
|
ai,
|
||||||
ad,
|
ad,
|
||||||
aph: self.app.on_lxmf_propagation_announce_received(
|
aph: self.app.on_lxmf_propagation_announce_received(
|
||||||
aspect, dh, ai, ad, aph, context=self
|
aspect,
|
||||||
|
dh,
|
||||||
|
ai,
|
||||||
|
ad,
|
||||||
|
aph,
|
||||||
|
context=self,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
AnnounceHandler(
|
AnnounceHandler(
|
||||||
@@ -364,7 +396,12 @@ class IdentityContext:
|
|||||||
ai,
|
ai,
|
||||||
ad,
|
ad,
|
||||||
aph: self.app.on_nomadnet_node_announce_received(
|
aph: self.app.on_nomadnet_node_announce_received(
|
||||||
aspect, dh, ai, ad, aph, context=self
|
aspect,
|
||||||
|
dh,
|
||||||
|
ai,
|
||||||
|
ad,
|
||||||
|
aph,
|
||||||
|
context=self,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
@@ -389,7 +426,7 @@ class IdentityContext:
|
|||||||
if self.message_router:
|
if self.message_router:
|
||||||
if hasattr(self.message_router, "delivery_destinations"):
|
if hasattr(self.message_router, "delivery_destinations"):
|
||||||
for dest_hash in list(
|
for dest_hash in list(
|
||||||
self.message_router.delivery_destinations.keys()
|
self.message_router.delivery_destinations.keys(),
|
||||||
):
|
):
|
||||||
dest = self.message_router.delivery_destinations[dest_hash]
|
dest = self.message_router.delivery_destinations[dest_hash]
|
||||||
RNS.Transport.deregister_destination(dest)
|
RNS.Transport.deregister_destination(dest)
|
||||||
@@ -399,7 +436,7 @@ class IdentityContext:
|
|||||||
and self.message_router.propagation_destination
|
and self.message_router.propagation_destination
|
||||||
):
|
):
|
||||||
RNS.Transport.deregister_destination(
|
RNS.Transport.deregister_destination(
|
||||||
self.message_router.propagation_destination
|
self.message_router.propagation_destination,
|
||||||
)
|
)
|
||||||
|
|
||||||
if self.telephone_manager and self.telephone_manager.telephone:
|
if self.telephone_manager and self.telephone_manager.telephone:
|
||||||
@@ -408,7 +445,7 @@ class IdentityContext:
|
|||||||
and self.telephone_manager.telephone.destination
|
and self.telephone_manager.telephone.destination
|
||||||
):
|
):
|
||||||
RNS.Transport.deregister_destination(
|
RNS.Transport.deregister_destination(
|
||||||
self.telephone_manager.telephone.destination
|
self.telephone_manager.telephone.destination,
|
||||||
)
|
)
|
||||||
|
|
||||||
self.app.cleanup_rns_state_for_identity(self.identity.hash)
|
self.app.cleanup_rns_state_for_identity(self.identity.hash)
|
||||||
@@ -423,7 +460,7 @@ class IdentityContext:
|
|||||||
self.message_router.exit_handler()
|
self.message_router.exit_handler()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(
|
print(
|
||||||
f"Error while tearing down LXMRouter for {self.identity_hash}: {e}"
|
f"Error while tearing down LXMRouter for {self.identity_hash}: {e}",
|
||||||
)
|
)
|
||||||
|
|
||||||
# 4. Stop telephone and voicemail
|
# 4. Stop telephone and voicemail
|
||||||
@@ -432,16 +469,22 @@ class IdentityContext:
|
|||||||
self.telephone_manager.teardown()
|
self.telephone_manager.teardown()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(
|
print(
|
||||||
f"Error while tearing down telephone for {self.identity_hash}: {e}"
|
f"Error while tearing down telephone for {self.identity_hash}: {e}",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if self.bot_handler:
|
||||||
|
try:
|
||||||
|
self.bot_handler.stop_all()
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error while stopping bots for {self.identity_hash}: {e}")
|
||||||
|
|
||||||
if self.database:
|
if self.database:
|
||||||
try:
|
try:
|
||||||
# 1. Checkpoint WAL and close database cleanly to ensure file is stable for hashing
|
# 1. Checkpoint WAL and close database cleanly to ensure file is stable for hashing
|
||||||
self.database._checkpoint_and_close()
|
self.database._checkpoint_and_close()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(
|
print(
|
||||||
f"Error closing database during teardown for {self.identity_hash}: {e}"
|
f"Error closing database during teardown for {self.identity_hash}: {e}",
|
||||||
)
|
)
|
||||||
|
|
||||||
# 2. Save integrity manifest AFTER closing to capture final stable state
|
# 2. Save integrity manifest AFTER closing to capture final stable state
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ class IdentityManager:
|
|||||||
metadata = None
|
metadata = None
|
||||||
if os.path.exists(metadata_path):
|
if os.path.exists(metadata_path):
|
||||||
try:
|
try:
|
||||||
with open(metadata_path, "r") as f:
|
with open(metadata_path) as f:
|
||||||
metadata = json.load(f)
|
metadata = json.load(f)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
@@ -62,10 +62,10 @@ class IdentityManager:
|
|||||||
"display_name": metadata.get("display_name", "Anonymous Peer"),
|
"display_name": metadata.get("display_name", "Anonymous Peer"),
|
||||||
"icon_name": metadata.get("icon_name"),
|
"icon_name": metadata.get("icon_name"),
|
||||||
"icon_foreground_colour": metadata.get(
|
"icon_foreground_colour": metadata.get(
|
||||||
"icon_foreground_colour"
|
"icon_foreground_colour",
|
||||||
),
|
),
|
||||||
"icon_background_colour": metadata.get(
|
"icon_background_colour": metadata.get(
|
||||||
"icon_background_colour"
|
"icon_background_colour",
|
||||||
),
|
),
|
||||||
"lxmf_address": metadata.get("lxmf_address"),
|
"lxmf_address": metadata.get("lxmf_address"),
|
||||||
"lxst_address": metadata.get("lxst_address"),
|
"lxst_address": metadata.get("lxst_address"),
|
||||||
@@ -137,14 +137,17 @@ class IdentityManager:
|
|||||||
|
|
||||||
def create_identity(self, display_name=None):
|
def create_identity(self, display_name=None):
|
||||||
new_identity = RNS.Identity(create_keys=True)
|
new_identity = RNS.Identity(create_keys=True)
|
||||||
identity_hash = new_identity.hash.hex()
|
return self._save_new_identity(new_identity, display_name or "Anonymous Peer")
|
||||||
|
|
||||||
|
def _save_new_identity(self, identity, display_name):
|
||||||
|
identity_hash = identity.hash.hex()
|
||||||
|
|
||||||
identity_dir = os.path.join(self.storage_dir, "identities", identity_hash)
|
identity_dir = os.path.join(self.storage_dir, "identities", identity_hash)
|
||||||
os.makedirs(identity_dir, exist_ok=True)
|
os.makedirs(identity_dir, exist_ok=True)
|
||||||
|
|
||||||
identity_file = os.path.join(identity_dir, "identity")
|
identity_file = os.path.join(identity_dir, "identity")
|
||||||
with open(identity_file, "wb") as f:
|
with open(identity_file, "wb") as f:
|
||||||
f.write(new_identity.get_private_key())
|
f.write(identity.get_private_key())
|
||||||
|
|
||||||
db_path = os.path.join(identity_dir, "database.db")
|
db_path = os.path.join(identity_dir, "database.db")
|
||||||
|
|
||||||
@@ -160,7 +163,7 @@ class IdentityManager:
|
|||||||
|
|
||||||
# Save metadata
|
# Save metadata
|
||||||
metadata = {
|
metadata = {
|
||||||
"display_name": display_name or "Anonymous Peer",
|
"display_name": display_name,
|
||||||
"icon_name": None,
|
"icon_name": None,
|
||||||
"icon_foreground_colour": None,
|
"icon_foreground_colour": None,
|
||||||
"icon_background_colour": None,
|
"icon_background_colour": None,
|
||||||
@@ -171,7 +174,7 @@ class IdentityManager:
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
"hash": identity_hash,
|
"hash": identity_hash,
|
||||||
"display_name": display_name or "Anonymous Peer",
|
"display_name": display_name,
|
||||||
}
|
}
|
||||||
|
|
||||||
def update_metadata_cache(self, identity_hash: str, metadata: dict):
|
def update_metadata_cache(self, identity_hash: str, metadata: dict):
|
||||||
@@ -185,7 +188,7 @@ class IdentityManager:
|
|||||||
existing_metadata = {}
|
existing_metadata = {}
|
||||||
if os.path.exists(metadata_path):
|
if os.path.exists(metadata_path):
|
||||||
try:
|
try:
|
||||||
with open(metadata_path, "r") as f:
|
with open(metadata_path) as f:
|
||||||
existing_metadata = json.load(f)
|
existing_metadata = json.load(f)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
@@ -206,20 +209,20 @@ class IdentityManager:
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
def restore_identity_from_bytes(self, identity_bytes: bytes) -> dict:
|
def restore_identity_from_bytes(self, identity_bytes: bytes) -> dict:
|
||||||
target_path = self.identity_file_path or os.path.join(
|
try:
|
||||||
self.storage_dir,
|
# We use RNS.Identity.from_bytes to validate and get the hash
|
||||||
"identity",
|
identity = RNS.Identity.from_bytes(identity_bytes)
|
||||||
)
|
if not identity:
|
||||||
os.makedirs(os.path.dirname(target_path), exist_ok=True)
|
raise ValueError("Could not load identity from bytes")
|
||||||
with open(target_path, "wb") as f:
|
|
||||||
f.write(identity_bytes)
|
return self._save_new_identity(identity, "Restored Identity")
|
||||||
return {"path": target_path, "size": os.path.getsize(target_path)}
|
except Exception as exc:
|
||||||
|
raise ValueError(f"Failed to restore identity: {exc}")
|
||||||
|
|
||||||
def restore_identity_from_base32(self, base32_value: str) -> dict:
|
def restore_identity_from_base32(self, base32_value: str) -> dict:
|
||||||
try:
|
try:
|
||||||
identity_bytes = base64.b32decode(base32_value, casefold=True)
|
identity_bytes = base64.b32decode(base32_value, casefold=True)
|
||||||
|
return self.restore_identity_from_bytes(identity_bytes)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
msg = f"Invalid base32 identity: {exc}"
|
msg = f"Invalid base32 identity: {exc}"
|
||||||
raise ValueError(msg) from exc
|
raise ValueError(msg) from exc
|
||||||
|
|
||||||
return self.restore_identity_from_bytes(identity_bytes)
|
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import os
|
|
||||||
import hashlib
|
import hashlib
|
||||||
import json
|
import json
|
||||||
from pathlib import Path
|
import os
|
||||||
from datetime import UTC, datetime
|
from datetime import UTC, datetime
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
class IntegrityManager:
|
class IntegrityManager:
|
||||||
@@ -30,7 +30,7 @@ class IntegrityManager:
|
|||||||
return True, ["Initial run - no manifest yet"]
|
return True, ["Initial run - no manifest yet"]
|
||||||
|
|
||||||
try:
|
try:
|
||||||
with open(self.manifest_path, "r") as f:
|
with open(self.manifest_path) as f:
|
||||||
manifest = json.load(f)
|
manifest = json.load(f)
|
||||||
|
|
||||||
issues = []
|
issues = []
|
||||||
@@ -39,7 +39,7 @@ class IntegrityManager:
|
|||||||
db_rel = str(self.database_path.relative_to(self.storage_dir))
|
db_rel = str(self.database_path.relative_to(self.storage_dir))
|
||||||
actual_db_hash = self._hash_file(self.database_path)
|
actual_db_hash = self._hash_file(self.database_path)
|
||||||
if actual_db_hash and actual_db_hash != manifest.get("files", {}).get(
|
if actual_db_hash and actual_db_hash != manifest.get("files", {}).get(
|
||||||
db_rel
|
db_rel,
|
||||||
):
|
):
|
||||||
issues.append(f"Database modified: {db_rel}")
|
issues.append(f"Database modified: {db_rel}")
|
||||||
|
|
||||||
@@ -70,7 +70,8 @@ class IntegrityManager:
|
|||||||
m_time = manifest.get("time", "Unknown")
|
m_time = manifest.get("time", "Unknown")
|
||||||
m_id = manifest.get("identity", "Unknown")
|
m_id = manifest.get("identity", "Unknown")
|
||||||
issues.insert(
|
issues.insert(
|
||||||
0, f"Last integrity snapshot: {m_date} {m_time} (Identity: {m_id})"
|
0,
|
||||||
|
f"Last integrity snapshot: {m_date} {m_time} (Identity: {m_id})",
|
||||||
)
|
)
|
||||||
|
|
||||||
# Check if identity matches
|
# Check if identity matches
|
||||||
@@ -84,7 +85,7 @@ class IntegrityManager:
|
|||||||
self.issues = issues
|
self.issues = issues
|
||||||
return len(issues) == 0, issues
|
return len(issues) == 0, issues
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return False, [f"Integrity check failed: {str(e)}"]
|
return False, [f"Integrity check failed: {e!s}"]
|
||||||
|
|
||||||
def save_manifest(self):
|
def save_manifest(self):
|
||||||
"""Snapshot the current state of critical files."""
|
"""Snapshot the current state of critical files."""
|
||||||
|
|||||||
@@ -3,10 +3,11 @@ import time
|
|||||||
|
|
||||||
import RNS
|
import RNS
|
||||||
from RNS.Interfaces.Interface import Interface
|
from RNS.Interfaces.Interface import Interface
|
||||||
|
from websockets.sync.server import Server, ServerConnection, serve
|
||||||
|
|
||||||
from meshchatx.src.backend.interfaces.WebsocketClientInterface import (
|
from meshchatx.src.backend.interfaces.WebsocketClientInterface import (
|
||||||
WebsocketClientInterface,
|
WebsocketClientInterface,
|
||||||
)
|
)
|
||||||
from websockets.sync.server import Server, ServerConnection, serve
|
|
||||||
|
|
||||||
|
|
||||||
class WebsocketServerInterface(Interface):
|
class WebsocketServerInterface(Interface):
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import base64
|
import base64
|
||||||
import json
|
import json
|
||||||
|
|
||||||
import LXMF
|
import LXMF
|
||||||
|
|
||||||
from meshchatx.src.backend.telemetry_utils import Telemeter
|
from meshchatx.src.backend.telemetry_utils import Telemeter
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -191,9 +191,9 @@ class MapManager:
|
|||||||
for z in zoom_levels:
|
for z in zoom_levels:
|
||||||
x1, y1 = self._lonlat_to_tile(min_lon, max_lat, z)
|
x1, y1 = self._lonlat_to_tile(min_lon, max_lat, z)
|
||||||
x2, y2 = self._lonlat_to_tile(max_lon, min_lat, z)
|
x2, y2 = self._lonlat_to_tile(max_lon, min_lat, z)
|
||||||
for x in range(x1, x2 + 1):
|
tiles_to_download.extend(
|
||||||
for y in range(y1, y2 + 1):
|
(z, x, y) for x in range(x1, x2 + 1) for y in range(y1, y2 + 1)
|
||||||
tiles_to_download.append((z, x, y))
|
)
|
||||||
|
|
||||||
total_tiles = len(tiles_to_download)
|
total_tiles = len(tiles_to_download)
|
||||||
self._export_progress[export_id]["total"] = total_tiles
|
self._export_progress[export_id]["total"] = total_tiles
|
||||||
@@ -265,7 +265,7 @@ class MapManager:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
with concurrent.futures.ThreadPoolExecutor(
|
with concurrent.futures.ThreadPoolExecutor(
|
||||||
max_workers=max_workers
|
max_workers=max_workers,
|
||||||
) as executor:
|
) as executor:
|
||||||
future_to_tile = {
|
future_to_tile = {
|
||||||
executor.submit(download_tile, tile): tile
|
executor.submit(download_tile, tile): tile
|
||||||
@@ -299,7 +299,8 @@ class MapManager:
|
|||||||
):
|
):
|
||||||
try:
|
try:
|
||||||
cursor.executemany(
|
cursor.executemany(
|
||||||
"INSERT INTO tiles VALUES (?, ?, ?, ?)", batch_data
|
"INSERT INTO tiles VALUES (?, ?, ?, ?)",
|
||||||
|
batch_data,
|
||||||
)
|
)
|
||||||
conn.commit()
|
conn.commit()
|
||||||
batch_data = []
|
batch_data = []
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import re
|
|
||||||
import html
|
import html
|
||||||
|
import re
|
||||||
|
|
||||||
|
|
||||||
class MarkdownRenderer:
|
class MarkdownRenderer:
|
||||||
@@ -24,12 +24,15 @@ class MarkdownRenderer:
|
|||||||
code = match.group(2)
|
code = match.group(2)
|
||||||
placeholder = f"[[CB{len(code_blocks)}]]"
|
placeholder = f"[[CB{len(code_blocks)}]]"
|
||||||
code_blocks.append(
|
code_blocks.append(
|
||||||
f'<pre class="bg-gray-800 dark:bg-zinc-900 text-zinc-100 dark:text-zinc-100 p-4 rounded-lg my-4 overflow-x-auto border border-gray-700 dark:border-zinc-800 font-mono text-sm"><code class="language-{lang} text-inherit">{code}</code></pre>'
|
f'<pre class="bg-gray-800 dark:bg-zinc-900 text-zinc-100 dark:text-zinc-100 p-4 rounded-lg my-4 overflow-x-auto border border-gray-700 dark:border-zinc-800 font-mono text-sm"><code class="language-{lang} text-inherit">{code}</code></pre>',
|
||||||
)
|
)
|
||||||
return placeholder
|
return placeholder
|
||||||
|
|
||||||
text = re.sub(
|
text = re.sub(
|
||||||
r"```(\w+)?\n(.*?)\n```", code_block_placeholder, text, flags=re.DOTALL
|
r"```(\w+)?\n(.*?)\n```",
|
||||||
|
code_block_placeholder,
|
||||||
|
text,
|
||||||
|
flags=re.DOTALL,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Horizontal Rules
|
# Horizontal Rules
|
||||||
@@ -134,7 +137,10 @@ class MarkdownRenderer:
|
|||||||
return f'<ul class="my-4 space-y-1">{html_items}</ul>'
|
return f'<ul class="my-4 space-y-1">{html_items}</ul>'
|
||||||
|
|
||||||
text = re.sub(
|
text = re.sub(
|
||||||
r"((?:^[*-] .*\n?)+)", unordered_list_repl, text, flags=re.MULTILINE
|
r"((?:^[*-] .*\n?)+)",
|
||||||
|
unordered_list_repl,
|
||||||
|
text,
|
||||||
|
flags=re.MULTILINE,
|
||||||
)
|
)
|
||||||
|
|
||||||
def ordered_list_repl(match):
|
def ordered_list_repl(match):
|
||||||
@@ -146,7 +152,10 @@ class MarkdownRenderer:
|
|||||||
return f'<ol class="my-4 space-y-1">{html_items}</ol>'
|
return f'<ol class="my-4 space-y-1">{html_items}</ol>'
|
||||||
|
|
||||||
text = re.sub(
|
text = re.sub(
|
||||||
r"((?:^\d+\. .*\n?)+)", ordered_list_repl, text, flags=re.MULTILINE
|
r"((?:^\d+\. .*\n?)+)",
|
||||||
|
ordered_list_repl,
|
||||||
|
text,
|
||||||
|
flags=re.MULTILINE,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Paragraphs - double newline to p tag
|
# Paragraphs - double newline to p tag
|
||||||
@@ -169,7 +178,7 @@ class MarkdownRenderer:
|
|||||||
# Replace single newlines with <br> for line breaks within paragraphs
|
# Replace single newlines with <br> for line breaks within paragraphs
|
||||||
part = part.replace("\n", "<br>")
|
part = part.replace("\n", "<br>")
|
||||||
processed_parts.append(
|
processed_parts.append(
|
||||||
f'<p class="my-4 leading-relaxed text-gray-800 dark:text-zinc-200">{part}</p>'
|
f'<p class="my-4 leading-relaxed text-gray-800 dark:text-zinc-200">{part}</p>',
|
||||||
)
|
)
|
||||||
|
|
||||||
text = "\n".join(processed_parts)
|
text = "\n".join(processed_parts)
|
||||||
|
|||||||
@@ -9,8 +9,7 @@ from LXMF import LXMRouter
|
|||||||
|
|
||||||
|
|
||||||
def create_lxmf_router(identity, storagepath, propagation_cost=None):
|
def create_lxmf_router(identity, storagepath, propagation_cost=None):
|
||||||
"""
|
"""Creates an LXMF.LXMRouter instance safely, avoiding signal handler crashes
|
||||||
Creates an LXMF.LXMRouter instance safely, avoiding signal handler crashes
|
|
||||||
when called from non-main threads.
|
when called from non-main threads.
|
||||||
"""
|
"""
|
||||||
if propagation_cost is None:
|
if propagation_cost is None:
|
||||||
|
|||||||
@@ -77,7 +77,11 @@ class MessageHandler:
|
|||||||
) m2 ON m1.peer_hash = m2.peer_hash AND m1.timestamp = m2.max_ts
|
) m2 ON m1.peer_hash = m2.peer_hash AND m1.timestamp = m2.max_ts
|
||||||
LEFT JOIN announces a ON a.destination_hash = m1.peer_hash
|
LEFT JOIN announces a ON a.destination_hash = m1.peer_hash
|
||||||
LEFT JOIN custom_destination_display_names c ON c.destination_hash = m1.peer_hash
|
LEFT JOIN custom_destination_display_names c ON c.destination_hash = m1.peer_hash
|
||||||
LEFT JOIN contacts con ON con.remote_identity_hash = m1.peer_hash
|
LEFT JOIN contacts con ON (
|
||||||
|
con.remote_identity_hash = m1.peer_hash OR
|
||||||
|
con.lxmf_address = m1.peer_hash OR
|
||||||
|
con.lxst_address = m1.peer_hash
|
||||||
|
)
|
||||||
LEFT JOIN lxmf_user_icons i ON i.destination_hash = m1.peer_hash
|
LEFT JOIN lxmf_user_icons i ON i.destination_hash = m1.peer_hash
|
||||||
LEFT JOIN lxmf_conversation_read_state r ON r.destination_hash = m1.peer_hash
|
LEFT JOIN lxmf_conversation_read_state r ON r.destination_hash = m1.peer_hash
|
||||||
"""
|
"""
|
||||||
@@ -86,7 +90,7 @@ class MessageHandler:
|
|||||||
|
|
||||||
if filter_unread:
|
if filter_unread:
|
||||||
where_clauses.append(
|
where_clauses.append(
|
||||||
"(r.last_read_at IS NULL OR m1.timestamp > strftime('%s', r.last_read_at))"
|
"(r.last_read_at IS NULL OR m1.timestamp > strftime('%s', r.last_read_at))",
|
||||||
)
|
)
|
||||||
|
|
||||||
if filter_failed:
|
if filter_failed:
|
||||||
@@ -94,7 +98,7 @@ class MessageHandler:
|
|||||||
|
|
||||||
if filter_has_attachments:
|
if filter_has_attachments:
|
||||||
where_clauses.append(
|
where_clauses.append(
|
||||||
"(m1.fields IS NOT NULL AND m1.fields != '{}' AND m1.fields != '')"
|
"(m1.fields IS NOT NULL AND m1.fields != '{}' AND m1.fields != '')",
|
||||||
)
|
)
|
||||||
|
|
||||||
if search:
|
if search:
|
||||||
@@ -105,7 +109,7 @@ class MessageHandler:
|
|||||||
OR m1.peer_hash IN (SELECT peer_hash FROM lxmf_messages WHERE title LIKE ? OR content LIKE ?))
|
OR m1.peer_hash IN (SELECT peer_hash FROM lxmf_messages WHERE title LIKE ? OR content LIKE ?))
|
||||||
""")
|
""")
|
||||||
params.extend(
|
params.extend(
|
||||||
[like_term, like_term, like_term, like_term, like_term, like_term]
|
[like_term, like_term, like_term, like_term, like_term, like_term],
|
||||||
)
|
)
|
||||||
|
|
||||||
if where_clauses:
|
if where_clauses:
|
||||||
|
|||||||
@@ -64,7 +64,8 @@ class PersistentLogHandler(logging.Handler):
|
|||||||
# Regex to extract IP and User-Agent from aiohttp access log
|
# Regex to extract IP and User-Agent from aiohttp access log
|
||||||
# Format: IP [date] "GET ..." status size "referer" "User-Agent"
|
# Format: IP [date] "GET ..." status size "referer" "User-Agent"
|
||||||
match = re.search(
|
match = re.search(
|
||||||
r"^([\d\.\:]+) .* \"[^\"]+\" \d+ \d+ \"[^\"]*\" \"([^\"]+)\"", message
|
r"^([\d\.\:]+) .* \"[^\"]+\" \d+ \d+ \"[^\"]*\" \"([^\"]+)\"",
|
||||||
|
message,
|
||||||
)
|
)
|
||||||
if match:
|
if match:
|
||||||
ip = match.group(1)
|
ip = match.group(1)
|
||||||
@@ -180,7 +181,13 @@ class PersistentLogHandler(logging.Handler):
|
|||||||
self.flush_lock.release()
|
self.flush_lock.release()
|
||||||
|
|
||||||
def get_logs(
|
def get_logs(
|
||||||
self, limit=100, offset=0, search=None, level=None, module=None, is_anomaly=None
|
self,
|
||||||
|
limit=100,
|
||||||
|
offset=0,
|
||||||
|
search=None,
|
||||||
|
level=None,
|
||||||
|
module=None,
|
||||||
|
is_anomaly=None,
|
||||||
):
|
):
|
||||||
if self.database:
|
if self.database:
|
||||||
# Flush current buffer first to ensure we have latest logs
|
# Flush current buffer first to ensure we have latest logs
|
||||||
@@ -196,34 +203,33 @@ class PersistentLogHandler(logging.Handler):
|
|||||||
module=module,
|
module=module,
|
||||||
is_anomaly=is_anomaly,
|
is_anomaly=is_anomaly,
|
||||||
)
|
)
|
||||||
else:
|
# Fallback to in-memory buffer if DB not yet available
|
||||||
# Fallback to in-memory buffer if DB not yet available
|
logs = list(self.logs_buffer)
|
||||||
logs = list(self.logs_buffer)
|
if search:
|
||||||
if search:
|
logs = [
|
||||||
logs = [
|
log
|
||||||
log
|
for log in logs
|
||||||
for log in logs
|
if search.lower() in log["message"].lower()
|
||||||
if search.lower() in log["message"].lower()
|
or search.lower() in log["module"].lower()
|
||||||
or search.lower() in log["module"].lower()
|
]
|
||||||
]
|
if level:
|
||||||
if level:
|
logs = [log for log in logs if log["level"] == level]
|
||||||
logs = [log for log in logs if log["level"] == level]
|
if is_anomaly is not None:
|
||||||
if is_anomaly is not None:
|
logs = [
|
||||||
logs = [
|
log for log in logs if log["is_anomaly"] == (1 if is_anomaly else 0)
|
||||||
log
|
]
|
||||||
for log in logs
|
|
||||||
if log["is_anomaly"] == (1 if is_anomaly else 0)
|
|
||||||
]
|
|
||||||
|
|
||||||
# Sort descending
|
# Sort descending
|
||||||
logs.sort(key=lambda x: x["timestamp"], reverse=True)
|
logs.sort(key=lambda x: x["timestamp"], reverse=True)
|
||||||
return logs[offset : offset + limit]
|
return logs[offset : offset + limit]
|
||||||
|
|
||||||
def get_total_count(self, search=None, level=None, module=None, is_anomaly=None):
|
def get_total_count(self, search=None, level=None, module=None, is_anomaly=None):
|
||||||
with self.lock:
|
with self.lock:
|
||||||
if self.database:
|
if self.database:
|
||||||
return self.database.debug_logs.get_total_count(
|
return self.database.debug_logs.get_total_count(
|
||||||
search=search, level=level, module=module, is_anomaly=is_anomaly
|
search=search,
|
||||||
|
level=level,
|
||||||
|
module=module,
|
||||||
|
is_anomaly=is_anomaly,
|
||||||
)
|
)
|
||||||
else:
|
return len(self.logs_buffer)
|
||||||
return len(self.logs_buffer)
|
|
||||||
|
|||||||
@@ -1,16 +1,16 @@
|
|||||||
import sys
|
|
||||||
import os
|
import os
|
||||||
import traceback
|
|
||||||
import platform
|
import platform
|
||||||
import shutil
|
import shutil
|
||||||
import sqlite3
|
import sqlite3
|
||||||
|
import sys
|
||||||
|
import traceback
|
||||||
|
|
||||||
import psutil
|
import psutil
|
||||||
import RNS
|
import RNS
|
||||||
|
|
||||||
|
|
||||||
class CrashRecovery:
|
class CrashRecovery:
|
||||||
"""
|
"""A diagnostic utility that intercepts application crashes and provides
|
||||||
A diagnostic utility that intercepts application crashes and provides
|
|
||||||
meaningful error reports and system state analysis.
|
meaningful error reports and system state analysis.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@@ -33,18 +33,14 @@ class CrashRecovery:
|
|||||||
self.enabled = False
|
self.enabled = False
|
||||||
|
|
||||||
def install(self):
|
def install(self):
|
||||||
"""
|
"""Installs the crash recovery exception hook into the system."""
|
||||||
Installs the crash recovery exception hook into the system.
|
|
||||||
"""
|
|
||||||
if not self.enabled:
|
if not self.enabled:
|
||||||
return
|
return
|
||||||
|
|
||||||
sys.excepthook = self.handle_exception
|
sys.excepthook = self.handle_exception
|
||||||
|
|
||||||
def disable(self):
|
def disable(self):
|
||||||
"""
|
"""Disables the crash recovery system manually."""
|
||||||
Disables the crash recovery system manually.
|
|
||||||
"""
|
|
||||||
self.enabled = False
|
self.enabled = False
|
||||||
|
|
||||||
def update_paths(
|
def update_paths(
|
||||||
@@ -54,9 +50,7 @@ class CrashRecovery:
|
|||||||
public_dir=None,
|
public_dir=None,
|
||||||
reticulum_config_dir=None,
|
reticulum_config_dir=None,
|
||||||
):
|
):
|
||||||
"""
|
"""Updates the internal paths used for system diagnosis."""
|
||||||
Updates the internal paths used for system diagnosis.
|
|
||||||
"""
|
|
||||||
if storage_dir:
|
if storage_dir:
|
||||||
self.storage_dir = storage_dir
|
self.storage_dir = storage_dir
|
||||||
if database_path:
|
if database_path:
|
||||||
@@ -67,9 +61,7 @@ class CrashRecovery:
|
|||||||
self.reticulum_config_dir = reticulum_config_dir
|
self.reticulum_config_dir = reticulum_config_dir
|
||||||
|
|
||||||
def handle_exception(self, exc_type, exc_value, exc_traceback):
|
def handle_exception(self, exc_type, exc_value, exc_traceback):
|
||||||
"""
|
"""Intercepts unhandled exceptions to provide a detailed diagnosis report."""
|
||||||
Intercepts unhandled exceptions to provide a detailed diagnosis report.
|
|
||||||
"""
|
|
||||||
# Let keyboard interrupts pass through normally
|
# Let keyboard interrupts pass through normally
|
||||||
if issubclass(exc_type, KeyboardInterrupt):
|
if issubclass(exc_type, KeyboardInterrupt):
|
||||||
sys.__excepthook__(exc_type, exc_value, exc_traceback)
|
sys.__excepthook__(exc_type, exc_value, exc_traceback)
|
||||||
@@ -100,13 +92,13 @@ class CrashRecovery:
|
|||||||
out.write("Recovery Suggestions:\n")
|
out.write("Recovery Suggestions:\n")
|
||||||
out.write(" 1. Review the 'System Environment Diagnosis' section above.\n")
|
out.write(" 1. Review the 'System Environment Diagnosis' section above.\n")
|
||||||
out.write(
|
out.write(
|
||||||
" 2. Verify that all dependencies are installed (poetry install or pip install -r requirements.txt).\n"
|
" 2. Verify that all dependencies are installed (poetry install or pip install -r requirements.txt).\n",
|
||||||
)
|
)
|
||||||
out.write(
|
out.write(
|
||||||
" 3. If database corruption is suspected, try starting with --auto-recover.\n"
|
" 3. If database corruption is suspected, try starting with --auto-recover.\n",
|
||||||
)
|
)
|
||||||
out.write(
|
out.write(
|
||||||
" 4. If the issue persists, report it to Ivan over another LXMF client: 7cc8d66b4f6a0e0e49d34af7f6077b5a\n"
|
" 4. If the issue persists, report it to Ivan over another LXMF client: 7cc8d66b4f6a0e0e49d34af7f6077b5a\n",
|
||||||
)
|
)
|
||||||
out.write("=" * 70 + "\n\n")
|
out.write("=" * 70 + "\n\n")
|
||||||
out.flush()
|
out.flush()
|
||||||
@@ -115,12 +107,10 @@ class CrashRecovery:
|
|||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
def run_diagnosis(self, file=sys.stderr):
|
def run_diagnosis(self, file=sys.stderr):
|
||||||
"""
|
"""Performs a series of OS-agnostic checks on the application's environment."""
|
||||||
Performs a series of OS-agnostic checks on the application's environment.
|
|
||||||
"""
|
|
||||||
# Basic System Info
|
# Basic System Info
|
||||||
file.write(
|
file.write(
|
||||||
f"- OS: {platform.system()} {platform.release()} ({platform.machine()})\n"
|
f"- OS: {platform.system()} {platform.release()} ({platform.machine()})\n",
|
||||||
)
|
)
|
||||||
file.write(f"- Python: {sys.version.split()[0]}\n")
|
file.write(f"- Python: {sys.version.split()[0]}\n")
|
||||||
|
|
||||||
@@ -128,7 +118,7 @@ class CrashRecovery:
|
|||||||
try:
|
try:
|
||||||
mem = psutil.virtual_memory()
|
mem = psutil.virtual_memory()
|
||||||
file.write(
|
file.write(
|
||||||
f"- Memory: {mem.percent}% used ({mem.available / (1024**2):.1f} MB available)\n"
|
f"- Memory: {mem.percent}% used ({mem.available / (1024**2):.1f} MB available)\n",
|
||||||
)
|
)
|
||||||
if mem.percent > 95:
|
if mem.percent > 95:
|
||||||
file.write(" [CRITICAL] System memory is dangerously low!\n")
|
file.write(" [CRITICAL] System memory is dangerously low!\n")
|
||||||
@@ -140,12 +130,12 @@ class CrashRecovery:
|
|||||||
file.write(f"- Storage Path: {self.storage_dir}\n")
|
file.write(f"- Storage Path: {self.storage_dir}\n")
|
||||||
if not os.path.exists(self.storage_dir):
|
if not os.path.exists(self.storage_dir):
|
||||||
file.write(
|
file.write(
|
||||||
" [ERROR] Storage path does not exist. Check MESHCHAT_STORAGE_DIR.\n"
|
" [ERROR] Storage path does not exist. Check MESHCHAT_STORAGE_DIR.\n",
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
if not os.access(self.storage_dir, os.W_OK):
|
if not os.access(self.storage_dir, os.W_OK):
|
||||||
file.write(
|
file.write(
|
||||||
" [ERROR] Storage path is NOT writable. Check filesystem permissions.\n"
|
" [ERROR] Storage path is NOT writable. Check filesystem permissions.\n",
|
||||||
)
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -154,7 +144,7 @@ class CrashRecovery:
|
|||||||
file.write(f" - Disk Space: {free_mb:.1f} MB free\n")
|
file.write(f" - Disk Space: {free_mb:.1f} MB free\n")
|
||||||
if free_mb < 50:
|
if free_mb < 50:
|
||||||
file.write(
|
file.write(
|
||||||
" [CRITICAL] Disk space is critically low (< 50MB)!\n"
|
" [CRITICAL] Disk space is critically low (< 50MB)!\n",
|
||||||
)
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
@@ -165,27 +155,28 @@ class CrashRecovery:
|
|||||||
if os.path.exists(self.database_path):
|
if os.path.exists(self.database_path):
|
||||||
if os.path.getsize(self.database_path) == 0:
|
if os.path.getsize(self.database_path) == 0:
|
||||||
file.write(
|
file.write(
|
||||||
" [WARNING] Database file exists but is empty (0 bytes).\n"
|
" [WARNING] Database file exists but is empty (0 bytes).\n",
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
try:
|
try:
|
||||||
# Open in read-only mode for safety during crash handling
|
# Open in read-only mode for safety during crash handling
|
||||||
conn = sqlite3.connect(
|
conn = sqlite3.connect(
|
||||||
f"file:{self.database_path}?mode=ro", uri=True
|
f"file:{self.database_path}?mode=ro",
|
||||||
|
uri=True,
|
||||||
)
|
)
|
||||||
cursor = conn.cursor()
|
cursor = conn.cursor()
|
||||||
cursor.execute("PRAGMA integrity_check")
|
cursor.execute("PRAGMA integrity_check")
|
||||||
res = cursor.fetchone()[0]
|
res = cursor.fetchone()[0]
|
||||||
if res != "ok":
|
if res != "ok":
|
||||||
file.write(
|
file.write(
|
||||||
f" [ERROR] Database corruption detected: {res}\n"
|
f" [ERROR] Database corruption detected: {res}\n",
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
file.write(" - Integrity: OK\n")
|
file.write(" - Integrity: OK\n")
|
||||||
conn.close()
|
conn.close()
|
||||||
except sqlite3.DatabaseError as e:
|
except sqlite3.DatabaseError as e:
|
||||||
file.write(
|
file.write(
|
||||||
f" [ERROR] Database is unreadable or not a SQLite file: {e}\n"
|
f" [ERROR] Database is unreadable or not a SQLite file: {e}\n",
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
file.write(f" [ERROR] Database check failed: {e}\n")
|
file.write(f" [ERROR] Database check failed: {e}\n")
|
||||||
@@ -197,13 +188,13 @@ class CrashRecovery:
|
|||||||
file.write(f"- Frontend Assets: {self.public_dir}\n")
|
file.write(f"- Frontend Assets: {self.public_dir}\n")
|
||||||
if not os.path.exists(self.public_dir):
|
if not os.path.exists(self.public_dir):
|
||||||
file.write(
|
file.write(
|
||||||
" [ERROR] Frontend directory is missing. Web interface will fail to load.\n"
|
" [ERROR] Frontend directory is missing. Web interface will fail to load.\n",
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
index_path = os.path.join(self.public_dir, "index.html")
|
index_path = os.path.join(self.public_dir, "index.html")
|
||||||
if not os.path.exists(index_path):
|
if not os.path.exists(index_path):
|
||||||
file.write(
|
file.write(
|
||||||
" [ERROR] index.html not found in frontend directory!\n"
|
" [ERROR] index.html not found in frontend directory!\n",
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
file.write(" - Frontend Status: Assets verified\n")
|
file.write(" - Frontend Status: Assets verified\n")
|
||||||
@@ -212,9 +203,7 @@ class CrashRecovery:
|
|||||||
self.run_reticulum_diagnosis(file=file)
|
self.run_reticulum_diagnosis(file=file)
|
||||||
|
|
||||||
def run_reticulum_diagnosis(self, file=sys.stderr):
|
def run_reticulum_diagnosis(self, file=sys.stderr):
|
||||||
"""
|
"""Diagnoses the Reticulum Network Stack environment."""
|
||||||
Diagnoses the Reticulum Network Stack environment.
|
|
||||||
"""
|
|
||||||
file.write("- Reticulum Network Stack:\n")
|
file.write("- Reticulum Network Stack:\n")
|
||||||
|
|
||||||
# Check config directory
|
# Check config directory
|
||||||
@@ -231,11 +220,11 @@ class CrashRecovery:
|
|||||||
else:
|
else:
|
||||||
try:
|
try:
|
||||||
# Basic config validation
|
# Basic config validation
|
||||||
with open(config_file, "r") as f:
|
with open(config_file) as f:
|
||||||
content = f.read()
|
content = f.read()
|
||||||
if "[reticulum]" not in content:
|
if "[reticulum]" not in content:
|
||||||
file.write(
|
file.write(
|
||||||
" [ERROR] Reticulum config file is invalid (missing [reticulum] section).\n"
|
" [ERROR] Reticulum config file is invalid (missing [reticulum] section).\n",
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
file.write(" - Config File: OK\n")
|
file.write(" - Config File: OK\n")
|
||||||
@@ -255,7 +244,7 @@ class CrashRecovery:
|
|||||||
if os.path.exists(logfile):
|
if os.path.exists(logfile):
|
||||||
file.write(f" - Recent Log Entries ({logfile}):\n")
|
file.write(f" - Recent Log Entries ({logfile}):\n")
|
||||||
try:
|
try:
|
||||||
with open(logfile, "r") as f:
|
with open(logfile) as f:
|
||||||
lines = f.readlines()
|
lines = f.readlines()
|
||||||
if not lines:
|
if not lines:
|
||||||
file.write(" (Log file is empty)\n")
|
file.write(" (Log file is empty)\n")
|
||||||
@@ -283,7 +272,7 @@ class CrashRecovery:
|
|||||||
file.write(f" > {iface} [{status}]\n")
|
file.write(f" > {iface} [{status}]\n")
|
||||||
else:
|
else:
|
||||||
file.write(
|
file.write(
|
||||||
" - Active Interfaces: None registered (Reticulum may not be initialized yet)\n"
|
" - Active Interfaces: None registered (Reticulum may not be initialized yet)\n",
|
||||||
)
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
@@ -295,7 +284,7 @@ class CrashRecovery:
|
|||||||
for conn in psutil.net_connections():
|
for conn in psutil.net_connections():
|
||||||
if conn.laddr.port == port and conn.status == "LISTEN":
|
if conn.laddr.port == port and conn.status == "LISTEN":
|
||||||
file.write(
|
file.write(
|
||||||
f" [ALERT] Port {port} is already in use by PID {conn.pid}. Potential conflict.\n"
|
f" [ALERT] Port {port} is already in use by PID {conn.pid}. Potential conflict.\n",
|
||||||
)
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ class RNPathHandler:
|
|||||||
"timestamp": entry.get("timestamp"),
|
"timestamp": entry.get("timestamp"),
|
||||||
"announce_hash": announce_hash,
|
"announce_hash": announce_hash,
|
||||||
"state": state,
|
"state": state,
|
||||||
}
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
# Sort: Responsive first, then by hops, then by interface
|
# Sort: Responsive first, then by hops, then by interface
|
||||||
@@ -64,19 +64,23 @@ class RNPathHandler:
|
|||||||
0 if e["state"] == RNS.Transport.STATE_RESPONSIVE else 1,
|
0 if e["state"] == RNS.Transport.STATE_RESPONSIVE else 1,
|
||||||
e["hops"],
|
e["hops"],
|
||||||
e["interface"],
|
e["interface"],
|
||||||
)
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
total = len(formatted_table)
|
total = len(formatted_table)
|
||||||
responsive_count = len(
|
responsive_count = len(
|
||||||
[e for e in formatted_table if e["state"] == RNS.Transport.STATE_RESPONSIVE]
|
[
|
||||||
|
e
|
||||||
|
for e in formatted_table
|
||||||
|
if e["state"] == RNS.Transport.STATE_RESPONSIVE
|
||||||
|
],
|
||||||
)
|
)
|
||||||
unresponsive_count = len(
|
unresponsive_count = len(
|
||||||
[
|
[
|
||||||
e
|
e
|
||||||
for e in formatted_table
|
for e in formatted_table
|
||||||
if e["state"] == RNS.Transport.STATE_UNRESPONSIVE
|
if e["state"] == RNS.Transport.STATE_UNRESPONSIVE
|
||||||
]
|
],
|
||||||
)
|
)
|
||||||
|
|
||||||
# Pagination
|
# Pagination
|
||||||
@@ -96,17 +100,16 @@ class RNPathHandler:
|
|||||||
|
|
||||||
def get_rate_table(self):
|
def get_rate_table(self):
|
||||||
table = self.reticulum.get_rate_table()
|
table = self.reticulum.get_rate_table()
|
||||||
formatted_table = []
|
formatted_table = [
|
||||||
for entry in table:
|
{
|
||||||
formatted_table.append(
|
"hash": entry["hash"].hex(),
|
||||||
{
|
"last": entry["last"],
|
||||||
"hash": entry["hash"].hex(),
|
"timestamps": entry["timestamps"],
|
||||||
"last": entry["last"],
|
"rate_violations": entry["rate_violations"],
|
||||||
"timestamps": entry["timestamps"],
|
"blocked_until": entry["blocked_until"],
|
||||||
"rate_violations": entry["rate_violations"],
|
}
|
||||||
"blocked_until": entry["blocked_until"],
|
for entry in table
|
||||||
}
|
]
|
||||||
)
|
|
||||||
return sorted(formatted_table, key=lambda e: e["last"])
|
return sorted(formatted_table, key=lambda e: e["last"])
|
||||||
|
|
||||||
def drop_path(self, destination_hash: str) -> bool:
|
def drop_path(self, destination_hash: str) -> bool:
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import time
|
import time
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
import RNS
|
import RNS
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -41,7 +41,11 @@ class TelephoneManager:
|
|||||||
# 6: STATUS_ESTABLISHED
|
# 6: STATUS_ESTABLISHED
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self, identity: RNS.Identity, config_manager=None, storage_dir=None, db=None
|
self,
|
||||||
|
identity: RNS.Identity,
|
||||||
|
config_manager=None,
|
||||||
|
storage_dir=None,
|
||||||
|
db=None,
|
||||||
):
|
):
|
||||||
self.identity = identity
|
self.identity = identity
|
||||||
self.config_manager = config_manager
|
self.config_manager = config_manager
|
||||||
@@ -177,7 +181,8 @@ class TelephoneManager:
|
|||||||
# Pack display name in LXMF-compatible app data format
|
# Pack display name in LXMF-compatible app data format
|
||||||
app_data = msgpack.packb([display_name, None, None])
|
app_data = msgpack.packb([display_name, None, None])
|
||||||
self.telephone.destination.announce(
|
self.telephone.destination.announce(
|
||||||
app_data=app_data, attached_interface=attached_interface
|
app_data=app_data,
|
||||||
|
attached_interface=attached_interface,
|
||||||
)
|
)
|
||||||
self.telephone.last_announce = time.time()
|
self.telephone.last_announce = time.time()
|
||||||
else:
|
else:
|
||||||
@@ -190,7 +195,8 @@ class TelephoneManager:
|
|||||||
if self.on_initiation_status_callback:
|
if self.on_initiation_status_callback:
|
||||||
try:
|
try:
|
||||||
self.on_initiation_status_callback(
|
self.on_initiation_status_callback(
|
||||||
self.initiation_status, self.initiation_target_hash
|
self.initiation_status,
|
||||||
|
self.initiation_target_hash,
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
RNS.log(
|
RNS.log(
|
||||||
@@ -229,7 +235,7 @@ class TelephoneManager:
|
|||||||
if not announce:
|
if not announce:
|
||||||
# 3) By identity_hash field (if user entered identity hash but we missed recall, or other announce types)
|
# 3) By identity_hash field (if user entered identity hash but we missed recall, or other announce types)
|
||||||
announces = self.db.announces.get_filtered_announces(
|
announces = self.db.announces.get_filtered_announces(
|
||||||
identity_hash=target_hash_hex
|
identity_hash=target_hash_hex,
|
||||||
)
|
)
|
||||||
if announces:
|
if announces:
|
||||||
announce = announces[0]
|
announce = announces[0]
|
||||||
@@ -248,7 +254,7 @@ class TelephoneManager:
|
|||||||
if announce.get("identity_public_key"):
|
if announce.get("identity_public_key"):
|
||||||
try:
|
try:
|
||||||
return RNS.Identity.from_bytes(
|
return RNS.Identity.from_bytes(
|
||||||
base64.b64decode(announce["identity_public_key"])
|
base64.b64decode(announce["identity_public_key"]),
|
||||||
)
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
@@ -297,7 +303,7 @@ class TelephoneManager:
|
|||||||
# Use a thread for the blocking LXST call, but monitor status for early exit
|
# Use a thread for the blocking LXST call, but monitor status for early exit
|
||||||
# if established elsewhere or timed out/hung up
|
# if established elsewhere or timed out/hung up
|
||||||
call_task = asyncio.create_task(
|
call_task = asyncio.create_task(
|
||||||
asyncio.to_thread(self.telephone.call, destination_identity)
|
asyncio.to_thread(self.telephone.call, destination_identity),
|
||||||
)
|
)
|
||||||
|
|
||||||
start_wait = time.time()
|
start_wait = time.time()
|
||||||
@@ -340,7 +346,7 @@ class TelephoneManager:
|
|||||||
return self.telephone.active_call
|
return self.telephone.active_call
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self._update_initiation_status(f"Failed: {str(e)}")
|
self._update_initiation_status(f"Failed: {e!s}")
|
||||||
await asyncio.sleep(3)
|
await asyncio.sleep(3)
|
||||||
raise
|
raise
|
||||||
finally:
|
finally:
|
||||||
@@ -379,7 +385,8 @@ class TelephoneManager:
|
|||||||
self.telephone.audio_input.start()
|
self.telephone.audio_input.start()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
RNS.log(
|
RNS.log(
|
||||||
f"Failed to start audio input for unmute: {e}", RNS.LOG_ERROR
|
f"Failed to start audio input for unmute: {e}",
|
||||||
|
RNS.LOG_ERROR,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Still call the internal method just in case
|
# Still call the internal method just in case
|
||||||
@@ -415,7 +422,8 @@ class TelephoneManager:
|
|||||||
self.telephone.audio_output.start()
|
self.telephone.audio_output.start()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
RNS.log(
|
RNS.log(
|
||||||
f"Failed to start audio output for unmute: {e}", RNS.LOG_ERROR
|
f"Failed to start audio output for unmute: {e}",
|
||||||
|
RNS.LOG_ERROR,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Still call the internal method just in case
|
# Still call the internal method just in case
|
||||||
|
|||||||
@@ -556,7 +556,8 @@ class VoicemailManager:
|
|||||||
os.remove(filepath)
|
os.remove(filepath)
|
||||||
os.rename(temp_path, filepath)
|
os.rename(temp_path, filepath)
|
||||||
RNS.log(
|
RNS.log(
|
||||||
f"Voicemail: Fixed recording format for {filepath}", RNS.LOG_DEBUG
|
f"Voicemail: Fixed recording format for {filepath}",
|
||||||
|
RNS.LOG_DEBUG,
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
RNS.log(
|
RNS.log(
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
import json
|
import json
|
||||||
import threading
|
import threading
|
||||||
from typing import Optional
|
|
||||||
|
|
||||||
import numpy as np
|
import numpy as np
|
||||||
import RNS
|
import RNS
|
||||||
@@ -89,9 +88,9 @@ class WebAudioBridge:
|
|||||||
self.telephone_manager = telephone_manager
|
self.telephone_manager = telephone_manager
|
||||||
self.config_manager = config_manager
|
self.config_manager = config_manager
|
||||||
self.clients = set()
|
self.clients = set()
|
||||||
self.tx_source: Optional[WebAudioSource] = None
|
self.tx_source: WebAudioSource | None = None
|
||||||
self.rx_sink: Optional[WebAudioSink] = None
|
self.rx_sink: WebAudioSink | None = None
|
||||||
self.rx_tee: Optional[Tee] = None
|
self.rx_tee: Tee | None = None
|
||||||
self.loop = asyncio.get_event_loop()
|
self.loop = asyncio.get_event_loop()
|
||||||
self.lock = threading.Lock()
|
self.lock = threading.Lock()
|
||||||
|
|
||||||
@@ -137,8 +136,8 @@ class WebAudioBridge:
|
|||||||
{
|
{
|
||||||
"type": "web_audio.ready",
|
"type": "web_audio.ready",
|
||||||
"frame_ms": frame_ms,
|
"frame_ms": frame_ms,
|
||||||
}
|
},
|
||||||
)
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
def push_client_frame(self, pcm_bytes: bytes):
|
def push_client_frame(self, pcm_bytes: bytes):
|
||||||
@@ -173,7 +172,8 @@ class WebAudioBridge:
|
|||||||
tele.transmit_mixer.start()
|
tele.transmit_mixer.start()
|
||||||
except Exception as exc: # noqa: BLE001
|
except Exception as exc: # noqa: BLE001
|
||||||
RNS.log(
|
RNS.log(
|
||||||
f"WebAudioBridge: failed to swap transmit path: {exc}", RNS.LOG_ERROR
|
f"WebAudioBridge: failed to swap transmit path: {exc}",
|
||||||
|
RNS.LOG_ERROR,
|
||||||
)
|
)
|
||||||
|
|
||||||
def _ensure_rx_tee(self, tele):
|
def _ensure_rx_tee(self, tele):
|
||||||
|
|||||||
@@ -630,6 +630,7 @@ export default {
|
|||||||
|
|
||||||
// stop listening for websocket messages
|
// stop listening for websocket messages
|
||||||
WebSocketConnection.off("message", this.onWebsocketMessage);
|
WebSocketConnection.off("message", this.onWebsocketMessage);
|
||||||
|
GlobalEmitter.off("config-updated", this.onConfigUpdatedExternally);
|
||||||
},
|
},
|
||||||
mounted() {
|
mounted() {
|
||||||
// listen for websocket messages
|
// listen for websocket messages
|
||||||
@@ -650,6 +651,8 @@ export default {
|
|||||||
this.syncPropagationNode();
|
this.syncPropagationNode();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
GlobalEmitter.on("config-updated", this.onConfigUpdatedExternally);
|
||||||
|
|
||||||
GlobalEmitter.on("keyboard-shortcut", (action) => {
|
GlobalEmitter.on("keyboard-shortcut", (action) => {
|
||||||
this.handleKeyboardShortcut(action);
|
this.handleKeyboardShortcut(action);
|
||||||
});
|
});
|
||||||
@@ -691,6 +694,12 @@ export default {
|
|||||||
}, 15000);
|
}, 15000);
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
|
onConfigUpdatedExternally(newConfig) {
|
||||||
|
if (!newConfig) return;
|
||||||
|
this.config = newConfig;
|
||||||
|
GlobalState.config = newConfig;
|
||||||
|
this.displayName = newConfig.display_name;
|
||||||
|
},
|
||||||
applyThemePreference(theme) {
|
applyThemePreference(theme) {
|
||||||
const mode = theme === "dark" ? "dark" : "light";
|
const mode = theme === "dark" ? "dark" : "light";
|
||||||
if (typeof document !== "undefined") {
|
if (typeof document !== "undefined") {
|
||||||
|
|||||||
@@ -24,13 +24,23 @@
|
|||||||
<div class="p-4 border-b border-gray-200 dark:border-zinc-800">
|
<div class="p-4 border-b border-gray-200 dark:border-zinc-800">
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">Notifications</h3>
|
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">Notifications</h3>
|
||||||
<button
|
<div class="flex items-center gap-2">
|
||||||
type="button"
|
<button
|
||||||
class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors"
|
v-if="notifications.length > 0"
|
||||||
@click="closeDropdown"
|
type="button"
|
||||||
>
|
class="text-sm text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200 transition-colors"
|
||||||
<MaterialDesignIcon icon-name="close" class="w-5 h-5" />
|
@click.stop="clearAllNotifications"
|
||||||
</button>
|
>
|
||||||
|
Clear
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors"
|
||||||
|
@click="closeDropdown"
|
||||||
|
>
|
||||||
|
<MaterialDesignIcon icon-name="close" class="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -139,6 +149,7 @@ export default {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
emits: ["notifications-cleared"],
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
isDropdownOpen: false,
|
isDropdownOpen: false,
|
||||||
@@ -233,6 +244,37 @@ export default {
|
|||||||
console.error("Failed to mark notifications as viewed", e);
|
console.error("Failed to mark notifications as viewed", e);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
async clearAllNotifications() {
|
||||||
|
try {
|
||||||
|
await window.axios.post("/api/v1/notifications/mark-as-viewed", {
|
||||||
|
destination_hashes: [],
|
||||||
|
notification_ids: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await window.axios.get("/api/v1/lxmf/conversations");
|
||||||
|
const conversations = response.data.conversations || [];
|
||||||
|
|
||||||
|
for (const conversation of conversations) {
|
||||||
|
if (conversation.is_unread) {
|
||||||
|
try {
|
||||||
|
await window.axios.get(
|
||||||
|
`/api/v1/lxmf/conversations/${conversation.destination_hash}/mark-as-read`
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
console.error(`Failed to mark conversation as read: ${conversation.destination_hash}`, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const GlobalState = (await import("../js/GlobalState")).default;
|
||||||
|
GlobalState.unreadConversationsCount = 0;
|
||||||
|
|
||||||
|
await this.loadNotifications();
|
||||||
|
this.$emit("notifications-cleared");
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Failed to clear notifications", e);
|
||||||
|
}
|
||||||
|
},
|
||||||
onNotificationClick(notification) {
|
onNotificationClick(notification) {
|
||||||
this.closeDropdown();
|
this.closeDropdown();
|
||||||
if (notification.type === "lxmf_message") {
|
if (notification.type === "lxmf_message") {
|
||||||
|
|||||||
@@ -556,6 +556,48 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Auto Backups -->
|
||||||
|
<div v-if="autoBackups.length > 0" class="space-y-6">
|
||||||
|
<div class="flex flex-col md:flex-row md:items-center justify-between gap-4">
|
||||||
|
<div class="space-y-1">
|
||||||
|
<div
|
||||||
|
class="font-black text-gray-900 dark:text-white text-sm tracking-tight flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<v-icon icon="mdi-history" size="16" class="text-blue-500"></v-icon>
|
||||||
|
Automatic Backups
|
||||||
|
</div>
|
||||||
|
<div class="text-xs text-gray-500">
|
||||||
|
Automated daily snapshots of your database.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid gap-3 sm:grid-cols-2">
|
||||||
|
<div
|
||||||
|
v-for="backup in autoBackups"
|
||||||
|
:key="backup.path"
|
||||||
|
class="flex items-center justify-between p-4 rounded-2xl bg-zinc-50 dark:bg-zinc-900 border border-zinc-100 dark:border-zinc-800 hover:border-blue-500/20 transition-all group"
|
||||||
|
>
|
||||||
|
<div class="flex flex-col">
|
||||||
|
<span
|
||||||
|
class="font-black text-gray-900 dark:text-white text-xs truncate max-w-[150px]"
|
||||||
|
>{{ backup.name }}</span
|
||||||
|
>
|
||||||
|
<span class="text-[10px] font-bold text-gray-400 mt-1 tabular-nums"
|
||||||
|
>{{ formatBytes(backup.size) }} • {{ backup.created_at }}</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="secondary-chip !px-3 !py-1 !text-[10px] opacity-0 group-hover:opacity-100"
|
||||||
|
@click="restoreFromSnapshot(backup.path)"
|
||||||
|
>
|
||||||
|
Restore
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Identity Section -->
|
<!-- Identity Section -->
|
||||||
<div class="bg-red-500/5 p-6 rounded-2xl border border-red-500/10 space-y-6">
|
<div class="bg-red-500/5 p-6 rounded-2xl border border-red-500/10 space-y-6">
|
||||||
<div class="flex items-center gap-4 text-red-500">
|
<div class="flex items-center gap-4 text-red-500">
|
||||||
@@ -686,6 +728,7 @@ export default {
|
|||||||
snapshotInProgress: false,
|
snapshotInProgress: false,
|
||||||
snapshotMessage: "",
|
snapshotMessage: "",
|
||||||
snapshotError: "",
|
snapshotError: "",
|
||||||
|
autoBackups: [],
|
||||||
identityBackupMessage: "",
|
identityBackupMessage: "",
|
||||||
identityBackupError: "",
|
identityBackupError: "",
|
||||||
identityBase32: "",
|
identityBase32: "",
|
||||||
@@ -713,6 +756,7 @@ export default {
|
|||||||
this.getConfig();
|
this.getConfig();
|
||||||
this.getDatabaseHealth();
|
this.getDatabaseHealth();
|
||||||
this.listSnapshots();
|
this.listSnapshots();
|
||||||
|
this.listAutoBackups();
|
||||||
// Update stats every 5 seconds
|
// Update stats every 5 seconds
|
||||||
this.updateInterval = setInterval(() => {
|
this.updateInterval = setInterval(() => {
|
||||||
this.getAppInfo();
|
this.getAppInfo();
|
||||||
@@ -738,6 +782,14 @@ export default {
|
|||||||
console.log("Failed to list snapshots", e);
|
console.log("Failed to list snapshots", e);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
async listAutoBackups() {
|
||||||
|
try {
|
||||||
|
const response = await window.axios.get("/api/v1/database/backups");
|
||||||
|
this.autoBackups = response.data;
|
||||||
|
} catch (e) {
|
||||||
|
console.log("Failed to list auto-backups", e);
|
||||||
|
}
|
||||||
|
},
|
||||||
async createSnapshot() {
|
async createSnapshot() {
|
||||||
if (this.snapshotInProgress) return;
|
if (this.snapshotInProgress) return;
|
||||||
this.snapshotInProgress = true;
|
this.snapshotInProgress = true;
|
||||||
@@ -1071,7 +1123,7 @@ export default {
|
|||||||
const response = await window.axios.post("/api/v1/identity/restore", formData, {
|
const response = await window.axios.post("/api/v1/identity/restore", formData, {
|
||||||
headers: { "Content-Type": "multipart/form-data" },
|
headers: { "Content-Type": "multipart/form-data" },
|
||||||
});
|
});
|
||||||
this.identityRestoreMessage = response.data.message || "Identity restored. Restart app.";
|
this.identityRestoreMessage = response.data.message || "Identity imported.";
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
this.identityRestoreError = "Identity restore failed";
|
this.identityRestoreError = "Identity restore failed";
|
||||||
console.log(e);
|
console.log(e);
|
||||||
@@ -1094,7 +1146,7 @@ export default {
|
|||||||
const response = await window.axios.post("/api/v1/identity/restore", {
|
const response = await window.axios.post("/api/v1/identity/restore", {
|
||||||
base32: this.identityRestoreBase32.trim(),
|
base32: this.identityRestoreBase32.trim(),
|
||||||
});
|
});
|
||||||
this.identityRestoreMessage = response.data.message || "Identity restored. Restart app.";
|
this.identityRestoreMessage = response.data.message || "Identity imported.";
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
this.identityRestoreError = "Identity restore failed";
|
this.identityRestoreError = "Identity restore failed";
|
||||||
console.log(e);
|
console.log(e);
|
||||||
|
|||||||
@@ -516,12 +516,18 @@
|
|||||||
:label="$t('call.allow_calls_from_contacts_only')"
|
:label="$t('call.allow_calls_from_contacts_only')"
|
||||||
@update:model-value="toggleAllowCallsFromContactsOnly"
|
@update:model-value="toggleAllowCallsFromContactsOnly"
|
||||||
/>
|
/>
|
||||||
<Toggle
|
<div class="flex flex-col gap-1">
|
||||||
id="web-audio-toggle"
|
<Toggle
|
||||||
:model-value="config?.telephone_web_audio_enabled"
|
id="web-audio-toggle"
|
||||||
label="Browser/Electron Audio"
|
:model-value="config?.telephone_web_audio_enabled"
|
||||||
@update:model-value="onToggleWebAudio"
|
label="Web Audio Bridge"
|
||||||
/>
|
@update:model-value="onToggleWebAudio"
|
||||||
|
/>
|
||||||
|
<div class="text-xs text-gray-500 dark:text-zinc-400 px-1">
|
||||||
|
Web audio bridge allows web/electron to hook into LXST backend for
|
||||||
|
passing microphone and audio streams to active telephone calls.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex flex-col gap-2 shrink-0">
|
<div class="flex flex-col gap-2 shrink-0">
|
||||||
<!-- <Toggle
|
<!-- <Toggle
|
||||||
@@ -676,6 +682,7 @@
|
|||||||
<div class="relative shrink-0">
|
<div class="relative shrink-0">
|
||||||
<LxmfUserIcon
|
<LxmfUserIcon
|
||||||
:custom-image="
|
:custom-image="
|
||||||
|
entry.contact_image ||
|
||||||
getContactByHash(entry.remote_identity_hash)?.custom_image
|
getContactByHash(entry.remote_identity_hash)?.custom_image
|
||||||
"
|
"
|
||||||
:icon-name="entry.remote_icon ? entry.remote_icon.icon_name : ''"
|
:icon-name="entry.remote_icon ? entry.remote_icon.icon_name : ''"
|
||||||
@@ -845,18 +852,12 @@
|
|||||||
<div class="flex items-center space-x-4">
|
<div class="flex items-center space-x-4">
|
||||||
<div class="shrink-0">
|
<div class="shrink-0">
|
||||||
<LxmfUserIcon
|
<LxmfUserIcon
|
||||||
v-if="announce.lxmf_user_icon"
|
:custom-image="announce.contact_image"
|
||||||
:icon-name="announce.lxmf_user_icon.icon_name"
|
:icon-name="announce.lxmf_user_icon?.icon_name"
|
||||||
:icon-foreground-colour="announce.lxmf_user_icon.foreground_colour"
|
:icon-foreground-colour="announce.lxmf_user_icon?.foreground_colour"
|
||||||
:icon-background-colour="announce.lxmf_user_icon.background_colour"
|
:icon-background-colour="announce.lxmf_user_icon?.background_colour"
|
||||||
class="size-10"
|
class="size-10"
|
||||||
/>
|
/>
|
||||||
<div
|
|
||||||
v-else
|
|
||||||
class="size-10 rounded-full bg-blue-50 dark:bg-blue-900/20 text-blue-500 flex items-center justify-center font-bold shrink-0"
|
|
||||||
>
|
|
||||||
{{ (announce.display_name || "A")[0].toUpperCase() }}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="flex-1 min-w-0">
|
<div class="flex-1 min-w-0">
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
@@ -1461,16 +1462,34 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center justify-between mt-1">
|
<div class="flex items-center justify-between mt-1">
|
||||||
<span
|
<div class="flex flex-col min-w-0">
|
||||||
class="text-[10px] text-gray-500 dark:text-zinc-500 font-mono truncate cursor-pointer hover:text-blue-500 transition-colors"
|
<span
|
||||||
:title="contact.remote_identity_hash"
|
class="text-[10px] text-gray-500 dark:text-zinc-500 font-mono truncate cursor-pointer hover:text-blue-500 transition-colors"
|
||||||
@click.stop="copyHash(contact.remote_identity_hash)"
|
:title="contact.remote_identity_hash"
|
||||||
>
|
@click.stop="copyHash(contact.remote_identity_hash)"
|
||||||
{{ formatDestinationHash(contact.remote_identity_hash) }}
|
>
|
||||||
</span>
|
ID: {{ formatDestinationHash(contact.remote_identity_hash) }}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
v-if="contact.lxmf_address"
|
||||||
|
class="text-[9px] text-gray-400 dark:text-zinc-500 font-mono truncate cursor-pointer hover:text-blue-500 transition-colors"
|
||||||
|
:title="contact.lxmf_address"
|
||||||
|
@click.stop="copyHash(contact.lxmf_address)"
|
||||||
|
>
|
||||||
|
LXMF: {{ formatDestinationHash(contact.lxmf_address) }}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
v-if="contact.lxst_address"
|
||||||
|
class="text-[9px] text-gray-400 dark:text-zinc-500 font-mono truncate cursor-pointer hover:text-blue-500 transition-colors"
|
||||||
|
:title="contact.lxst_address"
|
||||||
|
@click.stop="copyHash(contact.lxst_address)"
|
||||||
|
>
|
||||||
|
LXST: {{ formatDestinationHash(contact.lxst_address) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="text-[10px] bg-blue-100 text-blue-600 dark:bg-blue-900/30 dark:text-blue-400 px-3 py-1 rounded-full font-bold uppercase tracking-wider hover:bg-blue-200 dark:hover:bg-blue-900/50 transition-colors"
|
class="text-[10px] bg-blue-100 text-blue-600 dark:bg-blue-900/30 dark:text-blue-400 px-3 py-1 rounded-full font-bold uppercase tracking-wider hover:bg-blue-200 dark:hover:bg-blue-900/50 transition-colors shrink-0"
|
||||||
@click="
|
@click="
|
||||||
destinationHash =
|
destinationHash =
|
||||||
contact.remote_telephony_hash ||
|
contact.remote_telephony_hash ||
|
||||||
@@ -2044,6 +2063,34 @@
|
|||||||
placeholder="e.g. a39610c89d18bb48c73e429582423c24"
|
placeholder="e.g. a39610c89d18bb48c73e429582423c24"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
class="block text-xs font-bold text-gray-500 dark:text-zinc-400 uppercase tracking-wider mb-1.5 ml-1"
|
||||||
|
>
|
||||||
|
{{ $t("app.lxmf_address") }}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
v-model="contactForm.lxmf_address"
|
||||||
|
type="text"
|
||||||
|
class="input-field font-mono text-xs"
|
||||||
|
placeholder="Optional"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
class="block text-xs font-bold text-gray-500 dark:text-zinc-400 uppercase tracking-wider mb-1.5 ml-1"
|
||||||
|
>
|
||||||
|
LXST Address
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
v-model="contactForm.lxst_address"
|
||||||
|
type="text"
|
||||||
|
class="input-field font-mono text-xs"
|
||||||
|
placeholder="Optional"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label
|
<label
|
||||||
class="block text-xs font-bold text-gray-500 dark:text-zinc-400 uppercase tracking-wider mb-1.5 ml-1"
|
class="block text-xs font-bold text-gray-500 dark:text-zinc-400 uppercase tracking-wider mb-1.5 ml-1"
|
||||||
@@ -2944,6 +2991,8 @@ export default {
|
|||||||
this.contactForm = {
|
this.contactForm = {
|
||||||
name: "",
|
name: "",
|
||||||
remote_identity_hash: "",
|
remote_identity_hash: "",
|
||||||
|
lxmf_address: "",
|
||||||
|
lxst_address: "",
|
||||||
preferred_ringtone_id: null,
|
preferred_ringtone_id: null,
|
||||||
custom_image: null,
|
custom_image: null,
|
||||||
};
|
};
|
||||||
@@ -2955,6 +3004,8 @@ export default {
|
|||||||
id: contact.id,
|
id: contact.id,
|
||||||
name: contact.name,
|
name: contact.name,
|
||||||
remote_identity_hash: contact.remote_identity_hash,
|
remote_identity_hash: contact.remote_identity_hash,
|
||||||
|
lxmf_address: contact.lxmf_address || "",
|
||||||
|
lxst_address: contact.lxst_address || "",
|
||||||
preferred_ringtone_id: contact.preferred_ringtone_id,
|
preferred_ringtone_id: contact.preferred_ringtone_id,
|
||||||
custom_image: contact.custom_image,
|
custom_image: contact.custom_image,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1076,6 +1076,207 @@
|
|||||||
</template>
|
</template>
|
||||||
</ExpandingSection>
|
</ExpandingSection>
|
||||||
|
|
||||||
|
<ExpandingSection>
|
||||||
|
<template #title>Interface Discovery</template>
|
||||||
|
<template #content>
|
||||||
|
<div class="p-2 space-y-3">
|
||||||
|
<div class="flex items-center">
|
||||||
|
<div class="flex flex-col mr-auto">
|
||||||
|
<FormLabel class="mb-1">Advertise this Interface</FormLabel>
|
||||||
|
<FormSubLabel>
|
||||||
|
Broadcasts connection details so peers can find and connect to this interface.
|
||||||
|
</FormSubLabel>
|
||||||
|
</div>
|
||||||
|
<Toggle v-model="discovery.discoverable" class="my-auto mx-2" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="text-sm text-gray-500 dark:text-zinc-300">
|
||||||
|
LXMF must be installed to publish discovery announces. When enabled, Reticulum handles
|
||||||
|
signing, stamping, and periodic announces for this interface.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="discovery.discoverable" class="space-y-3">
|
||||||
|
<div>
|
||||||
|
<FormLabel class="mb-1">Discovery Name</FormLabel>
|
||||||
|
<input
|
||||||
|
v-model="discovery.discovery_name"
|
||||||
|
type="text"
|
||||||
|
placeholder="Human friendly name"
|
||||||
|
class="input-field"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<FormLabel class="mb-1">Reachable On</FormLabel>
|
||||||
|
<input
|
||||||
|
v-model="discovery.reachable_on"
|
||||||
|
type="text"
|
||||||
|
placeholder="Hostname, IP, or resolver script path"
|
||||||
|
class="input-field"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<FormLabel class="mb-1">Announce Interval (minutes)</FormLabel>
|
||||||
|
<input
|
||||||
|
v-model.number="discovery.announce_interval"
|
||||||
|
type="number"
|
||||||
|
min="5"
|
||||||
|
class="input-field"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<FormLabel class="mb-1">Stamp Value</FormLabel>
|
||||||
|
<input
|
||||||
|
v-model.number="discovery.discovery_stamp_value"
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
class="input-field"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center">
|
||||||
|
<Toggle id="discovery-encrypt" v-model="discovery.discovery_encrypt" />
|
||||||
|
<FormLabel for="discovery-encrypt" class="ml-2">Encrypt Announces</FormLabel>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center">
|
||||||
|
<Toggle id="publish-ifac" v-model="discovery.publish_ifac" />
|
||||||
|
<FormLabel for="publish-ifac" class="ml-2">Include IFAC Credentials</FormLabel>
|
||||||
|
</div>
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-3 gap-3">
|
||||||
|
<div>
|
||||||
|
<FormLabel class="mb-1">Latitude</FormLabel>
|
||||||
|
<input
|
||||||
|
v-model.number="discovery.latitude"
|
||||||
|
type="number"
|
||||||
|
step="0.00001"
|
||||||
|
class="input-field"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<FormLabel class="mb-1">Longitude</FormLabel>
|
||||||
|
<input
|
||||||
|
v-model.number="discovery.longitude"
|
||||||
|
type="number"
|
||||||
|
step="0.00001"
|
||||||
|
class="input-field"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<FormLabel class="mb-1">Height (m)</FormLabel>
|
||||||
|
<input v-model.number="discovery.height" type="number" class="input-field" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-3 gap-3">
|
||||||
|
<div>
|
||||||
|
<FormLabel class="mb-1">Discovery Frequency (Hz)</FormLabel>
|
||||||
|
<input
|
||||||
|
v-model.number="discovery.discovery_frequency"
|
||||||
|
type="number"
|
||||||
|
class="input-field"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<FormLabel class="mb-1">Discovery Bandwidth (Hz)</FormLabel>
|
||||||
|
<input
|
||||||
|
v-model.number="discovery.discovery_bandwidth"
|
||||||
|
type="number"
|
||||||
|
class="input-field"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<FormLabel class="mb-1">Discovery Modulation</FormLabel>
|
||||||
|
<input
|
||||||
|
v-model="discovery.discovery_modulation"
|
||||||
|
type="text"
|
||||||
|
placeholder="e.g. LoRa"
|
||||||
|
class="input-field"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="text-xs text-gray-500 dark:text-zinc-400">
|
||||||
|
If announce encryption is enabled, a valid network identity path is required in the
|
||||||
|
Reticulum configuration.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</ExpandingSection>
|
||||||
|
|
||||||
|
<ExpandingSection>
|
||||||
|
<template #title>Discover Interfaces (Peer)</template>
|
||||||
|
<template #content>
|
||||||
|
<div class="p-2 space-y-3">
|
||||||
|
<div class="flex items-center">
|
||||||
|
<div class="flex flex-col mr-auto">
|
||||||
|
<FormLabel class="mb-1">Enable Discovery Listener</FormLabel>
|
||||||
|
<FormSubLabel>
|
||||||
|
Listen for announced interfaces and optionally auto-connect to them.
|
||||||
|
</FormSubLabel>
|
||||||
|
</div>
|
||||||
|
<Toggle v-model="reticulumDiscovery.discover_interfaces" class="my-auto mx-2" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<FormLabel class="mb-1">Allowed Sources (comma separated)</FormLabel>
|
||||||
|
<input
|
||||||
|
v-model="reticulumDiscovery.interface_discovery_sources"
|
||||||
|
type="text"
|
||||||
|
placeholder="Identity hashes"
|
||||||
|
class="input-field"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<FormLabel class="mb-1">Required Stamp Value</FormLabel>
|
||||||
|
<input
|
||||||
|
v-model.number="reticulumDiscovery.required_discovery_value"
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
class="input-field"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<FormLabel class="mb-1">Auto-connect Slots</FormLabel>
|
||||||
|
<input
|
||||||
|
v-model.number="reticulumDiscovery.autoconnect_discovered_interfaces"
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
class="input-field"
|
||||||
|
/>
|
||||||
|
<FormSubLabel>Set to 0 to disable auto-connect.</FormSubLabel>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<FormLabel class="mb-1">Network Identity Path</FormLabel>
|
||||||
|
<input
|
||||||
|
v-model="reticulumDiscovery.network_identity"
|
||||||
|
type="text"
|
||||||
|
placeholder="~/.reticulum/storage/identities/..."
|
||||||
|
class="input-field"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex justify-end">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="primary-chip text-xs"
|
||||||
|
:disabled="savingDiscovery"
|
||||||
|
@click="saveReticulumDiscoveryConfig"
|
||||||
|
>
|
||||||
|
<MaterialDesignIcon
|
||||||
|
:icon-name="savingDiscovery ? 'progress-clock' : 'content-save'"
|
||||||
|
class="w-4 h-4"
|
||||||
|
:class="{ 'animate-spin-reverse': savingDiscovery }"
|
||||||
|
/>
|
||||||
|
<span class="ml-1">Save Discovery Preferences</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</ExpandingSection>
|
||||||
|
|
||||||
<!-- add/save interface button -->
|
<!-- add/save interface button -->
|
||||||
<div class="p-2 bg-white rounded shadow divide-y divide-gray-200 dark:bg-zinc-900">
|
<div class="p-2 bg-white rounded shadow divide-y divide-gray-200 dark:bg-zinc-900">
|
||||||
<button
|
<button
|
||||||
@@ -1100,10 +1301,12 @@ import FormLabel from "../forms/FormLabel.vue";
|
|||||||
import FormSubLabel from "../forms/FormSubLabel.vue";
|
import FormSubLabel from "../forms/FormSubLabel.vue";
|
||||||
import Toggle from "../forms/Toggle.vue";
|
import Toggle from "../forms/Toggle.vue";
|
||||||
import GlobalState from "../../js/GlobalState";
|
import GlobalState from "../../js/GlobalState";
|
||||||
|
import MaterialDesignIcon from "../MaterialDesignIcon.vue";
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: "AddInterfacePage",
|
name: "AddInterfacePage",
|
||||||
components: {
|
components: {
|
||||||
|
MaterialDesignIcon,
|
||||||
FormSubLabel,
|
FormSubLabel,
|
||||||
FormLabel,
|
FormLabel,
|
||||||
ExpandingSection,
|
ExpandingSection,
|
||||||
@@ -1147,6 +1350,32 @@ export default {
|
|||||||
ifac_size: null,
|
ifac_size: null,
|
||||||
},
|
},
|
||||||
|
|
||||||
|
discovery: {
|
||||||
|
discoverable: false,
|
||||||
|
discovery_name: "",
|
||||||
|
announce_interval: 360,
|
||||||
|
reachable_on: "",
|
||||||
|
discovery_stamp_value: 14,
|
||||||
|
discovery_encrypt: false,
|
||||||
|
publish_ifac: false,
|
||||||
|
latitude: null,
|
||||||
|
longitude: null,
|
||||||
|
height: null,
|
||||||
|
discovery_frequency: null,
|
||||||
|
discovery_bandwidth: null,
|
||||||
|
discovery_modulation: null,
|
||||||
|
},
|
||||||
|
|
||||||
|
reticulumDiscovery: {
|
||||||
|
discover_interfaces: false,
|
||||||
|
interface_discovery_sources: "",
|
||||||
|
required_discovery_value: null,
|
||||||
|
autoconnect_discovered_interfaces: 0,
|
||||||
|
network_identity: "",
|
||||||
|
},
|
||||||
|
|
||||||
|
savingDiscovery: false,
|
||||||
|
|
||||||
newInterfaceForwardIp: null,
|
newInterfaceForwardIp: null,
|
||||||
newInterfaceForwardPort: null,
|
newInterfaceForwardPort: null,
|
||||||
|
|
||||||
@@ -1250,6 +1479,7 @@ export default {
|
|||||||
},
|
},
|
||||||
mounted() {
|
mounted() {
|
||||||
this.getConfig();
|
this.getConfig();
|
||||||
|
this.loadReticulumDiscoveryConfig();
|
||||||
this.loadComports();
|
this.loadComports();
|
||||||
this.loadCommunityInterfaces();
|
this.loadCommunityInterfaces();
|
||||||
|
|
||||||
@@ -1279,6 +1509,66 @@ export default {
|
|||||||
console.log(e);
|
console.log(e);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
parseBool(value) {
|
||||||
|
if (typeof value === "string") {
|
||||||
|
return ["true", "yes", "1", "y", "on"].includes(value.toLowerCase());
|
||||||
|
}
|
||||||
|
return Boolean(value);
|
||||||
|
},
|
||||||
|
async loadReticulumDiscoveryConfig() {
|
||||||
|
try {
|
||||||
|
const response = await window.axios.get(`/api/v1/reticulum/discovery`);
|
||||||
|
const discovery = response.data?.discovery ?? {};
|
||||||
|
this.reticulumDiscovery.discover_interfaces = this.parseBool(discovery.discover_interfaces);
|
||||||
|
this.reticulumDiscovery.interface_discovery_sources = discovery.interface_discovery_sources ?? "";
|
||||||
|
this.reticulumDiscovery.required_discovery_value =
|
||||||
|
discovery.required_discovery_value !== undefined &&
|
||||||
|
discovery.required_discovery_value !== null &&
|
||||||
|
discovery.required_discovery_value !== ""
|
||||||
|
? Number(discovery.required_discovery_value)
|
||||||
|
: null;
|
||||||
|
this.reticulumDiscovery.autoconnect_discovered_interfaces =
|
||||||
|
discovery.autoconnect_discovered_interfaces !== undefined &&
|
||||||
|
discovery.autoconnect_discovered_interfaces !== null &&
|
||||||
|
discovery.autoconnect_discovered_interfaces !== ""
|
||||||
|
? Number(discovery.autoconnect_discovered_interfaces)
|
||||||
|
: 0;
|
||||||
|
this.reticulumDiscovery.network_identity = discovery.network_identity ?? "";
|
||||||
|
} catch (e) {
|
||||||
|
// safe to ignore if discovery config cannot be loaded
|
||||||
|
console.log(e);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async saveReticulumDiscoveryConfig() {
|
||||||
|
if (this.savingDiscovery) return;
|
||||||
|
this.savingDiscovery = true;
|
||||||
|
try {
|
||||||
|
const payload = {
|
||||||
|
discover_interfaces: this.reticulumDiscovery.discover_interfaces,
|
||||||
|
interface_discovery_sources: this.reticulumDiscovery.interface_discovery_sources || null,
|
||||||
|
required_discovery_value:
|
||||||
|
this.reticulumDiscovery.required_discovery_value === null ||
|
||||||
|
this.reticulumDiscovery.required_discovery_value === ""
|
||||||
|
? null
|
||||||
|
: Number(this.reticulumDiscovery.required_discovery_value),
|
||||||
|
autoconnect_discovered_interfaces:
|
||||||
|
this.reticulumDiscovery.autoconnect_discovered_interfaces === null ||
|
||||||
|
this.reticulumDiscovery.autoconnect_discovered_interfaces === ""
|
||||||
|
? 0
|
||||||
|
: Number(this.reticulumDiscovery.autoconnect_discovered_interfaces),
|
||||||
|
network_identity: this.reticulumDiscovery.network_identity || null,
|
||||||
|
};
|
||||||
|
|
||||||
|
await window.axios.patch(`/api/v1/reticulum/discovery`, payload);
|
||||||
|
ToastUtils.success("Discovery settings saved");
|
||||||
|
await this.loadReticulumDiscoveryConfig();
|
||||||
|
} catch (e) {
|
||||||
|
ToastUtils.error("Failed to save discovery settings");
|
||||||
|
console.log(e);
|
||||||
|
} finally {
|
||||||
|
this.savingDiscovery = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
async loadComports() {
|
async loadComports() {
|
||||||
try {
|
try {
|
||||||
const response = await window.axios.get(`/api/v1/comports`);
|
const response = await window.axios.get(`/api/v1/comports`);
|
||||||
@@ -1408,6 +1698,22 @@ export default {
|
|||||||
this.sharedInterfaceSettings.network_name = iface.network_name;
|
this.sharedInterfaceSettings.network_name = iface.network_name;
|
||||||
this.sharedInterfaceSettings.passphrase = iface.passphrase;
|
this.sharedInterfaceSettings.passphrase = iface.passphrase;
|
||||||
this.sharedInterfaceSettings.ifac_size = iface.ifac_size;
|
this.sharedInterfaceSettings.ifac_size = iface.ifac_size;
|
||||||
|
|
||||||
|
// interface discovery
|
||||||
|
this.discovery.discoverable = this.parseBool(iface.discoverable);
|
||||||
|
this.discovery.discovery_name = iface.discovery_name ?? "";
|
||||||
|
this.discovery.announce_interval = iface.announce_interval ?? this.discovery.announce_interval;
|
||||||
|
this.discovery.reachable_on = iface.reachable_on ?? "";
|
||||||
|
this.discovery.discovery_stamp_value =
|
||||||
|
iface.discovery_stamp_value ?? this.discovery.discovery_stamp_value;
|
||||||
|
this.discovery.discovery_encrypt = this.parseBool(iface.discovery_encrypt);
|
||||||
|
this.discovery.publish_ifac = this.parseBool(iface.publish_ifac);
|
||||||
|
this.discovery.latitude = iface.latitude !== undefined ? Number(iface.latitude) : null;
|
||||||
|
this.discovery.longitude = iface.longitude !== undefined ? Number(iface.longitude) : null;
|
||||||
|
this.discovery.height = iface.height !== undefined ? Number(iface.height) : null;
|
||||||
|
this.discovery.discovery_frequency = iface.discovery_frequency ?? null;
|
||||||
|
this.discovery.discovery_bandwidth = iface.discovery_bandwidth ?? null;
|
||||||
|
this.discovery.discovery_modulation = iface.discovery_modulation ?? null;
|
||||||
} catch {
|
} catch {
|
||||||
// do nothing if failed to load interfaces
|
// do nothing if failed to load interfaces
|
||||||
}
|
}
|
||||||
@@ -1430,6 +1736,15 @@ export default {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const discoveryEnabled = this.discovery.discoverable === true;
|
||||||
|
const isRadioInterface = ["RNodeInterface", "RNodeIPInterface"].includes(this.newInterfaceType);
|
||||||
|
const fallbackDiscoveryFrequency =
|
||||||
|
this.discovery.discovery_frequency ??
|
||||||
|
(discoveryEnabled && isRadioInterface ? this.calculateFrequencyInHz() : null);
|
||||||
|
const fallbackDiscoveryBandwidth =
|
||||||
|
this.discovery.discovery_bandwidth ??
|
||||||
|
(discoveryEnabled && isRadioInterface ? this.newInterfaceBandwidth : null);
|
||||||
|
|
||||||
// add interface
|
// add interface
|
||||||
const response = await window.axios.post(`/api/v1/reticulum/interfaces/add`, {
|
const response = await window.axios.post(`/api/v1/reticulum/interfaces/add`, {
|
||||||
allow_overwriting_interface: this.isEditingInterface,
|
allow_overwriting_interface: this.isEditingInterface,
|
||||||
@@ -1506,6 +1821,32 @@ export default {
|
|||||||
airtime_limit_long: this.newInterfaceAirtimeLimitLong,
|
airtime_limit_long: this.newInterfaceAirtimeLimitLong,
|
||||||
airtime_limit_short: this.newInterfaceAirtimeLimitShort,
|
airtime_limit_short: this.newInterfaceAirtimeLimitShort,
|
||||||
|
|
||||||
|
// discovery options
|
||||||
|
discoverable: discoveryEnabled ? "yes" : null,
|
||||||
|
discovery_name: discoveryEnabled ? this.discovery.discovery_name : null,
|
||||||
|
announce_interval:
|
||||||
|
discoveryEnabled && this.discovery.announce_interval !== null
|
||||||
|
? Number(this.discovery.announce_interval)
|
||||||
|
: null,
|
||||||
|
reachable_on: discoveryEnabled ? this.discovery.reachable_on : null,
|
||||||
|
discovery_stamp_value:
|
||||||
|
discoveryEnabled && this.discovery.discovery_stamp_value !== null
|
||||||
|
? Number(this.discovery.discovery_stamp_value)
|
||||||
|
: null,
|
||||||
|
discovery_encrypt: discoveryEnabled ? this.discovery.discovery_encrypt : null,
|
||||||
|
publish_ifac: discoveryEnabled ? this.discovery.publish_ifac : null,
|
||||||
|
latitude:
|
||||||
|
discoveryEnabled && this.discovery.latitude !== null ? Number(this.discovery.latitude) : null,
|
||||||
|
longitude:
|
||||||
|
discoveryEnabled && this.discovery.longitude !== null ? Number(this.discovery.longitude) : null,
|
||||||
|
height: discoveryEnabled && this.discovery.height !== null ? Number(this.discovery.height) : null,
|
||||||
|
discovery_frequency: discoveryEnabled ? fallbackDiscoveryFrequency : null,
|
||||||
|
discovery_bandwidth: discoveryEnabled ? fallbackDiscoveryBandwidth : null,
|
||||||
|
discovery_modulation:
|
||||||
|
discoveryEnabled && this.discovery.discovery_modulation
|
||||||
|
? this.discovery.discovery_modulation
|
||||||
|
: null,
|
||||||
|
|
||||||
// settings that can be added to any interface type
|
// settings that can be added to any interface type
|
||||||
mode: this.sharedInterfaceSettings.mode || "full",
|
mode: this.sharedInterfaceSettings.mode || "full",
|
||||||
bitrate: this.sharedInterfaceSettings.bitrate,
|
bitrate: this.sharedInterfaceSettings.bitrate,
|
||||||
|
|||||||
@@ -42,6 +42,7 @@
|
|||||||
<span :class="statusChipClass">{{
|
<span :class="statusChipClass">{{
|
||||||
isInterfaceEnabled(iface) ? $t("app.enabled") : $t("app.disabled")
|
isInterfaceEnabled(iface) ? $t("app.enabled") : $t("app.disabled")
|
||||||
}}</span>
|
}}</span>
|
||||||
|
<span v-if="isDiscoverable()" class="discoverable-chip">Discoverable</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="text-sm text-gray-600 dark:text-gray-300">
|
<div class="text-sm text-gray-600 dark:text-gray-300">
|
||||||
{{ description }}
|
{{ description }}
|
||||||
@@ -244,6 +245,13 @@ export default {
|
|||||||
onIFACSignatureClick: function (ifacSignature) {
|
onIFACSignatureClick: function (ifacSignature) {
|
||||||
DialogUtils.alert(ifacSignature);
|
DialogUtils.alert(ifacSignature);
|
||||||
},
|
},
|
||||||
|
isDiscoverable() {
|
||||||
|
const value = this.iface.discoverable;
|
||||||
|
if (typeof value === "string") {
|
||||||
|
return ["true", "yes", "1", "on"].includes(value.toLowerCase());
|
||||||
|
}
|
||||||
|
return Boolean(value);
|
||||||
|
},
|
||||||
isInterfaceEnabled: function (iface) {
|
isInterfaceEnabled: function (iface) {
|
||||||
return Utils.isInterfaceEnabled(iface);
|
return Utils.isInterfaceEnabled(iface);
|
||||||
},
|
},
|
||||||
@@ -292,6 +300,9 @@ export default {
|
|||||||
.ifac-line {
|
.ifac-line {
|
||||||
@apply text-xs flex flex-wrap items-center gap-1;
|
@apply text-xs flex flex-wrap items-center gap-1;
|
||||||
}
|
}
|
||||||
|
.discoverable-chip {
|
||||||
|
@apply inline-flex items-center rounded-full bg-blue-100 text-blue-700 px-2 py-0.5 text-xs font-semibold dark:bg-blue-900/50 dark:text-blue-200;
|
||||||
|
}
|
||||||
.detail-grid {
|
.detail-grid {
|
||||||
@apply grid gap-3 sm:grid-cols-2;
|
@apply grid gap-3 sm:grid-cols-2;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -120,7 +120,115 @@
|
|||||||
<div class="text-sm">{{ $t("interfaces.no_interfaces_description") }}</div>
|
<div class="text-sm">{{ $t("interfaces.no_interfaces_description") }}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else class="grid gap-4 xl:grid-cols-2">
|
<div class="glass-card space-y-4">
|
||||||
|
<div class="flex flex-wrap gap-3 items-center">
|
||||||
|
<div class="flex-1">
|
||||||
|
<div class="text-xs uppercase tracking-wide text-gray-500 dark:text-gray-400">
|
||||||
|
Discovery
|
||||||
|
</div>
|
||||||
|
<div class="text-xl font-semibold text-gray-900 dark:text-white">Interface Discovery</div>
|
||||||
|
<div class="text-sm text-gray-600 dark:text-gray-300">
|
||||||
|
Publish your interfaces for others to find, or listen for announced entrypoints and
|
||||||
|
auto-connect to them.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<RouterLink :to="{ name: 'interfaces.add' }" class="secondary-chip text-sm">
|
||||||
|
<MaterialDesignIcon icon-name="lan" class="w-4 h-4" />
|
||||||
|
Configure Per-Interface
|
||||||
|
</RouterLink>
|
||||||
|
</div>
|
||||||
|
<div class="grid gap-4 md:grid-cols-2">
|
||||||
|
<div class="space-y-2 text-sm text-gray-700 dark:text-gray-300">
|
||||||
|
<div class="font-semibold text-gray-900 dark:text-white">Publish (Server)</div>
|
||||||
|
<div>
|
||||||
|
Enable discovery while adding or editing an interface to broadcast reachable details.
|
||||||
|
Reticulum will sign and stamp announces automatically.
|
||||||
|
</div>
|
||||||
|
<div class="text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
Requires LXMF in the Python environment. Transport is optional for publishing, but
|
||||||
|
usually recommended so peers can connect back.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-3">
|
||||||
|
<div class="flex items-center">
|
||||||
|
<div class="flex flex-col mr-auto">
|
||||||
|
<div class="text-sm font-semibold text-gray-900 dark:text-white">
|
||||||
|
Discover Interfaces (Peer)
|
||||||
|
</div>
|
||||||
|
<div class="text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
Listen for discovery announces and optionally auto-connect to available
|
||||||
|
interfaces.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Toggle v-model="discoveryConfig.discover_interfaces" class="my-auto mx-2" />
|
||||||
|
</div>
|
||||||
|
<div class="grid grid-cols-1 gap-2 sm:grid-cols-2">
|
||||||
|
<div>
|
||||||
|
<div class="text-xs font-semibold text-gray-700 dark:text-gray-200">
|
||||||
|
Allowed Sources
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
v-model="discoveryConfig.interface_discovery_sources"
|
||||||
|
type="text"
|
||||||
|
placeholder="Comma separated identity hashes"
|
||||||
|
class="input-field"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="text-xs font-semibold text-gray-700 dark:text-gray-200">
|
||||||
|
Required Stamp Value
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
v-model.number="discoveryConfig.required_discovery_value"
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
class="input-field"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="text-xs font-semibold text-gray-700 dark:text-gray-200">
|
||||||
|
Auto-connect Slots
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
v-model.number="discoveryConfig.autoconnect_discovered_interfaces"
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
class="input-field"
|
||||||
|
/>
|
||||||
|
<div class="text-xs text-gray-500 dark:text-gray-400">0 disables auto-connect.</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="text-xs font-semibold text-gray-700 dark:text-gray-200">
|
||||||
|
Network Identity Path
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
v-model="discoveryConfig.network_identity"
|
||||||
|
type="text"
|
||||||
|
placeholder="~/.reticulum/storage/identities/..."
|
||||||
|
class="input-field"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-end">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="primary-chip text-xs"
|
||||||
|
:disabled="savingDiscovery"
|
||||||
|
@click="saveDiscoveryConfig"
|
||||||
|
>
|
||||||
|
<MaterialDesignIcon
|
||||||
|
:icon-name="savingDiscovery ? 'progress-clock' : 'content-save'"
|
||||||
|
class="w-4 h-4"
|
||||||
|
:class="{ 'animate-spin-reverse': savingDiscovery }"
|
||||||
|
/>
|
||||||
|
<span class="ml-1">Save Discovery Settings</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="filteredInterfaces.length !== 0" class="grid gap-4 xl:grid-cols-2">
|
||||||
<Interface
|
<Interface
|
||||||
v-for="iface of filteredInterfaces"
|
v-for="iface of filteredInterfaces"
|
||||||
:key="iface._name"
|
:key="iface._name"
|
||||||
@@ -150,10 +258,12 @@ import DownloadUtils from "../../js/DownloadUtils";
|
|||||||
import MaterialDesignIcon from "../MaterialDesignIcon.vue";
|
import MaterialDesignIcon from "../MaterialDesignIcon.vue";
|
||||||
import ToastUtils from "../../js/ToastUtils";
|
import ToastUtils from "../../js/ToastUtils";
|
||||||
import GlobalState from "../../js/GlobalState";
|
import GlobalState from "../../js/GlobalState";
|
||||||
|
import Toggle from "../forms/Toggle.vue";
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: "InterfacesPage",
|
name: "InterfacesPage",
|
||||||
components: {
|
components: {
|
||||||
|
Toggle,
|
||||||
ImportInterfacesModal,
|
ImportInterfacesModal,
|
||||||
Interface,
|
Interface,
|
||||||
MaterialDesignIcon,
|
MaterialDesignIcon,
|
||||||
@@ -168,6 +278,14 @@ export default {
|
|||||||
typeFilter: "all",
|
typeFilter: "all",
|
||||||
reloadingRns: false,
|
reloadingRns: false,
|
||||||
isReticulumRunning: true,
|
isReticulumRunning: true,
|
||||||
|
discoveryConfig: {
|
||||||
|
discover_interfaces: false,
|
||||||
|
interface_discovery_sources: "",
|
||||||
|
required_discovery_value: null,
|
||||||
|
autoconnect_discovered_interfaces: 0,
|
||||||
|
network_identity: "",
|
||||||
|
},
|
||||||
|
savingDiscovery: false,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
@@ -246,6 +364,7 @@ export default {
|
|||||||
mounted() {
|
mounted() {
|
||||||
this.loadInterfaces();
|
this.loadInterfaces();
|
||||||
this.updateInterfaceStats();
|
this.updateInterfaceStats();
|
||||||
|
this.loadDiscoveryConfig();
|
||||||
|
|
||||||
// update info every few seconds
|
// update info every few seconds
|
||||||
this.reloadInterval = setInterval(() => {
|
this.reloadInterval = setInterval(() => {
|
||||||
@@ -387,6 +506,65 @@ export default {
|
|||||||
this.trackInterfaceChange();
|
this.trackInterfaceChange();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
parseBool(value) {
|
||||||
|
if (typeof value === "string") {
|
||||||
|
return ["true", "yes", "1", "y", "on"].includes(value.toLowerCase());
|
||||||
|
}
|
||||||
|
return Boolean(value);
|
||||||
|
},
|
||||||
|
async loadDiscoveryConfig() {
|
||||||
|
try {
|
||||||
|
const response = await window.axios.get(`/api/v1/reticulum/discovery`);
|
||||||
|
const discovery = response.data?.discovery ?? {};
|
||||||
|
this.discoveryConfig.discover_interfaces = this.parseBool(discovery.discover_interfaces);
|
||||||
|
this.discoveryConfig.interface_discovery_sources = discovery.interface_discovery_sources ?? "";
|
||||||
|
this.discoveryConfig.required_discovery_value =
|
||||||
|
discovery.required_discovery_value !== undefined &&
|
||||||
|
discovery.required_discovery_value !== null &&
|
||||||
|
discovery.required_discovery_value !== ""
|
||||||
|
? Number(discovery.required_discovery_value)
|
||||||
|
: null;
|
||||||
|
this.discoveryConfig.autoconnect_discovered_interfaces =
|
||||||
|
discovery.autoconnect_discovered_interfaces !== undefined &&
|
||||||
|
discovery.autoconnect_discovered_interfaces !== null &&
|
||||||
|
discovery.autoconnect_discovered_interfaces !== ""
|
||||||
|
? Number(discovery.autoconnect_discovered_interfaces)
|
||||||
|
: 0;
|
||||||
|
this.discoveryConfig.network_identity = discovery.network_identity ?? "";
|
||||||
|
} catch (e) {
|
||||||
|
console.log(e);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async saveDiscoveryConfig() {
|
||||||
|
if (this.savingDiscovery) return;
|
||||||
|
this.savingDiscovery = true;
|
||||||
|
try {
|
||||||
|
const payload = {
|
||||||
|
discover_interfaces: this.discoveryConfig.discover_interfaces,
|
||||||
|
interface_discovery_sources: this.discoveryConfig.interface_discovery_sources || null,
|
||||||
|
required_discovery_value:
|
||||||
|
this.discoveryConfig.required_discovery_value === null ||
|
||||||
|
this.discoveryConfig.required_discovery_value === ""
|
||||||
|
? null
|
||||||
|
: Number(this.discoveryConfig.required_discovery_value),
|
||||||
|
autoconnect_discovered_interfaces:
|
||||||
|
this.discoveryConfig.autoconnect_discovered_interfaces === null ||
|
||||||
|
this.discoveryConfig.autoconnect_discovered_interfaces === ""
|
||||||
|
? 0
|
||||||
|
: Number(this.discoveryConfig.autoconnect_discovered_interfaces),
|
||||||
|
network_identity: this.discoveryConfig.network_identity || null,
|
||||||
|
};
|
||||||
|
|
||||||
|
await window.axios.patch(`/api/v1/reticulum/discovery`, payload);
|
||||||
|
ToastUtils.success("Discovery settings saved");
|
||||||
|
await this.loadDiscoveryConfig();
|
||||||
|
} catch (e) {
|
||||||
|
ToastUtils.error("Failed to save discovery settings");
|
||||||
|
console.log(e);
|
||||||
|
} finally {
|
||||||
|
this.savingDiscovery = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
setStatusFilter(value) {
|
setStatusFilter(value) {
|
||||||
this.statusFilter = value;
|
this.statusFilter = value;
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -116,6 +116,22 @@
|
|||||||
>
|
>
|
||||||
<v-icon icon="mdi-trash-can-outline" size="18" class="sm:!size-5"></v-icon>
|
<v-icon icon="mdi-trash-can-outline" size="18" class="sm:!size-5"></v-icon>
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
v-if="selectedFeature"
|
||||||
|
class="p-1.5 sm:p-2 rounded-xl bg-blue-100 dark:bg-blue-900/30 text-blue-600 transition-all hover:scale-110 active:scale-90"
|
||||||
|
title="Edit note"
|
||||||
|
@click="startEditingNote(selectedFeature)"
|
||||||
|
>
|
||||||
|
<v-icon icon="mdi-note-edit-outline" size="18" class="sm:!size-5"></v-icon>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
v-if="selectedFeature && !selectedFeature.get('telemetry')"
|
||||||
|
class="p-1.5 sm:p-2 rounded-xl bg-red-100 dark:bg-red-900/30 text-red-600 transition-all hover:scale-110 active:scale-90 animate-pulse"
|
||||||
|
title="Delete selected item"
|
||||||
|
@click="deleteSelectedFeature"
|
||||||
|
>
|
||||||
|
<v-icon icon="mdi-selection-remove" size="18" class="sm:!size-5"></v-icon>
|
||||||
|
</button>
|
||||||
<div class="w-px h-6 bg-gray-200 dark:bg-zinc-800 my-auto mx-0.5 sm:mx-1"></div>
|
<div class="w-px h-6 bg-gray-200 dark:bg-zinc-800 my-auto mx-0.5 sm:mx-1"></div>
|
||||||
<button
|
<button
|
||||||
class="p-1.5 sm:p-2 rounded-xl hover:bg-gray-100 dark:hover:bg-zinc-800 text-gray-600 dark:text-gray-400 transition-all hover:scale-110 active:scale-90"
|
class="p-1.5 sm:p-2 rounded-xl hover:bg-gray-100 dark:hover:bg-zinc-800 text-gray-600 dark:text-gray-400 transition-all hover:scale-110 active:scale-90"
|
||||||
@@ -211,18 +227,27 @@
|
|||||||
|
|
||||||
<!-- note hover tooltip -->
|
<!-- note hover tooltip -->
|
||||||
<div
|
<div
|
||||||
v-if="hoveredNote && !editingFeature"
|
v-if="
|
||||||
|
hoveredFeature &&
|
||||||
|
(hoveredFeature.get('note') ||
|
||||||
|
(hoveredFeature.get('telemetry') && hoveredFeature.get('telemetry').note)) &&
|
||||||
|
!editingFeature
|
||||||
|
"
|
||||||
class="absolute pointer-events-none z-50 bg-white/90 dark:bg-zinc-900/90 backdrop-blur border border-gray-200 dark:border-zinc-700 rounded-lg shadow-xl p-2 text-sm text-gray-900 dark:text-zinc-100 max-w-xs transform -translate-x-1/2 -translate-y-full mb-4"
|
class="absolute pointer-events-none z-50 bg-white/90 dark:bg-zinc-900/90 backdrop-blur border border-gray-200 dark:border-zinc-700 rounded-lg shadow-xl p-2 text-sm text-gray-900 dark:text-zinc-100 max-w-xs transform -translate-x-1/2 -translate-y-full mb-4"
|
||||||
:style="{
|
:style="{
|
||||||
left: map.getPixelFromCoordinate(hoveredNote.getGeometry().getCoordinates())[0] + 'px',
|
left: map.getPixelFromCoordinate(hoveredFeature.getGeometry().getCoordinates())[0] + 'px',
|
||||||
top: map.getPixelFromCoordinate(hoveredNote.getGeometry().getCoordinates())[1] + 'px',
|
top: map.getPixelFromCoordinate(hoveredFeature.getGeometry().getCoordinates())[1] + 'px',
|
||||||
}"
|
}"
|
||||||
>
|
>
|
||||||
<div class="font-bold flex items-center gap-1 mb-1 text-amber-500">
|
<div class="font-bold flex items-center gap-1 mb-1 text-amber-500">
|
||||||
<MaterialDesignIcon icon-name="note-text" class="size-4" />
|
<MaterialDesignIcon icon-name="note-text" class="size-4" />
|
||||||
<span>Note</span>
|
<span>{{
|
||||||
|
hoveredFeature.get("telemetry") ? hoveredFeature.get("peer")?.display_name || "Peer" : "Note"
|
||||||
|
}}</span>
|
||||||
|
</div>
|
||||||
|
<div class="whitespace-pre-wrap break-words">
|
||||||
|
{{ hoveredFeature.get("note") || hoveredFeature.get("telemetry")?.note }}
|
||||||
</div>
|
</div>
|
||||||
<div class="whitespace-pre-wrap break-words">{{ hoveredNote.get("note") || "Empty note" }}</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- inline note editor (overlay) -->
|
<!-- inline note editor (overlay) -->
|
||||||
@@ -266,6 +291,58 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- context menu -->
|
||||||
|
<div
|
||||||
|
v-if="showContextMenu"
|
||||||
|
class="fixed z-[120] bg-white dark:bg-zinc-900 border border-gray-200 dark:border-zinc-800 rounded-xl shadow-2xl overflow-hidden text-sm text-gray-900 dark:text-zinc-100"
|
||||||
|
:style="{ left: contextMenuPos.x + 'px', top: contextMenuPos.y + 'px' }"
|
||||||
|
>
|
||||||
|
<div class="px-3 py-2 font-bold border-b border-gray-100 dark:border-zinc-800">
|
||||||
|
{{ contextMenuFeature ? "Feature actions" : "Map actions" }}
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-col">
|
||||||
|
<button
|
||||||
|
v-if="contextMenuFeature"
|
||||||
|
class="flex items-center gap-2 px-3 py-2 hover:bg-gray-100 dark:hover:bg-zinc-800 text-left"
|
||||||
|
@click="contextSelectFeature"
|
||||||
|
>
|
||||||
|
<MaterialDesignIcon icon-name="cursor-default" class="size-4" />
|
||||||
|
<span>Select / Move</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
v-if="contextMenuFeature"
|
||||||
|
class="flex items-center gap-2 px-3 py-2 hover:bg-gray-100 dark:hover:bg-zinc-800 text-left"
|
||||||
|
@click="contextAddNote"
|
||||||
|
>
|
||||||
|
<MaterialDesignIcon icon-name="note-edit" class="size-4" />
|
||||||
|
<span>Add / Edit Note</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
v-if="contextMenuFeature && !contextMenuFeature.get('telemetry')"
|
||||||
|
class="flex items-center gap-2 px-3 py-2 hover:bg-red-50 dark:hover:bg-red-900/20 text-left text-red-600"
|
||||||
|
@click="contextDeleteFeature"
|
||||||
|
>
|
||||||
|
<MaterialDesignIcon icon-name="delete" class="size-4" />
|
||||||
|
<span>Delete</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="flex items-center gap-2 px-3 py-2 hover:bg-gray-100 dark:hover:bg-zinc-800 text-left"
|
||||||
|
@click="contextCopyCoords"
|
||||||
|
>
|
||||||
|
<MaterialDesignIcon icon-name="crosshairs-gps" class="size-4" />
|
||||||
|
<span>Copy coords</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
v-if="!contextMenuFeature"
|
||||||
|
class="flex items-center gap-2 px-3 py-2 hover:bg-gray-100 dark:hover:bg-zinc-800 text-left"
|
||||||
|
@click="contextClearMap"
|
||||||
|
>
|
||||||
|
<MaterialDesignIcon icon-name="delete-sweep" class="size-4" />
|
||||||
|
<span>Clear drawings</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- loading skeleton for map -->
|
<!-- loading skeleton for map -->
|
||||||
<div v-if="!isMapLoaded" class="absolute inset-0 z-0 bg-slate-100 dark:bg-zinc-900 animate-pulse">
|
<div v-if="!isMapLoaded" class="absolute inset-0 z-0 bg-slate-100 dark:bg-zinc-900 animate-pulse">
|
||||||
<div class="grid grid-cols-4 grid-rows-4 h-full w-full gap-1 p-1 opacity-20">
|
<div class="grid grid-cols-4 grid-rows-4 h-full w-full gap-1 p-1 opacity-20">
|
||||||
@@ -550,11 +627,11 @@
|
|||||||
>
|
>
|
||||||
<div class="flex justify-between space-x-4">
|
<div class="flex justify-between space-x-4">
|
||||||
<span class="opacity-50 uppercase tracking-tighter">Lat</span>
|
<span class="opacity-50 uppercase tracking-tighter">Lat</span>
|
||||||
<span class="text-gray-900 dark:text-zinc-100">{{ currentCenter[1].toFixed(6) }}</span>
|
<span class="text-gray-900 dark:text-zinc-100">{{ displayCoords[1].toFixed(6) }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex justify-between space-x-4">
|
<div class="flex justify-between space-x-4">
|
||||||
<span class="opacity-50 uppercase tracking-tighter">Lon</span>
|
<span class="opacity-50 uppercase tracking-tighter">Lon</span>
|
||||||
<span class="text-gray-900 dark:text-zinc-100">{{ currentCenter[0].toFixed(6) }}</span>
|
<span class="text-gray-900 dark:text-zinc-100">{{ displayCoords[0].toFixed(6) }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -710,11 +787,11 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="flex justify-between">
|
<div class="flex justify-between">
|
||||||
<span>Lat:</span>
|
<span>Lat:</span>
|
||||||
<span class="font-mono">{{ currentCenter[1].toFixed(5) }}</span>
|
<span class="font-mono">{{ displayCoords[1].toFixed(5) }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex justify-between">
|
<div class="flex justify-between">
|
||||||
<span>Lon:</span>
|
<span>Lon:</span>
|
||||||
<span class="font-mono">{{ currentCenter[0].toFixed(5) }}</span>
|
<span class="font-mono">{{ displayCoords[0].toFixed(5) }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -967,15 +1044,18 @@ import XYZ from "ol/source/XYZ";
|
|||||||
import VectorSource from "ol/source/Vector";
|
import VectorSource from "ol/source/Vector";
|
||||||
import Feature from "ol/Feature";
|
import Feature from "ol/Feature";
|
||||||
import Point from "ol/geom/Point";
|
import Point from "ol/geom/Point";
|
||||||
import { Style, Text, Fill, Stroke, Circle as CircleStyle } from "ol/style";
|
import { Style, Text, Fill, Stroke, Circle as CircleStyle, Icon } from "ol/style";
|
||||||
import { fromLonLat, toLonLat } from "ol/proj";
|
import { fromLonLat, toLonLat } from "ol/proj";
|
||||||
import { defaults as defaultControls } from "ol/control";
|
import { defaults as defaultControls } from "ol/control";
|
||||||
import DragBox from "ol/interaction/DragBox";
|
import DragBox from "ol/interaction/DragBox";
|
||||||
import Draw from "ol/interaction/Draw";
|
import Draw from "ol/interaction/Draw";
|
||||||
import Modify from "ol/interaction/Modify";
|
import Modify from "ol/interaction/Modify";
|
||||||
import Snap from "ol/interaction/Snap";
|
import Snap from "ol/interaction/Snap";
|
||||||
|
import Select from "ol/interaction/Select";
|
||||||
|
import Translate from "ol/interaction/Translate";
|
||||||
import { getArea, getLength } from "ol/sphere";
|
import { getArea, getLength } from "ol/sphere";
|
||||||
import { LineString, Polygon } from "ol/geom";
|
import { LineString, Polygon, Circle } from "ol/geom";
|
||||||
|
import { fromCircle } from "ol/geom/Polygon";
|
||||||
import { unByKey } from "ol/Observable";
|
import { unByKey } from "ol/Observable";
|
||||||
import Overlay from "ol/Overlay";
|
import Overlay from "ol/Overlay";
|
||||||
import GeoJSON from "ol/format/GeoJSON";
|
import GeoJSON from "ol/format/GeoJSON";
|
||||||
@@ -1001,6 +1081,7 @@ export default {
|
|||||||
isSettingsOpen: false,
|
isSettingsOpen: false,
|
||||||
currentCenter: [0, 0],
|
currentCenter: [0, 0],
|
||||||
currentZoom: 2,
|
currentZoom: 2,
|
||||||
|
cursorCoords: null,
|
||||||
config: null,
|
config: null,
|
||||||
peers: {},
|
peers: {},
|
||||||
|
|
||||||
@@ -1059,8 +1140,8 @@ export default {
|
|||||||
drawType: null, // 'Point', 'LineString', 'Polygon', 'Circle' or null
|
drawType: null, // 'Point', 'LineString', 'Polygon', 'Circle' or null
|
||||||
isDrawing: false,
|
isDrawing: false,
|
||||||
drawingTools: [
|
drawingTools: [
|
||||||
|
{ type: "Select", icon: "cursor-default" },
|
||||||
{ type: "Point", icon: "map-marker-plus" },
|
{ type: "Point", icon: "map-marker-plus" },
|
||||||
{ type: "Note", icon: "note-text-outline" },
|
|
||||||
{ type: "LineString", icon: "vector-line" },
|
{ type: "LineString", icon: "vector-line" },
|
||||||
{ type: "Polygon", icon: "vector-polygon" },
|
{ type: "Polygon", icon: "vector-polygon" },
|
||||||
{ type: "Circle", icon: "circle-outline" },
|
{ type: "Circle", icon: "circle-outline" },
|
||||||
@@ -1074,6 +1155,7 @@ export default {
|
|||||||
helpTooltip: null,
|
helpTooltip: null,
|
||||||
measureTooltipElement: null,
|
measureTooltipElement: null,
|
||||||
measureTooltip: null,
|
measureTooltip: null,
|
||||||
|
measurementOverlays: [],
|
||||||
|
|
||||||
// drawing storage
|
// drawing storage
|
||||||
savedDrawings: [],
|
savedDrawings: [],
|
||||||
@@ -1081,13 +1163,22 @@ export default {
|
|||||||
// note editing
|
// note editing
|
||||||
editingFeature: null,
|
editingFeature: null,
|
||||||
noteText: "",
|
noteText: "",
|
||||||
hoveredNote: null,
|
hoveredFeature: null,
|
||||||
noteOverlay: null,
|
noteOverlay: null,
|
||||||
showNoteModal: false,
|
showNoteModal: false,
|
||||||
showSaveDrawingModal: false,
|
showSaveDrawingModal: false,
|
||||||
newDrawingName: "",
|
newDrawingName: "",
|
||||||
isLoadingDrawings: false,
|
isLoadingDrawings: false,
|
||||||
showLoadDrawingModal: false,
|
showLoadDrawingModal: false,
|
||||||
|
styleCache: {},
|
||||||
|
selectedFeature: null,
|
||||||
|
select: null,
|
||||||
|
translate: null,
|
||||||
|
// context menu
|
||||||
|
showContextMenu: false,
|
||||||
|
contextMenuPos: { x: 0, y: 0 },
|
||||||
|
contextMenuFeature: null,
|
||||||
|
contextMenuCoord: null,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
@@ -1104,6 +1195,9 @@ export default {
|
|||||||
}
|
}
|
||||||
return total;
|
return total;
|
||||||
},
|
},
|
||||||
|
displayCoords() {
|
||||||
|
return this.cursorCoords || this.currentCenter;
|
||||||
|
},
|
||||||
},
|
},
|
||||||
watch: {
|
watch: {
|
||||||
showSaveDrawingModal(val) {
|
showSaveDrawingModal(val) {
|
||||||
@@ -1148,7 +1242,9 @@ export default {
|
|||||||
dataProjection: "EPSG:4326",
|
dataProjection: "EPSG:4326",
|
||||||
featureProjection: "EPSG:3857",
|
featureProjection: "EPSG:3857",
|
||||||
});
|
});
|
||||||
|
console.log("Restoring persisted drawings, count:", features.length);
|
||||||
this.drawSource.addFeatures(features);
|
this.drawSource.addFeatures(features);
|
||||||
|
this.rebuildMeasurementOverlays();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("Failed to restore persisted drawings", e);
|
console.error("Failed to restore persisted drawings", e);
|
||||||
}
|
}
|
||||||
@@ -1201,6 +1297,19 @@ export default {
|
|||||||
}, 30000);
|
}, 30000);
|
||||||
},
|
},
|
||||||
beforeUnmount() {
|
beforeUnmount() {
|
||||||
|
if (this.map && this.map.getViewport()) {
|
||||||
|
this.map.getViewport().removeEventListener("contextmenu", this.onContextMenu);
|
||||||
|
}
|
||||||
|
document.removeEventListener("click", this.handleGlobalClick);
|
||||||
|
if (this._saveStateTimer) {
|
||||||
|
clearTimeout(this._saveStateTimer);
|
||||||
|
this._saveStateTimer = null;
|
||||||
|
}
|
||||||
|
if (this._pendingSaveResolvers && this._pendingSaveResolvers.length > 0) {
|
||||||
|
const pending = this._pendingSaveResolvers.slice();
|
||||||
|
this._pendingSaveResolvers = [];
|
||||||
|
this.saveMapStateImmediate().then(() => pending.forEach((p) => p.resolve()));
|
||||||
|
}
|
||||||
if (this.reloadInterval) clearInterval(this.reloadInterval);
|
if (this.reloadInterval) clearInterval(this.reloadInterval);
|
||||||
if (this.exportInterval) clearInterval(this.exportInterval);
|
if (this.exportInterval) clearInterval(this.exportInterval);
|
||||||
if (this.searchTimeout) clearTimeout(this.searchTimeout);
|
if (this.searchTimeout) clearTimeout(this.searchTimeout);
|
||||||
@@ -1209,17 +1318,37 @@ export default {
|
|||||||
WebSocketConnection.off("message", this.onWebsocketMessage);
|
WebSocketConnection.off("message", this.onWebsocketMessage);
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
async saveMapState() {
|
saveMapState() {
|
||||||
|
if (!this._pendingSaveResolvers) {
|
||||||
|
this._pendingSaveResolvers = [];
|
||||||
|
}
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
this._pendingSaveResolvers.push({ resolve, reject });
|
||||||
|
if (this._saveStateTimer) clearTimeout(this._saveStateTimer);
|
||||||
|
this._saveStateTimer = setTimeout(async () => {
|
||||||
|
const pending = this._pendingSaveResolvers.slice();
|
||||||
|
this._pendingSaveResolvers = [];
|
||||||
|
this._saveStateTimer = null;
|
||||||
|
try {
|
||||||
|
await this.saveMapStateImmediate();
|
||||||
|
pending.forEach((p) => p.resolve());
|
||||||
|
} catch (e) {
|
||||||
|
pending.forEach((p) => p.reject(e));
|
||||||
|
}
|
||||||
|
}, 150);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
async saveMapStateImmediate() {
|
||||||
try {
|
try {
|
||||||
// Serialize drawings
|
|
||||||
let drawings = null;
|
let drawings = null;
|
||||||
if (this.drawSource) {
|
if (this.drawSource) {
|
||||||
const format = new GeoJSON();
|
const format = new GeoJSON();
|
||||||
drawings = format.writeFeatures(this.drawSource.getFeatures());
|
const features = this.serializeFeatures(this.drawSource.getFeatures());
|
||||||
|
drawings = format.writeFeatures(features, {
|
||||||
|
dataProjection: "EPSG:4326",
|
||||||
|
featureProjection: "EPSG:3857",
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use JSON.parse/stringify to strip Vue Proxies and ensure plain objects/arrays
|
|
||||||
// This prevents DataCloneError when saving to IndexedDB
|
|
||||||
const state = JSON.parse(
|
const state = JSON.parse(
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
center: this.currentCenter,
|
center: this.currentCenter,
|
||||||
@@ -1231,6 +1360,7 @@ export default {
|
|||||||
})
|
})
|
||||||
);
|
);
|
||||||
await TileCache.setMapState("last_view", state);
|
await TileCache.setMapState("last_view", state);
|
||||||
|
console.log("Map state persisted to cache, drawings size:", drawings ? drawings.length : 0);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("Failed to save map state", e);
|
console.error("Failed to save map state", e);
|
||||||
}
|
}
|
||||||
@@ -1340,20 +1470,19 @@ export default {
|
|||||||
source: this.drawSource,
|
source: this.drawSource,
|
||||||
style: (feature) => {
|
style: (feature) => {
|
||||||
const type = feature.get("type");
|
const type = feature.get("type");
|
||||||
if (type === "note") {
|
const geometry = feature.getGeometry();
|
||||||
return new Style({
|
const geomType = geometry ? geometry.getType() : null;
|
||||||
image: new CircleStyle({
|
|
||||||
radius: 10,
|
if (type === "note" || geomType === "Point") {
|
||||||
fill: new Fill({
|
const isNote = type === "note";
|
||||||
color: "#f59e0b",
|
return this.createMarkerStyle({
|
||||||
}),
|
iconColor: isNote ? "#f59e0b" : "#3b82f6",
|
||||||
stroke: new Stroke({
|
bgColor: "#ffffff",
|
||||||
color: "#ffffff",
|
label: isNote && feature.get("note") ? "Note" : "",
|
||||||
width: 2,
|
isStale: false,
|
||||||
}),
|
iconPath: isNote
|
||||||
}),
|
? "M14 2H6c-1.1 0-2 .9-2 2v16c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V8l-6-6zM6 20V4h7v5h5v11H6z"
|
||||||
// Use a simple circle for now as custom fonts in canvas can be tricky
|
: null,
|
||||||
// or use the built-in Text style if we are sure it works
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return new Style({
|
return new Style({
|
||||||
@@ -1375,6 +1504,7 @@ export default {
|
|||||||
zIndex: 50,
|
zIndex: 50,
|
||||||
});
|
});
|
||||||
this.map.addLayer(this.drawLayer);
|
this.map.addLayer(this.drawLayer);
|
||||||
|
this.attachDrawPersistence();
|
||||||
|
|
||||||
this.noteOverlay = new Overlay({
|
this.noteOverlay = new Overlay({
|
||||||
element: this.$refs.noteOverlayElement,
|
element: this.$refs.noteOverlayElement,
|
||||||
@@ -1387,16 +1517,83 @@ export default {
|
|||||||
this.map.addOverlay(this.noteOverlay);
|
this.map.addOverlay(this.noteOverlay);
|
||||||
|
|
||||||
this.modify = new Modify({ source: this.drawSource });
|
this.modify = new Modify({ source: this.drawSource });
|
||||||
this.modify.on("modifyend", () => this.saveMapState());
|
this.modify.on("modifystart", (e) => {
|
||||||
|
const feats = (e.features && e.features.getArray()) || this.select.getFeatures().getArray();
|
||||||
|
feats.forEach((f) => this.clearMeasurementOverlay(f));
|
||||||
|
});
|
||||||
|
this.modify.on("modifyend", (e) => {
|
||||||
|
const feats = (e.features && e.features.getArray()) || this.select.getFeatures().getArray();
|
||||||
|
feats.forEach((f) => this.finalizeMeasurementOverlay(f));
|
||||||
|
this.saveMapState();
|
||||||
|
});
|
||||||
this.map.addInteraction(this.modify);
|
this.map.addInteraction(this.modify);
|
||||||
|
|
||||||
|
this.select = new Select({
|
||||||
|
layers: [this.drawLayer],
|
||||||
|
hitTolerance: 15, // High tolerance for touch/offgrid
|
||||||
|
style: null, // Keep original feature style
|
||||||
|
});
|
||||||
|
this.select.on("select", (e) => {
|
||||||
|
this.selectedFeature = e.selected[0] || null;
|
||||||
|
});
|
||||||
|
this.map.addInteraction(this.select);
|
||||||
|
|
||||||
|
this.translate = new Translate({
|
||||||
|
features: this.select.getFeatures(),
|
||||||
|
layers: [this.drawLayer], // Only move drawing layer items, not telemetry
|
||||||
|
});
|
||||||
|
this.translate.on("translateend", (e) => {
|
||||||
|
const feats = (e.features && e.features.getArray()) || this.select.getFeatures().getArray();
|
||||||
|
feats.forEach((f) => this.finalizeMeasurementOverlay(f));
|
||||||
|
this.saveMapState();
|
||||||
|
});
|
||||||
|
this.map.addInteraction(this.translate);
|
||||||
|
|
||||||
|
// Default to Select tool
|
||||||
|
this.drawType = "Select";
|
||||||
|
this.select.setActive(true);
|
||||||
|
this.translate.setActive(true);
|
||||||
|
this.modify.setActive(true);
|
||||||
|
|
||||||
this.snap = new Snap({ source: this.drawSource });
|
this.snap = new Snap({ source: this.drawSource });
|
||||||
this.map.addInteraction(this.snap);
|
this.map.addInteraction(this.snap);
|
||||||
|
|
||||||
|
// Right-click context menu
|
||||||
|
this.map.getViewport().addEventListener("contextmenu", this.onContextMenu);
|
||||||
|
|
||||||
// setup telemetry markers
|
// setup telemetry markers
|
||||||
this.markerSource = new VectorSource();
|
this.markerSource = new VectorSource();
|
||||||
this.markerLayer = new VectorLayer({
|
this.markerLayer = new VectorLayer({
|
||||||
source: this.markerSource,
|
source: this.markerSource,
|
||||||
|
style: (feature) => {
|
||||||
|
const t = feature.get("telemetry");
|
||||||
|
const peer = feature.get("peer");
|
||||||
|
const displayName = peer?.display_name || t.destination_hash.substring(0, 8);
|
||||||
|
|
||||||
|
// Calculate staleness
|
||||||
|
const now = Date.now();
|
||||||
|
const updatedAt = t.updated_at
|
||||||
|
? new Date(t.updated_at).getTime()
|
||||||
|
: t.timestamp
|
||||||
|
? t.timestamp * 1000
|
||||||
|
: now;
|
||||||
|
const isStale = now - updatedAt > 10 * 60 * 1000;
|
||||||
|
|
||||||
|
let iconColor = "#2563eb";
|
||||||
|
let bgColor = "#ffffff";
|
||||||
|
|
||||||
|
if (peer?.lxmf_user_icon) {
|
||||||
|
iconColor = peer.lxmf_user_icon.foreground_colour || iconColor;
|
||||||
|
bgColor = peer.lxmf_user_icon.background_colour || bgColor;
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.createMarkerStyle({
|
||||||
|
iconColor,
|
||||||
|
bgColor,
|
||||||
|
label: displayName,
|
||||||
|
isStale,
|
||||||
|
});
|
||||||
|
},
|
||||||
zIndex: 100,
|
zIndex: 100,
|
||||||
});
|
});
|
||||||
this.map.addLayer(this.markerLayer);
|
this.map.addLayer(this.markerLayer);
|
||||||
@@ -1404,12 +1601,19 @@ export default {
|
|||||||
this.map.on("pointermove", this.handleMapPointerMove);
|
this.map.on("pointermove", this.handleMapPointerMove);
|
||||||
this.map.on("click", (evt) => {
|
this.map.on("click", (evt) => {
|
||||||
this.handleMapClick(evt);
|
this.handleMapClick(evt);
|
||||||
|
this.closeContextMenu();
|
||||||
const feature = this.map.forEachFeatureAtPixel(evt.pixel, (f) => f);
|
const feature = this.map.forEachFeatureAtPixel(evt.pixel, (f) => f);
|
||||||
if (feature && feature.get("telemetry")) {
|
if (feature && feature.get("telemetry")) {
|
||||||
this.onMarkerClick(feature);
|
this.onMarkerClick(feature);
|
||||||
} else {
|
} else {
|
||||||
this.selectedMarker = null;
|
this.selectedMarker = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Deselect drawing if clicking empty space
|
||||||
|
if (!feature && this.select) {
|
||||||
|
this.select.getFeatures().clear();
|
||||||
|
this.selectedFeature = null;
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
this.currentCenter = [defaultLon, defaultLat];
|
this.currentCenter = [defaultLon, defaultLat];
|
||||||
@@ -1431,6 +1635,9 @@ export default {
|
|||||||
|
|
||||||
this.map.addInteraction(this.dragBox);
|
this.map.addInteraction(this.dragBox);
|
||||||
this.isMapLoaded = true;
|
this.isMapLoaded = true;
|
||||||
|
|
||||||
|
// Close context menu when clicking elsewhere
|
||||||
|
document.addEventListener("click", this.handleGlobalClick);
|
||||||
},
|
},
|
||||||
isLocalUrl(url) {
|
isLocalUrl(url) {
|
||||||
if (!url) return false;
|
if (!url) return false;
|
||||||
@@ -2057,10 +2264,29 @@ export default {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
attachDrawPersistence() {
|
||||||
|
if (!this.drawSource) return;
|
||||||
|
const persist = () => this.saveMapState();
|
||||||
|
this.drawSource.on("addfeature", persist);
|
||||||
|
this.drawSource.on("removefeature", persist);
|
||||||
|
this.drawSource.on("changefeature", persist);
|
||||||
|
this.drawSource.on("clear", persist);
|
||||||
|
},
|
||||||
|
|
||||||
|
deleteSelectedFeature() {
|
||||||
|
if (this.selectedFeature && this.drawSource) {
|
||||||
|
this.clearMeasurementOverlay(this.selectedFeature);
|
||||||
|
this.drawSource.removeFeature(this.selectedFeature);
|
||||||
|
if (this.select) this.select.getFeatures().clear();
|
||||||
|
this.selectedFeature = null;
|
||||||
|
this.saveMapState();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
// Drawing methods
|
// Drawing methods
|
||||||
toggleDraw(type) {
|
toggleDraw(type) {
|
||||||
if (!this.map) return;
|
if (!this.map) return;
|
||||||
if (this.drawType === type && !this.isMeasuring) {
|
if (this.drawType === type && !this.isDrawing) {
|
||||||
this.stopDrawing();
|
this.stopDrawing();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -2069,27 +2295,79 @@ export default {
|
|||||||
this.isMeasuring = false;
|
this.isMeasuring = false;
|
||||||
this.drawType = type;
|
this.drawType = type;
|
||||||
|
|
||||||
|
if (type === "Select") {
|
||||||
|
if (this.select) this.select.setActive(true);
|
||||||
|
if (this.translate) this.translate.setActive(true);
|
||||||
|
if (this.modify) this.modify.setActive(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Disable selection/translation while drawing
|
||||||
|
if (this.select) this.select.setActive(false);
|
||||||
|
if (this.translate) this.translate.setActive(false);
|
||||||
|
if (this.modify) this.modify.setActive(false);
|
||||||
|
|
||||||
this.draw = new Draw({
|
this.draw = new Draw({
|
||||||
source: this.drawSource,
|
source: this.drawSource,
|
||||||
type: type === "Note" ? "Point" : type,
|
type: type,
|
||||||
});
|
});
|
||||||
|
|
||||||
this.draw.on("drawstart", () => {
|
this.draw.on("drawstart", (evt) => {
|
||||||
this.isDrawing = true;
|
this.isDrawing = true;
|
||||||
|
this.sketch = evt.feature;
|
||||||
|
|
||||||
|
// For LineString, Polygon, and Circle, show measure tooltip while drawing
|
||||||
|
if (type === "LineString" || type === "Polygon" || type === "Circle") {
|
||||||
|
this.createMeasureTooltip();
|
||||||
|
this._drawListener = this.sketch.getGeometry().on("change", (e) => {
|
||||||
|
const geom = e.target;
|
||||||
|
let output;
|
||||||
|
let tooltipCoord;
|
||||||
|
if (geom instanceof Polygon) {
|
||||||
|
output = this.formatArea(geom);
|
||||||
|
tooltipCoord = geom.getInteriorPoint().getCoordinates();
|
||||||
|
} else if (geom instanceof LineString) {
|
||||||
|
output = this.formatLength(geom);
|
||||||
|
tooltipCoord = geom.getLastCoordinate();
|
||||||
|
} else if (geom instanceof Circle) {
|
||||||
|
const radius = geom.getRadius();
|
||||||
|
const center = geom.getCenter();
|
||||||
|
// Calculate radius distance in projection (sphere-aware)
|
||||||
|
const edge = [center[0] + radius, center[1]];
|
||||||
|
const line = new LineString([center, edge]);
|
||||||
|
output = `Radius: ${this.formatLength(line)}`;
|
||||||
|
tooltipCoord = edge;
|
||||||
|
}
|
||||||
|
if (output) {
|
||||||
|
this.measureTooltipElement.innerHTML = output;
|
||||||
|
this.measureTooltip.setPosition(tooltipCoord);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
this.draw.on("drawend", (evt) => {
|
this.draw.on("drawend", (evt) => {
|
||||||
this.isDrawing = false;
|
this.isDrawing = false;
|
||||||
const feature = evt.feature;
|
const feature = evt.feature;
|
||||||
if (type === "Note") {
|
feature.set("type", "draw"); // Tag as custom drawing for styling
|
||||||
feature.set("type", "note");
|
|
||||||
feature.set("note", "");
|
// Clean up sketch listener and tooltips unless it was the Measure tool
|
||||||
// Open edit box after a short delay to let the feature settle
|
if (this._drawListener) {
|
||||||
setTimeout(() => {
|
unByKey(this._drawListener);
|
||||||
this.startEditingNote(feature);
|
this._drawListener = null;
|
||||||
}, 200);
|
|
||||||
}
|
}
|
||||||
// Use setTimeout to ensure the feature is actually in the source before saving
|
this.sketch = null;
|
||||||
|
|
||||||
|
// Finalize measurement overlay for the drawn feature
|
||||||
|
this.finalizeMeasurementOverlay(feature);
|
||||||
|
this.cleanupMeasureTooltip();
|
||||||
|
|
||||||
|
// Re-enable select/translate/modify after drawing
|
||||||
|
if (this.select) this.select.setActive(true);
|
||||||
|
if (this.translate) this.translate.setActive(true);
|
||||||
|
if (this.modify) this.modify.setActive(true);
|
||||||
|
this.drawType = "Select";
|
||||||
|
|
||||||
setTimeout(() => this.saveMapState(), 100);
|
setTimeout(() => this.saveMapState(), 100);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -2098,7 +2376,8 @@ export default {
|
|||||||
|
|
||||||
startEditingNote(feature) {
|
startEditingNote(feature) {
|
||||||
this.editingFeature = feature;
|
this.editingFeature = feature;
|
||||||
this.noteText = feature.get("note") || "";
|
const telemetry = feature.get("telemetry");
|
||||||
|
this.noteText = telemetry ? telemetry.note || "" : feature.get("note") || "";
|
||||||
if (this.isMobileScreen) {
|
if (this.isMobileScreen) {
|
||||||
this.showNoteModal = true;
|
this.showNoteModal = true;
|
||||||
} else {
|
} else {
|
||||||
@@ -2109,13 +2388,29 @@ export default {
|
|||||||
updateNoteOverlay() {
|
updateNoteOverlay() {
|
||||||
if (!this.editingFeature || !this.map) return;
|
if (!this.editingFeature || !this.map) return;
|
||||||
const geometry = this.editingFeature.getGeometry();
|
const geometry = this.editingFeature.getGeometry();
|
||||||
const coord = geometry.getCoordinates();
|
let coord;
|
||||||
|
if (geometry instanceof Point) {
|
||||||
|
coord = geometry.getCoordinates();
|
||||||
|
} else if (geometry instanceof LineString) {
|
||||||
|
coord = geometry.getCoordinateAt(0.5); // Middle of line
|
||||||
|
} else if (geometry instanceof Polygon) {
|
||||||
|
coord = geometry.getInteriorPoint().getCoordinates();
|
||||||
|
} else if (geometry instanceof Circle) {
|
||||||
|
coord = geometry.getCenter();
|
||||||
|
} else {
|
||||||
|
coord = this.map.getView().getCenter();
|
||||||
|
}
|
||||||
this.noteOverlay.setPosition(coord);
|
this.noteOverlay.setPosition(coord);
|
||||||
},
|
},
|
||||||
|
|
||||||
saveNote() {
|
saveNote() {
|
||||||
if (this.editingFeature) {
|
if (this.editingFeature) {
|
||||||
this.editingFeature.set("note", this.noteText);
|
const telemetry = this.editingFeature.get("telemetry");
|
||||||
|
if (telemetry) {
|
||||||
|
telemetry.note = this.noteText;
|
||||||
|
} else {
|
||||||
|
this.editingFeature.set("note", this.noteText);
|
||||||
|
}
|
||||||
this.saveMapState();
|
this.saveMapState();
|
||||||
}
|
}
|
||||||
this.closeNoteEditor();
|
this.closeNoteEditor();
|
||||||
@@ -2144,19 +2439,190 @@ export default {
|
|||||||
this.closeNoteEditor();
|
this.closeNoteEditor();
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Measurement helpers
|
||||||
|
cleanupMeasureTooltip() {
|
||||||
|
if (this.measureTooltipElement && this.measureTooltipElement.parentNode) {
|
||||||
|
this.measureTooltipElement.parentNode.removeChild(this.measureTooltipElement);
|
||||||
|
}
|
||||||
|
if (this.measureTooltip) {
|
||||||
|
this.map.removeOverlay(this.measureTooltip);
|
||||||
|
}
|
||||||
|
this.measureTooltipElement = null;
|
||||||
|
this.measureTooltip = null;
|
||||||
|
},
|
||||||
|
getMeasurementForGeometry(geom) {
|
||||||
|
if (geom instanceof Polygon) {
|
||||||
|
return {
|
||||||
|
text: this.formatArea(geom),
|
||||||
|
coord: geom.getInteriorPoint().getCoordinates(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (geom instanceof LineString) {
|
||||||
|
return {
|
||||||
|
text: this.formatLength(geom),
|
||||||
|
coord: geom.getLastCoordinate(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (geom instanceof Circle) {
|
||||||
|
const center = geom.getCenter();
|
||||||
|
const edge = [center[0] + geom.getRadius(), center[1]];
|
||||||
|
const line = new LineString([center, edge]);
|
||||||
|
return {
|
||||||
|
text: `Radius: ${this.formatLength(line)}`,
|
||||||
|
coord: edge,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
clearMeasurementOverlay(feature) {
|
||||||
|
const overlay = feature.get("_measureOverlay");
|
||||||
|
if (overlay) {
|
||||||
|
this.map.removeOverlay(overlay);
|
||||||
|
feature.unset("_measureOverlay", true);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
finalizeMeasurementOverlay(feature) {
|
||||||
|
if (!this.map) return;
|
||||||
|
this.clearMeasurementOverlay(feature);
|
||||||
|
const geom = feature.getGeometry();
|
||||||
|
const measurement = this.getMeasurementForGeometry(geom);
|
||||||
|
if (!measurement) return;
|
||||||
|
const el = document.createElement("div");
|
||||||
|
el.className = "ol-tooltip ol-tooltip-static";
|
||||||
|
el.innerHTML = measurement.text;
|
||||||
|
const overlay = new Overlay({
|
||||||
|
element: el,
|
||||||
|
offset: [0, -7],
|
||||||
|
positioning: "bottom-center",
|
||||||
|
});
|
||||||
|
overlay.set("isMeasureTooltip", true);
|
||||||
|
this.map.addOverlay(overlay);
|
||||||
|
overlay.setPosition(measurement.coord);
|
||||||
|
feature.set("_measureOverlay", overlay);
|
||||||
|
},
|
||||||
|
rebuildMeasurementOverlays() {
|
||||||
|
if (!this.drawSource || !this.map) return;
|
||||||
|
// Remove all existing measure overlays
|
||||||
|
const overlays = this.map.getOverlays().getArray();
|
||||||
|
for (let i = overlays.length - 1; i >= 0; i--) {
|
||||||
|
const ov = overlays[i];
|
||||||
|
if (ov.get && ov.get("isMeasureTooltip")) {
|
||||||
|
this.map.removeOverlay(ov);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Rebuild for all features
|
||||||
|
this.drawSource.getFeatures().forEach((f) => {
|
||||||
|
f.unset("_measureOverlay", true);
|
||||||
|
this.finalizeMeasurementOverlay(f);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
serializeFeatures(features) {
|
||||||
|
return features.map((f) => {
|
||||||
|
const clone = f.clone();
|
||||||
|
clone.unset("_measureOverlay", true); // avoid circular refs
|
||||||
|
const geom = clone.getGeometry();
|
||||||
|
if (geom instanceof Circle) {
|
||||||
|
clone.setGeometry(fromCircle(geom, 128));
|
||||||
|
}
|
||||||
|
return clone;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
// Context menu handlers
|
||||||
|
onContextMenu(evt) {
|
||||||
|
if (!this.map) return;
|
||||||
|
evt.preventDefault();
|
||||||
|
const pixel = this.map.getEventPixel(evt);
|
||||||
|
const feature = this.map.forEachFeatureAtPixel(pixel, (f) => f);
|
||||||
|
this.contextMenuFeature = feature || null;
|
||||||
|
this.contextMenuCoord = toLonLat(this.map.getCoordinateFromPixel(pixel));
|
||||||
|
this.contextMenuPos = { x: evt.clientX, y: evt.clientY };
|
||||||
|
if (feature && this.select) {
|
||||||
|
this.select.getFeatures().clear();
|
||||||
|
this.select.getFeatures().push(feature);
|
||||||
|
this.selectedFeature = feature;
|
||||||
|
}
|
||||||
|
this.showContextMenu = true;
|
||||||
|
},
|
||||||
|
closeContextMenu() {
|
||||||
|
this.showContextMenu = false;
|
||||||
|
},
|
||||||
|
contextSelectFeature() {
|
||||||
|
if (!this.contextMenuFeature || !this.select || !this.translate) {
|
||||||
|
this.closeContextMenu();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.select.setActive(true);
|
||||||
|
this.translate.setActive(true);
|
||||||
|
this.modify?.setActive(true);
|
||||||
|
this.select.getFeatures().clear();
|
||||||
|
this.select.getFeatures().push(this.contextMenuFeature);
|
||||||
|
this.selectedFeature = this.contextMenuFeature;
|
||||||
|
this.drawType = "Select";
|
||||||
|
this.closeContextMenu();
|
||||||
|
},
|
||||||
|
contextDeleteFeature() {
|
||||||
|
if (this.contextMenuFeature && !this.contextMenuFeature.get("telemetry")) {
|
||||||
|
this.drawSource.removeFeature(this.contextMenuFeature);
|
||||||
|
this.saveMapState();
|
||||||
|
}
|
||||||
|
this.closeContextMenu();
|
||||||
|
},
|
||||||
|
contextAddNote() {
|
||||||
|
if (this.contextMenuFeature) {
|
||||||
|
this.startEditingNote(this.contextMenuFeature);
|
||||||
|
}
|
||||||
|
this.closeContextMenu();
|
||||||
|
},
|
||||||
|
async contextCopyCoords() {
|
||||||
|
if (!this.contextMenuCoord) {
|
||||||
|
this.closeContextMenu();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const [lon, lat] = this.contextMenuCoord;
|
||||||
|
const text = `${lat.toFixed(6)}, ${lon.toFixed(6)}`;
|
||||||
|
try {
|
||||||
|
if (navigator?.clipboard?.writeText) {
|
||||||
|
await navigator.clipboard.writeText(text);
|
||||||
|
ToastUtils.success("Copied coordinates");
|
||||||
|
} else {
|
||||||
|
ToastUtils.success(text);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Copy failed", e);
|
||||||
|
ToastUtils.warning(text);
|
||||||
|
}
|
||||||
|
this.closeContextMenu();
|
||||||
|
},
|
||||||
|
contextClearMap() {
|
||||||
|
this.clearDrawings();
|
||||||
|
this.closeContextMenu();
|
||||||
|
},
|
||||||
|
// Clear all overlays on escape/context close
|
||||||
|
handleGlobalClick() {
|
||||||
|
if (this.showContextMenu) {
|
||||||
|
this.closeContextMenu();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
handleMapPointerMove(evt) {
|
handleMapPointerMove(evt) {
|
||||||
|
if (!this.map) return;
|
||||||
|
const lonLat = toLonLat(evt.coordinate);
|
||||||
|
this.cursorCoords = [lonLat[0], lonLat[1]];
|
||||||
if (evt.dragging || this.isDrawing || this.isMeasuring) return;
|
if (evt.dragging || this.isDrawing || this.isMeasuring) return;
|
||||||
|
|
||||||
const pixel = this.map.getEventPixel(evt.originalEvent);
|
const pixel = this.map.getEventPixel(evt.originalEvent);
|
||||||
const feature = this.map.forEachFeatureAtPixel(pixel, (f) => f, {
|
const feature = this.map.forEachFeatureAtPixel(pixel, (f) => f);
|
||||||
layerFilter: (l) => l === this.drawLayer,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (feature && feature.get("type") === "note") {
|
if (feature) {
|
||||||
this.hoveredNote = feature;
|
const hasNote = feature.get("note") || (feature.get("telemetry") && feature.get("telemetry").note);
|
||||||
|
if (hasNote) {
|
||||||
|
this.hoveredFeature = feature;
|
||||||
|
} else {
|
||||||
|
this.hoveredFeature = null;
|
||||||
|
}
|
||||||
this.map.getTargetElement().style.cursor = "pointer";
|
this.map.getTargetElement().style.cursor = "pointer";
|
||||||
} else {
|
} else {
|
||||||
this.hoveredNote = null;
|
this.hoveredFeature = null;
|
||||||
this.map.getTargetElement().style.cursor = "";
|
this.map.getTargetElement().style.cursor = "";
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -2181,6 +2647,9 @@ export default {
|
|||||||
this.map.removeInteraction(this.draw);
|
this.map.removeInteraction(this.draw);
|
||||||
this.draw = null;
|
this.draw = null;
|
||||||
}
|
}
|
||||||
|
if (this.select) this.select.setActive(true);
|
||||||
|
if (this.translate) this.translate.setActive(true);
|
||||||
|
if (this.modify) this.modify.setActive(true);
|
||||||
this.drawType = null;
|
this.drawType = null;
|
||||||
this.isDrawing = false;
|
this.isDrawing = false;
|
||||||
this.stopMeasuring();
|
this.stopMeasuring();
|
||||||
@@ -2402,8 +2871,11 @@ export default {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const format = new GeoJSON();
|
const format = new GeoJSON();
|
||||||
const features = this.drawSource.getFeatures();
|
const features = this.serializeFeatures(this.drawSource.getFeatures());
|
||||||
const json = format.writeFeatures(features);
|
const json = format.writeFeatures(features, {
|
||||||
|
dataProjection: "EPSG:4326",
|
||||||
|
featureProjection: "EPSG:3857",
|
||||||
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await window.axios.post("/api/v1/map/drawings", {
|
await window.axios.post("/api/v1/map/drawings", {
|
||||||
@@ -2426,6 +2898,7 @@ export default {
|
|||||||
});
|
});
|
||||||
this.drawSource.clear();
|
this.drawSource.clear();
|
||||||
this.drawSource.addFeatures(features);
|
this.drawSource.addFeatures(features);
|
||||||
|
await this.saveMapState();
|
||||||
this.showLoadDrawingModal = false;
|
this.showLoadDrawingModal = false;
|
||||||
ToastUtils.success(`Loaded "${drawing.name}"`);
|
ToastUtils.success(`Loaded "${drawing.name}"`);
|
||||||
},
|
},
|
||||||
@@ -2493,44 +2966,47 @@ export default {
|
|||||||
const loc = t.telemetry?.location;
|
const loc = t.telemetry?.location;
|
||||||
if (!loc || loc.latitude === undefined || loc.longitude === undefined) continue;
|
if (!loc || loc.latitude === undefined || loc.longitude === undefined) continue;
|
||||||
|
|
||||||
const peer = this.peers[t.destination_hash];
|
|
||||||
const displayName = peer?.display_name || t.destination_hash.substring(0, 8);
|
|
||||||
|
|
||||||
const feature = new Feature({
|
const feature = new Feature({
|
||||||
geometry: new Point(fromLonLat([loc.longitude, loc.latitude])),
|
geometry: new Point(fromLonLat([loc.longitude, loc.latitude])),
|
||||||
telemetry: t,
|
telemetry: t,
|
||||||
peer: peer,
|
peer: this.peers[t.destination_hash],
|
||||||
});
|
});
|
||||||
|
|
||||||
// Default style
|
|
||||||
let iconColor = "#3b82f6";
|
|
||||||
let bgColor = "#ffffff";
|
|
||||||
|
|
||||||
if (peer?.lxmf_user_icon) {
|
|
||||||
iconColor = peer.lxmf_user_icon.foreground_colour || iconColor;
|
|
||||||
bgColor = peer.lxmf_user_icon.background_colour || bgColor;
|
|
||||||
}
|
|
||||||
|
|
||||||
feature.setStyle(
|
|
||||||
new Style({
|
|
||||||
image: new CircleStyle({
|
|
||||||
radius: 8,
|
|
||||||
fill: new Fill({ color: bgColor }),
|
|
||||||
stroke: new Stroke({ color: iconColor, width: 2 }),
|
|
||||||
}),
|
|
||||||
text: new Text({
|
|
||||||
text: displayName,
|
|
||||||
offsetY: -15,
|
|
||||||
font: "bold 11px sans-serif",
|
|
||||||
fill: new Fill({ color: "#000" }),
|
|
||||||
stroke: new Stroke({ color: "#fff", width: 2 }),
|
|
||||||
}),
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
this.markerSource.addFeature(feature);
|
this.markerSource.addFeature(feature);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
createMarkerStyle({ iconColor, bgColor, label, isStale, iconPath }) {
|
||||||
|
const cacheKey = `${iconColor}-${bgColor}-${label}-${isStale}-${iconPath || "default"}`;
|
||||||
|
if (this.styleCache[cacheKey]) return this.styleCache[cacheKey];
|
||||||
|
|
||||||
|
const markerFill = isStale ? "#d1d5db" : bgColor;
|
||||||
|
const markerStroke = isStale ? "#9ca3af" : iconColor;
|
||||||
|
const path =
|
||||||
|
iconPath ||
|
||||||
|
"M12 2C8.13 2 5 5.13 5 9c0 5.25 7 13 7 13s7-7.75 7-13c0-3.87-3.13-7-7-7Zm0 11a2 2 0 1 1 0-4 2 2 0 0 1 0 4Z";
|
||||||
|
|
||||||
|
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><path d="${path}" fill="${markerFill}" stroke="${markerStroke}" stroke-width="1.5"/></svg>`;
|
||||||
|
const src = "data:image/svg+xml;base64," + btoa(unescape(encodeURIComponent(svg)));
|
||||||
|
|
||||||
|
const style = new Style({
|
||||||
|
image: new Icon({
|
||||||
|
src: src,
|
||||||
|
anchor: [0.5, 1],
|
||||||
|
scale: 1.6, // Reduced from 2.5
|
||||||
|
imgSize: [24, 24],
|
||||||
|
}),
|
||||||
|
text: new Text({
|
||||||
|
text: label,
|
||||||
|
offsetY: -45, // Adjusted from -60
|
||||||
|
font: "bold 12px sans-serif",
|
||||||
|
fill: new Fill({ color: isStale ? "#6b7280" : "#111827" }),
|
||||||
|
stroke: new Stroke({ color: "#ffffff", width: 3 }),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
this.styleCache[cacheKey] = style;
|
||||||
|
return style;
|
||||||
|
},
|
||||||
onMarkerClick(feature) {
|
onMarkerClick(feature) {
|
||||||
this.selectedMarker = {
|
this.selectedMarker = {
|
||||||
telemetry: feature.get("telemetry"),
|
telemetry: feature.get("telemetry"),
|
||||||
|
|||||||
@@ -180,6 +180,18 @@
|
|||||||
<div class="text-[10px] text-gray-500 dark:text-zinc-500 font-mono truncate">
|
<div class="text-[10px] text-gray-500 dark:text-zinc-500 font-mono truncate">
|
||||||
{{ contact.remote_identity_hash }}
|
{{ contact.remote_identity_hash }}
|
||||||
</div>
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="contact.lxmf_address"
|
||||||
|
class="text-[9px] text-gray-400 dark:text-zinc-500 font-mono truncate"
|
||||||
|
>
|
||||||
|
LXMF: {{ contact.lxmf_address }}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="contact.lxst_address"
|
||||||
|
class="text-[9px] text-gray-400 dark:text-zinc-500 font-mono truncate"
|
||||||
|
>
|
||||||
|
LXST: {{ contact.lxst_address }}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -264,11 +276,17 @@
|
|||||||
<span class="text-sm font-bold">Contact Shared</span>
|
<span class="text-sm font-bold">Contact Shared</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
<div
|
<LxmfUserIcon
|
||||||
class="size-10 flex items-center justify-center rounded-full bg-blue-100 dark:bg-blue-800 text-blue-600 dark:text-blue-200 font-bold"
|
:custom-image="getParsedItems(chatItem).contact.custom_image"
|
||||||
>
|
:icon-name="getParsedItems(chatItem).contact.lxmf_user_icon?.icon_name"
|
||||||
{{ getParsedItems(chatItem).contact.name.charAt(0).toUpperCase() }}
|
:icon-foreground-colour="
|
||||||
</div>
|
getParsedItems(chatItem).contact.lxmf_user_icon?.foreground_colour
|
||||||
|
"
|
||||||
|
:icon-background-colour="
|
||||||
|
getParsedItems(chatItem).contact.lxmf_user_icon?.background_colour
|
||||||
|
"
|
||||||
|
icon-class="size-10"
|
||||||
|
/>
|
||||||
<div class="flex-1 min-w-0">
|
<div class="flex-1 min-w-0">
|
||||||
<div class="text-sm font-bold text-gray-900 dark:text-white truncate">
|
<div class="text-sm font-bold text-gray-900 dark:text-white truncate">
|
||||||
{{ getParsedItems(chatItem).contact.name }}
|
{{ getParsedItems(chatItem).contact.name }}
|
||||||
@@ -278,6 +296,18 @@
|
|||||||
>
|
>
|
||||||
{{ getParsedItems(chatItem).contact.hash }}
|
{{ getParsedItems(chatItem).contact.hash }}
|
||||||
</div>
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="getParsedItems(chatItem).contact.lxmf_address"
|
||||||
|
class="text-[9px] font-mono text-gray-400 dark:text-zinc-500 truncate"
|
||||||
|
>
|
||||||
|
LXMF: {{ getParsedItems(chatItem).contact.lxmf_address }}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="getParsedItems(chatItem).contact.lxst_address"
|
||||||
|
class="text-[9px] font-mono text-gray-400 dark:text-zinc-500 truncate"
|
||||||
|
>
|
||||||
|
LXST: {{ getParsedItems(chatItem).contact.lxst_address }}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
@@ -286,7 +316,9 @@
|
|||||||
@click="
|
@click="
|
||||||
addContact(
|
addContact(
|
||||||
getParsedItems(chatItem).contact.name,
|
getParsedItems(chatItem).contact.name,
|
||||||
getParsedItems(chatItem).contact.hash
|
getParsedItems(chatItem).contact.hash,
|
||||||
|
getParsedItems(chatItem).contact.lxmf_address,
|
||||||
|
getParsedItems(chatItem).contact.lxst_address
|
||||||
)
|
)
|
||||||
"
|
"
|
||||||
>
|
>
|
||||||
@@ -1858,12 +1890,30 @@ export default {
|
|||||||
paperMessage: null,
|
paperMessage: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Parse contact: Contact: ivan <ca314c30b27eacec5f6ca6ac504e94c9>
|
// Parse contact: Contact: ivan <ca314c30b27eacec5f6ca6ac504e94c9> [LXMF: ...] [LXST: ...]
|
||||||
const contactMatch = content.match(/^Contact:\s+(.+?)\s+<([a-fA-F0-9]{32})>$/i);
|
const contactMatch = content.match(
|
||||||
|
/^Contact:\s+(.+?)\s+<([a-fA-F0-9]{32})>(?:\s+\[LXMF:\s+([a-fA-F0-9]{32})\])?(?:\s+\[LXST:\s+([a-fA-F0-9]{32})\])?/i
|
||||||
|
);
|
||||||
if (contactMatch) {
|
if (contactMatch) {
|
||||||
|
const contactHash = contactMatch[2];
|
||||||
|
const lxmfAddress = contactMatch[3];
|
||||||
|
const lxstAddress = contactMatch[4];
|
||||||
|
|
||||||
|
// try to find enriched info from existing conversations/peers
|
||||||
|
const existing = this.conversations.find(
|
||||||
|
(c) =>
|
||||||
|
c.destination_hash === contactHash ||
|
||||||
|
c.destination_hash === lxmfAddress ||
|
||||||
|
c.destination_hash === lxstAddress
|
||||||
|
);
|
||||||
|
|
||||||
items.contact = {
|
items.contact = {
|
||||||
name: contactMatch[1],
|
name: contactMatch[1],
|
||||||
hash: contactMatch[2],
|
hash: contactHash,
|
||||||
|
lxmf_address: lxmfAddress,
|
||||||
|
lxst_address: lxstAddress,
|
||||||
|
custom_image: existing?.contact_image,
|
||||||
|
lxmf_user_icon: existing?.lxmf_user_icon,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1881,7 +1931,7 @@ export default {
|
|||||||
|
|
||||||
return items;
|
return items;
|
||||||
},
|
},
|
||||||
async addContact(name, hash) {
|
async addContact(name, hash, lxmf_address = null, lxst_address = null) {
|
||||||
try {
|
try {
|
||||||
// Check if contact already exists
|
// Check if contact already exists
|
||||||
const checkResponse = await window.axios.get(`/api/v1/telephone/contacts/check/${hash}`);
|
const checkResponse = await window.axios.get(`/api/v1/telephone/contacts/check/${hash}`);
|
||||||
@@ -1893,6 +1943,8 @@ export default {
|
|||||||
await window.axios.post("/api/v1/telephone/contacts", {
|
await window.axios.post("/api/v1/telephone/contacts", {
|
||||||
name: name,
|
name: name,
|
||||||
remote_identity_hash: hash,
|
remote_identity_hash: hash,
|
||||||
|
lxmf_address: lxmf_address,
|
||||||
|
lxst_address: lxst_address,
|
||||||
});
|
});
|
||||||
ToastUtils.success(`Added ${name} to contacts`);
|
ToastUtils.success(`Added ${name} to contacts`);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -2539,10 +2591,13 @@ export default {
|
|||||||
ToastUtils.error("Failed to load contacts");
|
ToastUtils.error("Failed to load contacts");
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
async shareContact(contact) {
|
shareContact(contact) {
|
||||||
this.newMessageText = `Contact: ${contact.name} <${contact.remote_identity_hash}>`;
|
let sharedString = `Contact: ${contact.name} <${contact.remote_identity_hash}>`;
|
||||||
|
if (contact.lxmf_address) sharedString += ` [LXMF: ${contact.lxmf_address}]`;
|
||||||
|
if (contact.lxst_address) sharedString += ` [LXST: ${contact.lxst_address}]`;
|
||||||
|
this.newMessageText = sharedString;
|
||||||
this.isShareContactModalOpen = false;
|
this.isShareContactModalOpen = false;
|
||||||
await this.sendMessage();
|
this.sendMessage();
|
||||||
},
|
},
|
||||||
shareAsPaperMessage(chatItem) {
|
shareAsPaperMessage(chatItem) {
|
||||||
this.paperMessageHash = chatItem.lxmf_message.hash;
|
this.paperMessageHash = chatItem.lxmf_message.hash;
|
||||||
|
|||||||
@@ -192,6 +192,7 @@ export default {
|
|||||||
// stop listening for websocket messages
|
// stop listening for websocket messages
|
||||||
WebSocketConnection.off("message", this.onWebsocketMessage);
|
WebSocketConnection.off("message", this.onWebsocketMessage);
|
||||||
GlobalEmitter.off("compose-new-message", this.onComposeNewMessage);
|
GlobalEmitter.off("compose-new-message", this.onComposeNewMessage);
|
||||||
|
GlobalEmitter.off("refresh-conversations", this.requestConversationsRefresh);
|
||||||
},
|
},
|
||||||
mounted() {
|
mounted() {
|
||||||
// listen for websocket messages
|
// listen for websocket messages
|
||||||
@@ -371,6 +372,20 @@ export default {
|
|||||||
this.conversations = newConversations;
|
this.conversations = newConversations;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
for (const conversation of newConversations) {
|
||||||
|
if (!conversation?.destination_hash) continue;
|
||||||
|
const existingPeer = this.peers[conversation.destination_hash] || {};
|
||||||
|
this.peers[conversation.destination_hash] = {
|
||||||
|
...existingPeer,
|
||||||
|
destination_hash: conversation.destination_hash,
|
||||||
|
display_name: conversation.display_name ?? existingPeer.display_name,
|
||||||
|
custom_display_name: conversation.custom_display_name ?? existingPeer.custom_display_name,
|
||||||
|
contact_image: conversation.contact_image ?? existingPeer.contact_image,
|
||||||
|
lxmf_user_icon: conversation.lxmf_user_icon ?? existingPeer.lxmf_user_icon,
|
||||||
|
updated_at: conversation.updated_at ?? existingPeer.updated_at,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
this.hasLoadedConversations = true;
|
this.hasLoadedConversations = true;
|
||||||
this.hasMoreConversations = newConversations.length === this.pageSize;
|
this.hasMoreConversations = newConversations.length === this.pageSize;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -402,7 +417,8 @@ export default {
|
|||||||
return params;
|
return params;
|
||||||
},
|
},
|
||||||
updatePeerFromAnnounce: function (announce) {
|
updatePeerFromAnnounce: function (announce) {
|
||||||
this.peers[announce.destination_hash] = announce;
|
const existing = this.peers[announce.destination_hash] || {};
|
||||||
|
this.peers[announce.destination_hash] = { ...existing, ...announce };
|
||||||
},
|
},
|
||||||
onPeerClick: function (peer) {
|
onPeerClick: function (peer) {
|
||||||
// update selected peer
|
// update selected peer
|
||||||
|
|||||||
@@ -267,6 +267,7 @@
|
|||||||
|
|
||||||
<div class="my-auto mr-2">
|
<div class="my-auto mr-2">
|
||||||
<LxmfUserIcon
|
<LxmfUserIcon
|
||||||
|
:custom-image="peer.contact_image"
|
||||||
:icon-name="peer.lxmf_user_icon?.icon_name"
|
:icon-name="peer.lxmf_user_icon?.icon_name"
|
||||||
:icon-foreground-colour="peer.lxmf_user_icon?.foreground_colour"
|
:icon-foreground-colour="peer.lxmf_user_icon?.foreground_colour"
|
||||||
:icon-background-colour="peer.lxmf_user_icon?.background_colour"
|
:icon-background-colour="peer.lxmf_user_icon?.background_colour"
|
||||||
@@ -457,6 +458,9 @@ export default {
|
|||||||
return matchesDisplayName || matchesCustomDisplayName || matchesDestinationHash;
|
return matchesDisplayName || matchesCustomDisplayName || matchesDestinationHash;
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
hasUnreadConversations() {
|
||||||
|
return this.conversations.some((c) => c.is_unread);
|
||||||
|
},
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
isBlocked(destinationHash) {
|
isBlocked(destinationHash) {
|
||||||
|
|||||||
@@ -212,6 +212,8 @@ import LxmfUserIcon from "../LxmfUserIcon.vue";
|
|||||||
import ToastUtils from "../../js/ToastUtils";
|
import ToastUtils from "../../js/ToastUtils";
|
||||||
import ColourPickerDropdown from "../ColourPickerDropdown.vue";
|
import ColourPickerDropdown from "../ColourPickerDropdown.vue";
|
||||||
import MaterialDesignIcon from "../MaterialDesignIcon.vue";
|
import MaterialDesignIcon from "../MaterialDesignIcon.vue";
|
||||||
|
import GlobalState from "../../js/GlobalState";
|
||||||
|
import GlobalEmitter from "../../js/GlobalEmitter";
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: "ProfileIconPage",
|
name: "ProfileIconPage",
|
||||||
@@ -324,6 +326,8 @@ export default {
|
|||||||
try {
|
try {
|
||||||
const response = await window.axios.patch("/api/v1/config", config);
|
const response = await window.axios.patch("/api/v1/config", config);
|
||||||
this.config = response.data.config;
|
this.config = response.data.config;
|
||||||
|
GlobalState.config = response.data.config;
|
||||||
|
GlobalEmitter.emit("config-updated", response.data.config);
|
||||||
this.saveOriginalValues();
|
this.saveOriginalValues();
|
||||||
|
|
||||||
if (!silent) {
|
if (!silent) {
|
||||||
|
|||||||
326
meshchatx/src/frontend/components/tools/BotsPage.vue
Normal file
326
meshchatx/src/frontend/components/tools/BotsPage.vue
Normal file
@@ -0,0 +1,326 @@
|
|||||||
|
<template>
|
||||||
|
<div
|
||||||
|
class="flex flex-col flex-1 overflow-hidden min-w-0 bg-gradient-to-br from-slate-50 via-slate-100 to-white dark:from-zinc-950 dark:via-zinc-900 dark:to-zinc-900"
|
||||||
|
>
|
||||||
|
<div class="flex-1 overflow-y-auto w-full px-4 md:px-8 py-6">
|
||||||
|
<div class="space-y-4 w-full max-w-4xl mx-auto">
|
||||||
|
<div class="glass-card space-y-5">
|
||||||
|
<div class="space-y-2">
|
||||||
|
<div class="text-xs uppercase tracking-wide text-gray-500 dark:text-gray-400">
|
||||||
|
{{ $t("bots.bot_framework") }}
|
||||||
|
</div>
|
||||||
|
<div class="text-2xl font-semibold text-gray-900 dark:text-white">{{ $t("bots.title") }}</div>
|
||||||
|
<div class="text-sm text-gray-600 dark:text-gray-300">
|
||||||
|
{{ $t("bots.description") }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-6">
|
||||||
|
<!-- Create New Bot -->
|
||||||
|
<div class="space-y-4">
|
||||||
|
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">
|
||||||
|
{{ $t("bots.create_new_bot") }}
|
||||||
|
</h3>
|
||||||
|
<div class="grid sm:grid-cols-2 gap-4">
|
||||||
|
<div
|
||||||
|
v-for="template in templates"
|
||||||
|
:key="template.id"
|
||||||
|
class="glass-card !p-4 hover:border-blue-400 transition cursor-pointer flex flex-col justify-between"
|
||||||
|
@click="selectTemplate(template)"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<div class="font-bold text-gray-900 dark:text-white">{{ template.name }}</div>
|
||||||
|
<div class="text-sm text-gray-600 dark:text-gray-400 mt-1">
|
||||||
|
{{ template.description }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
class="primary-chip w-full mt-4 py-2"
|
||||||
|
@click.stop="selectTemplate(template)"
|
||||||
|
>
|
||||||
|
{{ $t("bots.select") }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- More bots coming soon -->
|
||||||
|
<div
|
||||||
|
class="glass-card !p-4 border-dashed border-2 border-gray-200 dark:border-zinc-800 flex flex-col items-center justify-center opacity-60"
|
||||||
|
>
|
||||||
|
<div class="p-2 bg-gray-100 dark:bg-zinc-800 rounded-lg mb-2">
|
||||||
|
<MaterialDesignIcon
|
||||||
|
icon-name="plus"
|
||||||
|
class="size-6 text-gray-400 dark:text-gray-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="text-sm font-medium text-gray-500 dark:text-gray-400 text-center">
|
||||||
|
{{ $t("bots.more_bots_coming") }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Saved Bots -->
|
||||||
|
<div class="space-y-4">
|
||||||
|
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">
|
||||||
|
{{ $t("bots.saved_bots") }}
|
||||||
|
</h3>
|
||||||
|
<div v-if="bots.length === 0" class="text-sm text-gray-500 italic">
|
||||||
|
{{ $t("bots.no_bots_running") }}
|
||||||
|
</div>
|
||||||
|
<div v-else class="space-y-3">
|
||||||
|
<div
|
||||||
|
v-for="bot in bots"
|
||||||
|
:key="bot.id"
|
||||||
|
class="glass-card !p-4 flex items-center justify-between"
|
||||||
|
>
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<div class="p-2 bg-blue-100 dark:bg-blue-900/30 rounded-lg">
|
||||||
|
<MaterialDesignIcon
|
||||||
|
icon-name="robot"
|
||||||
|
class="size-6 text-blue-600 dark:text-blue-400"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="font-bold text-gray-900 dark:text-white">{{ bot.name }}</div>
|
||||||
|
<div class="text-xs font-mono text-gray-500">
|
||||||
|
{{ runningMap[bot.id]?.address || "Not running" }}
|
||||||
|
</div>
|
||||||
|
<div class="text-[10px] text-gray-400">
|
||||||
|
{{ bot.template_id || bot.template }}
|
||||||
|
</div>
|
||||||
|
<div v-if="bot.storage_dir" class="text-[10px] text-gray-400">
|
||||||
|
{{ bot.storage_dir }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<template v-if="runningMap[bot.id]">
|
||||||
|
<button
|
||||||
|
class="p-2 text-red-500 hover:bg-red-50 dark:hover:bg-red-900/20 rounded-lg transition-colors"
|
||||||
|
:title="$t('bots.stop_bot')"
|
||||||
|
@click="stopBot(bot.id)"
|
||||||
|
>
|
||||||
|
<MaterialDesignIcon icon-name="stop" class="size-5" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="p-2 text-blue-500 hover:bg-blue-50 dark:hover:bg-blue-900/20 rounded-lg transition-colors"
|
||||||
|
:title="$t('bots.restart_bot')"
|
||||||
|
@click="restartExisting(bot)"
|
||||||
|
>
|
||||||
|
<MaterialDesignIcon icon-name="refresh" class="size-5" />
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<button class="primary-chip px-3 py-1 text-xs" @click="startExisting(bot)">
|
||||||
|
{{ $t("bots.start_bot") }}
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
<button
|
||||||
|
class="p-2 text-gray-500 hover:bg-gray-50 dark:hover:bg-zinc-800 rounded-lg transition-colors"
|
||||||
|
:title="$t('bots.export_identity')"
|
||||||
|
@click="exportIdentity(bot.id)"
|
||||||
|
>
|
||||||
|
<MaterialDesignIcon icon-name="export" class="size-5" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="p-2 text-red-500 hover:bg-red-50 dark:hover:bg-red-900/20 rounded-lg transition-colors"
|
||||||
|
:title="$t('bots.delete_bot')"
|
||||||
|
@click="deleteBot(bot.id)"
|
||||||
|
>
|
||||||
|
<MaterialDesignIcon icon-name="delete" class="size-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Start Bot Modal -->
|
||||||
|
<div
|
||||||
|
v-if="selectedTemplate"
|
||||||
|
class="fixed inset-0 z-[100] flex items-center justify-center p-4 bg-black/50 backdrop-blur-sm"
|
||||||
|
@click.self="selectedTemplate = null"
|
||||||
|
>
|
||||||
|
<div class="glass-card max-w-md w-full space-y-4">
|
||||||
|
<div class="flex justify-between items-center">
|
||||||
|
<h3 class="text-xl font-bold text-gray-900 dark:text-white">
|
||||||
|
{{ $t("bots.start_bot") }}: {{ selectedTemplate.name }}
|
||||||
|
</h3>
|
||||||
|
<button
|
||||||
|
class="p-1 hover:bg-gray-100 dark:hover:bg-zinc-800 rounded-lg"
|
||||||
|
@click="selectedTemplate = null"
|
||||||
|
>
|
||||||
|
<MaterialDesignIcon icon-name="close" class="size-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label class="glass-label">{{ $t("bots.bot_name") }}</label>
|
||||||
|
<input
|
||||||
|
v-model="newBotName"
|
||||||
|
type="text"
|
||||||
|
:placeholder="selectedTemplate.name"
|
||||||
|
class="input-field"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
{{ selectedTemplate.description }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex gap-3 justify-end pt-2">
|
||||||
|
<button class="secondary-chip px-6 py-2" @click="selectedTemplate = null">
|
||||||
|
{{ $t("bots.cancel") }}
|
||||||
|
</button>
|
||||||
|
<button class="primary-chip px-6 py-2" :disabled="isStarting" @click="startBot">
|
||||||
|
<span
|
||||||
|
v-if="isStarting"
|
||||||
|
class="inline-block w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin mr-2"
|
||||||
|
></span>
|
||||||
|
{{ $t("bots.start_bot") }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import ToastUtils from "../../js/ToastUtils";
|
||||||
|
import MaterialDesignIcon from "../MaterialDesignIcon.vue";
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: "BotsPage",
|
||||||
|
components: {
|
||||||
|
MaterialDesignIcon,
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
bots: [],
|
||||||
|
templates: [],
|
||||||
|
runningBots: [],
|
||||||
|
selectedTemplate: null,
|
||||||
|
newBotName: "",
|
||||||
|
isStarting: false,
|
||||||
|
loading: true,
|
||||||
|
refreshInterval: null,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
runningMap() {
|
||||||
|
const map = {};
|
||||||
|
this.runningBots.forEach((b) => {
|
||||||
|
map[b.id] = b;
|
||||||
|
});
|
||||||
|
return map;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
mounted() {
|
||||||
|
this.getStatus();
|
||||||
|
this.refreshInterval = setInterval(this.getStatus, 5000);
|
||||||
|
},
|
||||||
|
beforeUnmount() {
|
||||||
|
if (this.refreshInterval) {
|
||||||
|
clearInterval(this.refreshInterval);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
async getStatus() {
|
||||||
|
try {
|
||||||
|
const response = await window.axios.get("/api/v1/bots/status");
|
||||||
|
this.bots = response.data.status.bots || [];
|
||||||
|
this.runningBots = response.data.status.running_bots;
|
||||||
|
this.templates = response.data.templates;
|
||||||
|
this.loading = false;
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
selectTemplate(template) {
|
||||||
|
this.selectedTemplate = template;
|
||||||
|
this.newBotName = template.name;
|
||||||
|
},
|
||||||
|
async startBot() {
|
||||||
|
if (this.isStarting) return;
|
||||||
|
this.isStarting = true;
|
||||||
|
try {
|
||||||
|
await window.axios.post("/api/v1/bots/start", {
|
||||||
|
template_id: this.selectedTemplate.id,
|
||||||
|
name: this.newBotName,
|
||||||
|
});
|
||||||
|
ToastUtils.success(this.$t("bots.bot_started"));
|
||||||
|
this.selectedTemplate = null;
|
||||||
|
this.getStatus();
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
ToastUtils.error(e.response?.data?.message || this.$t("bots.failed_to_start"));
|
||||||
|
} finally {
|
||||||
|
this.isStarting = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async stopBot(botId) {
|
||||||
|
try {
|
||||||
|
await window.axios.post("/api/v1/bots/stop", { bot_id: botId });
|
||||||
|
ToastUtils.success(this.$t("bots.bot_stopped"));
|
||||||
|
this.getStatus();
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
ToastUtils.error(this.$t("bots.failed_to_stop"));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async startExisting(bot) {
|
||||||
|
try {
|
||||||
|
await window.axios.post("/api/v1/bots/start", {
|
||||||
|
bot_id: bot.id,
|
||||||
|
template_id: bot.template_id,
|
||||||
|
name: bot.name,
|
||||||
|
});
|
||||||
|
ToastUtils.success(this.$t("bots.bot_started"));
|
||||||
|
this.getStatus();
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
ToastUtils.error(e.response?.data?.message || this.$t("bots.failed_to_start"));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async restartExisting(bot) {
|
||||||
|
try {
|
||||||
|
await window.axios.post("/api/v1/bots/restart", { bot_id: bot.id });
|
||||||
|
ToastUtils.success(this.$t("bots.bot_started"));
|
||||||
|
this.getStatus();
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
ToastUtils.error(e.response?.data?.message || this.$t("bots.failed_to_start"));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async deleteBot(botId) {
|
||||||
|
if (!confirm(this.$t("common.delete_confirm"))) return;
|
||||||
|
try {
|
||||||
|
await window.axios.post("/api/v1/bots/delete", { bot_id: botId });
|
||||||
|
ToastUtils.success(this.$t("bots.bot_deleted"));
|
||||||
|
this.getStatus();
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
ToastUtils.error(this.$t("bots.failed_to_delete"));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
exportIdentity(botId) {
|
||||||
|
window.open(`/api/v1/bots/export?bot_id=${botId}`, "_blank");
|
||||||
|
},
|
||||||
|
copyToClipboard(text) {
|
||||||
|
navigator.clipboard.writeText(text);
|
||||||
|
ToastUtils.success(this.$t("translator.copied_to_clipboard"));
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.glass-label {
|
||||||
|
@apply block text-sm font-semibold text-gray-700 dark:text-gray-300 mb-1;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -3,7 +3,7 @@
|
|||||||
class="flex flex-col flex-1 overflow-hidden min-w-0 bg-gradient-to-br from-slate-50 via-slate-100 to-white dark:from-zinc-950 dark:via-zinc-900 dark:to-zinc-900"
|
class="flex flex-col flex-1 overflow-hidden min-w-0 bg-gradient-to-br from-slate-50 via-slate-100 to-white dark:from-zinc-950 dark:via-zinc-900 dark:to-zinc-900"
|
||||||
>
|
>
|
||||||
<div class="flex-1 overflow-y-auto w-full">
|
<div class="flex-1 overflow-y-auto w-full">
|
||||||
<div class="space-y-4 p-4 md:p-6 max-w-5xl mx-auto w-full">
|
<div class="space-y-4 p-4 md:p-6 lg:p-8 w-full">
|
||||||
<div class="glass-card space-y-3">
|
<div class="glass-card space-y-3">
|
||||||
<div class="text-xs uppercase tracking-wide text-gray-500 dark:text-gray-400">
|
<div class="text-xs uppercase tracking-wide text-gray-500 dark:text-gray-400">
|
||||||
{{ $t("tools.utilities") }}
|
{{ $t("tools.utilities") }}
|
||||||
@@ -16,184 +16,61 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="grid gap-4 md:grid-cols-2">
|
<div class="glass-card">
|
||||||
<RouterLink :to="{ name: 'ping' }" class="tool-card glass-card">
|
<div class="relative">
|
||||||
<div class="tool-card__icon bg-blue-50 text-blue-500 dark:bg-blue-900/30 dark:text-blue-200">
|
<MaterialDesignIcon
|
||||||
<MaterialDesignIcon icon-name="radar" class="w-6 h-6" />
|
icon-name="magnify"
|
||||||
</div>
|
class="absolute left-3 top-1/2 transform -translate-y-1/2 w-5 h-5 text-gray-400"
|
||||||
<div class="flex-1">
|
/>
|
||||||
<div class="tool-card__title">{{ $t("tools.ping.title") }}</div>
|
<input
|
||||||
<div class="tool-card__description">
|
v-model="searchQuery"
|
||||||
{{ $t("tools.ping.description") }}
|
type="text"
|
||||||
</div>
|
:placeholder="$t('common.search')"
|
||||||
</div>
|
class="w-full pl-10 pr-4 py-3 bg-white/50 dark:bg-zinc-900/50 border border-gray-200 dark:border-zinc-700 rounded-xl focus:outline-none focus:ring-2 focus:ring-blue-500 dark:focus:ring-blue-400 text-gray-900 dark:text-white placeholder-gray-400 dark:placeholder-gray-500"
|
||||||
<MaterialDesignIcon icon-name="chevron-right" class="tool-card__chevron" />
|
/>
|
||||||
</RouterLink>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<RouterLink :to="{ name: 'rnprobe' }" class="tool-card glass-card">
|
<div class="grid gap-4 grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
||||||
<div
|
<RouterLink
|
||||||
class="tool-card__icon bg-purple-50 text-purple-500 dark:bg-purple-900/30 dark:text-purple-200"
|
v-for="tool in filteredTools"
|
||||||
>
|
:key="tool.name"
|
||||||
<MaterialDesignIcon icon-name="radar" class="w-6 h-6" />
|
:to="tool.route"
|
||||||
|
:class="['tool-card', 'glass-card', tool.customClass].filter(Boolean)"
|
||||||
|
>
|
||||||
|
<div :class="tool.iconBg">
|
||||||
|
<MaterialDesignIcon v-if="tool.icon" :icon-name="tool.icon" class="w-6 h-6" />
|
||||||
|
<img
|
||||||
|
v-else-if="tool.image"
|
||||||
|
:src="tool.image"
|
||||||
|
:class="tool.imageClass"
|
||||||
|
:alt="tool.imageAlt"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex-1">
|
<div class="flex-1">
|
||||||
<div class="tool-card__title">{{ $t("tools.rnprobe.title") }}</div>
|
<div class="tool-card__title">{{ tool.title }}</div>
|
||||||
<div class="tool-card__description">
|
<div class="tool-card__description">
|
||||||
{{ $t("tools.rnprobe.description") }}
|
{{ tool.description }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<MaterialDesignIcon icon-name="chevron-right" class="tool-card__chevron" />
|
<div v-if="tool.extraAction" class="flex items-center gap-2">
|
||||||
</RouterLink>
|
|
||||||
|
|
||||||
<RouterLink :to="{ name: 'rncp' }" class="tool-card glass-card">
|
|
||||||
<div
|
|
||||||
class="tool-card__icon bg-green-50 text-green-500 dark:bg-green-900/30 dark:text-green-200"
|
|
||||||
>
|
|
||||||
<MaterialDesignIcon icon-name="swap-horizontal" class="w-6 h-6" />
|
|
||||||
</div>
|
|
||||||
<div class="flex-1">
|
|
||||||
<div class="tool-card__title">{{ $t("tools.rncp.title") }}</div>
|
|
||||||
<div class="tool-card__description">
|
|
||||||
{{ $t("tools.rncp.description") }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<MaterialDesignIcon icon-name="chevron-right" class="tool-card__chevron" />
|
|
||||||
</RouterLink>
|
|
||||||
|
|
||||||
<RouterLink :to="{ name: 'rnstatus' }" class="tool-card glass-card">
|
|
||||||
<div
|
|
||||||
class="tool-card__icon bg-orange-50 text-orange-500 dark:bg-orange-900/30 dark:text-orange-200"
|
|
||||||
>
|
|
||||||
<MaterialDesignIcon icon-name="chart-line" class="w-6 h-6" />
|
|
||||||
</div>
|
|
||||||
<div class="flex-1">
|
|
||||||
<div class="tool-card__title">{{ $t("tools.rnstatus.title") }}</div>
|
|
||||||
<div class="tool-card__description">
|
|
||||||
{{ $t("tools.rnstatus.description") }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<MaterialDesignIcon icon-name="chevron-right" class="tool-card__chevron" />
|
|
||||||
</RouterLink>
|
|
||||||
|
|
||||||
<RouterLink :to="{ name: 'rnpath' }" class="tool-card glass-card">
|
|
||||||
<div
|
|
||||||
class="tool-card__icon bg-indigo-50 text-indigo-500 dark:bg-indigo-900/30 dark:text-indigo-200"
|
|
||||||
>
|
|
||||||
<MaterialDesignIcon icon-name="route" class="w-6 h-6" />
|
|
||||||
</div>
|
|
||||||
<div class="flex-1">
|
|
||||||
<div class="tool-card__title">{{ $t("tools.rnpath.title") }}</div>
|
|
||||||
<div class="tool-card__description">
|
|
||||||
{{ $t("tools.rnpath.description") }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<MaterialDesignIcon icon-name="chevron-right" class="tool-card__chevron" />
|
|
||||||
</RouterLink>
|
|
||||||
|
|
||||||
<RouterLink :to="{ name: 'translator' }" class="tool-card glass-card">
|
|
||||||
<div
|
|
||||||
class="tool-card__icon bg-indigo-50 text-indigo-500 dark:bg-indigo-900/30 dark:text-indigo-200"
|
|
||||||
>
|
|
||||||
<MaterialDesignIcon icon-name="translate" class="w-6 h-6" />
|
|
||||||
</div>
|
|
||||||
<div class="flex-1">
|
|
||||||
<div class="tool-card__title">{{ $t("tools.translator.title") }}</div>
|
|
||||||
<div class="tool-card__description">
|
|
||||||
{{ $t("tools.translator.description") }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<MaterialDesignIcon icon-name="chevron-right" class="tool-card__chevron" />
|
|
||||||
</RouterLink>
|
|
||||||
|
|
||||||
<RouterLink :to="{ name: 'forwarder' }" class="tool-card glass-card">
|
|
||||||
<div class="tool-card__icon bg-rose-50 text-rose-500 dark:bg-rose-900/30 dark:text-rose-200">
|
|
||||||
<MaterialDesignIcon icon-name="email-send-outline" class="w-6 h-6" />
|
|
||||||
</div>
|
|
||||||
<div class="flex-1">
|
|
||||||
<div class="tool-card__title">{{ $t("tools.forwarder.title") }}</div>
|
|
||||||
<div class="tool-card__description">
|
|
||||||
{{ $t("tools.forwarder.description") }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<MaterialDesignIcon icon-name="chevron-right" class="tool-card__chevron" />
|
|
||||||
</RouterLink>
|
|
||||||
|
|
||||||
<RouterLink :to="{ name: 'documentation' }" class="tool-card glass-card">
|
|
||||||
<div class="tool-card__icon bg-cyan-50 text-cyan-500 dark:bg-cyan-900/30 dark:text-cyan-200">
|
|
||||||
<MaterialDesignIcon icon-name="book-open-variant" class="w-6 h-6" />
|
|
||||||
</div>
|
|
||||||
<div class="flex-1">
|
|
||||||
<div class="tool-card__title">{{ $t("docs.title") }}</div>
|
|
||||||
<div class="tool-card__description">
|
|
||||||
{{ $t("docs.subtitle") }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<MaterialDesignIcon icon-name="chevron-right" class="tool-card__chevron" />
|
|
||||||
</RouterLink>
|
|
||||||
|
|
||||||
<RouterLink :to="{ name: 'micron-editor' }" class="tool-card glass-card">
|
|
||||||
<div class="tool-card__icon bg-teal-50 text-teal-500 dark:bg-teal-900/30 dark:text-teal-200">
|
|
||||||
<MaterialDesignIcon icon-name="code-tags" class="w-6 h-6" />
|
|
||||||
</div>
|
|
||||||
<div class="flex-1">
|
|
||||||
<div class="tool-card__title">{{ $t("tools.micron_editor.title") }}</div>
|
|
||||||
<div class="tool-card__description">
|
|
||||||
{{ $t("tools.micron_editor.description") }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<MaterialDesignIcon icon-name="chevron-right" class="tool-card__chevron" />
|
|
||||||
</RouterLink>
|
|
||||||
|
|
||||||
<RouterLink :to="{ name: 'paper-message' }" class="tool-card glass-card">
|
|
||||||
<div class="tool-card__icon bg-blue-50 text-blue-500 dark:bg-blue-900/30 dark:text-blue-200">
|
|
||||||
<MaterialDesignIcon icon-name="qrcode" class="w-6 h-6" />
|
|
||||||
</div>
|
|
||||||
<div class="flex-1">
|
|
||||||
<div class="tool-card__title">{{ $t("tools.paper_message.title") }}</div>
|
|
||||||
<div class="tool-card__description">
|
|
||||||
{{ $t("tools.paper_message.description") }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<MaterialDesignIcon icon-name="chevron-right" class="tool-card__chevron" />
|
|
||||||
</RouterLink>
|
|
||||||
|
|
||||||
<RouterLink :to="{ name: 'rnode-flasher' }" class="tool-card glass-card">
|
|
||||||
<div
|
|
||||||
class="tool-card__icon bg-purple-50 text-purple-500 dark:bg-purple-900/30 dark:text-purple-200"
|
|
||||||
>
|
|
||||||
<img :src="rnodeLogoPath" class="w-8 h-8 rounded-full" alt="RNode" />
|
|
||||||
</div>
|
|
||||||
<div class="flex-1">
|
|
||||||
<div class="tool-card__title">{{ $t("tools.rnode_flasher.title") }}</div>
|
|
||||||
<div class="tool-card__description">
|
|
||||||
{{ $t("tools.rnode_flasher.description") }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<a
|
<a
|
||||||
href="/rnode-flasher/index.html"
|
:href="tool.extraAction.href"
|
||||||
target="_blank"
|
:target="tool.extraAction.target"
|
||||||
class="p-2 hover:bg-gray-100 dark:hover:bg-zinc-800 rounded-lg transition-colors text-gray-400 hover:text-blue-500"
|
class="p-2 hover:bg-gray-100 dark:hover:bg-zinc-800 rounded-lg transition-colors text-gray-400 hover:text-blue-500"
|
||||||
@click.stop
|
@click.stop
|
||||||
>
|
>
|
||||||
<MaterialDesignIcon icon-name="open-in-new" class="size-5" />
|
<MaterialDesignIcon :icon-name="tool.extraAction.icon" class="size-5" />
|
||||||
</a>
|
</a>
|
||||||
<MaterialDesignIcon icon-name="chevron-right" class="tool-card__chevron" />
|
<MaterialDesignIcon icon-name="chevron-right" class="tool-card__chevron" />
|
||||||
</div>
|
</div>
|
||||||
|
<MaterialDesignIcon v-else icon-name="chevron-right" class="tool-card__chevron" />
|
||||||
</RouterLink>
|
</RouterLink>
|
||||||
|
</div>
|
||||||
|
|
||||||
<RouterLink :to="{ name: 'debug-logs' }" class="tool-card glass-card border-dashed border-2">
|
<div v-if="filteredTools.length === 0" class="glass-card text-center py-12">
|
||||||
<div class="tool-card__icon bg-zinc-100 text-zinc-500 dark:bg-zinc-800 dark:text-zinc-400">
|
<MaterialDesignIcon icon-name="magnify" class="w-12 h-12 mx-auto mb-4 text-gray-400" />
|
||||||
<MaterialDesignIcon icon-name="console" class="w-6 h-6" />
|
<div class="text-gray-600 dark:text-gray-400">{{ $t("common.no_results") }}</div>
|
||||||
</div>
|
|
||||||
<div class="flex-1">
|
|
||||||
<div class="tool-card__title">Debug Logs</div>
|
|
||||||
<div class="tool-card__description">
|
|
||||||
View and export internal system logs for troubleshooting.
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<MaterialDesignIcon icon-name="chevron-right" class="tool-card__chevron" />
|
|
||||||
</RouterLink>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -210,8 +87,148 @@ export default {
|
|||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
rnodeLogoPath: "/rnode-flasher/reticulum_logo_512.png",
|
rnodeLogoPath: "/rnode-flasher/reticulum_logo_512.png",
|
||||||
|
searchQuery: "",
|
||||||
|
tools: [
|
||||||
|
{
|
||||||
|
name: "ping",
|
||||||
|
route: { name: "ping" },
|
||||||
|
icon: "radar",
|
||||||
|
iconBg: "tool-card__icon bg-blue-50 text-blue-500 dark:bg-blue-900/30 dark:text-blue-200",
|
||||||
|
titleKey: "tools.ping.title",
|
||||||
|
descriptionKey: "tools.ping.description",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "rnprobe",
|
||||||
|
route: { name: "rnprobe" },
|
||||||
|
icon: "radar",
|
||||||
|
iconBg: "tool-card__icon bg-purple-50 text-purple-500 dark:bg-purple-900/30 dark:text-purple-200",
|
||||||
|
titleKey: "tools.rnprobe.title",
|
||||||
|
descriptionKey: "tools.rnprobe.description",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "rncp",
|
||||||
|
route: { name: "rncp" },
|
||||||
|
icon: "swap-horizontal",
|
||||||
|
iconBg: "tool-card__icon bg-green-50 text-green-500 dark:bg-green-900/30 dark:text-green-200",
|
||||||
|
titleKey: "tools.rncp.title",
|
||||||
|
descriptionKey: "tools.rncp.description",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "rnstatus",
|
||||||
|
route: { name: "rnstatus" },
|
||||||
|
icon: "chart-line",
|
||||||
|
iconBg: "tool-card__icon bg-orange-50 text-orange-500 dark:bg-orange-900/30 dark:text-orange-200",
|
||||||
|
titleKey: "tools.rnstatus.title",
|
||||||
|
descriptionKey: "tools.rnstatus.description",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "rnpath",
|
||||||
|
route: { name: "rnpath" },
|
||||||
|
icon: "route",
|
||||||
|
iconBg: "tool-card__icon bg-indigo-50 text-indigo-500 dark:bg-indigo-900/30 dark:text-indigo-200",
|
||||||
|
titleKey: "tools.rnpath.title",
|
||||||
|
descriptionKey: "tools.rnpath.description",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "translator",
|
||||||
|
route: { name: "translator" },
|
||||||
|
icon: "translate",
|
||||||
|
iconBg: "tool-card__icon bg-indigo-50 text-indigo-500 dark:bg-indigo-900/30 dark:text-indigo-200",
|
||||||
|
titleKey: "tools.translator.title",
|
||||||
|
descriptionKey: "tools.translator.description",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "bots",
|
||||||
|
route: { name: "bots" },
|
||||||
|
icon: "robot",
|
||||||
|
iconBg: "tool-card__icon bg-blue-50 text-blue-500 dark:bg-blue-900/30 dark:text-blue-200",
|
||||||
|
titleKey: "tools.bots.title",
|
||||||
|
descriptionKey: "tools.bots.description",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "forwarder",
|
||||||
|
route: { name: "forwarder" },
|
||||||
|
icon: "email-send-outline",
|
||||||
|
iconBg: "tool-card__icon bg-rose-50 text-rose-500 dark:bg-rose-900/30 dark:text-rose-200",
|
||||||
|
titleKey: "tools.forwarder.title",
|
||||||
|
descriptionKey: "tools.forwarder.description",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "documentation",
|
||||||
|
route: { name: "documentation" },
|
||||||
|
icon: "book-open-variant",
|
||||||
|
iconBg: "tool-card__icon bg-cyan-50 text-cyan-500 dark:bg-cyan-900/30 dark:text-cyan-200",
|
||||||
|
titleKey: "docs.title",
|
||||||
|
descriptionKey: "docs.subtitle",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "micron-editor",
|
||||||
|
route: { name: "micron-editor" },
|
||||||
|
icon: "code-tags",
|
||||||
|
iconBg: "tool-card__icon bg-teal-50 text-teal-500 dark:bg-teal-900/30 dark:text-teal-200",
|
||||||
|
titleKey: "tools.micron_editor.title",
|
||||||
|
descriptionKey: "tools.micron_editor.description",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "paper-message",
|
||||||
|
route: { name: "paper-message" },
|
||||||
|
icon: "qrcode",
|
||||||
|
iconBg: "tool-card__icon bg-blue-50 text-blue-500 dark:bg-blue-900/30 dark:text-blue-200",
|
||||||
|
titleKey: "tools.paper_message.title",
|
||||||
|
descriptionKey: "tools.paper_message.description",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "rnode-flasher",
|
||||||
|
route: { name: "rnode-flasher" },
|
||||||
|
icon: null,
|
||||||
|
image: "/rnode-flasher/reticulum_logo_512.png",
|
||||||
|
imageClass: "w-8 h-8 rounded-full",
|
||||||
|
imageAlt: "RNode",
|
||||||
|
iconBg: "tool-card__icon bg-purple-50 text-purple-500 dark:bg-purple-900/30 dark:text-purple-200",
|
||||||
|
titleKey: "tools.rnode_flasher.title",
|
||||||
|
descriptionKey: "tools.rnode_flasher.description",
|
||||||
|
extraAction: {
|
||||||
|
href: "/rnode-flasher/index.html",
|
||||||
|
target: "_blank",
|
||||||
|
icon: "open-in-new",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "debug-logs",
|
||||||
|
route: { name: "debug-logs" },
|
||||||
|
icon: "console",
|
||||||
|
iconBg: "tool-card__icon bg-zinc-100 text-zinc-500 dark:bg-zinc-800 dark:text-zinc-400",
|
||||||
|
titleKey: null,
|
||||||
|
title: "Debug Logs",
|
||||||
|
descriptionKey: null,
|
||||||
|
description: "View and export internal system logs for troubleshooting.",
|
||||||
|
customClass: "border-dashed border-2",
|
||||||
|
},
|
||||||
|
],
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
computed: {
|
||||||
|
filteredTools() {
|
||||||
|
const toolsWithTranslations = this.tools.map((tool) => ({
|
||||||
|
...tool,
|
||||||
|
title: tool.title || (tool.titleKey ? this.$t(tool.titleKey) : ""),
|
||||||
|
description: tool.description || (tool.descriptionKey ? this.$t(tool.descriptionKey) : ""),
|
||||||
|
}));
|
||||||
|
|
||||||
|
if (!this.searchQuery.trim()) {
|
||||||
|
return toolsWithTranslations;
|
||||||
|
}
|
||||||
|
|
||||||
|
const query = this.searchQuery.toLowerCase().trim();
|
||||||
|
return toolsWithTranslations.filter((tool) => {
|
||||||
|
return (
|
||||||
|
tool.title.toLowerCase().includes(query) ||
|
||||||
|
tool.description.toLowerCase().includes(query) ||
|
||||||
|
tool.name.toLowerCase().includes(query)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,31 @@
|
|||||||
{
|
{
|
||||||
|
"bots": {
|
||||||
|
"title": "LXMFy-Bots",
|
||||||
|
"description": "Verwalten Sie automatisierte Bots für Echo, Notizen und Erinnerungen mit LXMFy.",
|
||||||
|
"bot_framework": "Bot-Framework",
|
||||||
|
"lxmfy_not_detected": "LXMFy nicht erkannt",
|
||||||
|
"lxmfy_not_detected_desc": "Um Bots zu verwenden, müssen Sie das LXMFy-Paket installieren:",
|
||||||
|
"install_via_pip": "Über pip installieren",
|
||||||
|
"create_new_bot": "Neuen Bot erstellen",
|
||||||
|
"running_bots": "Laufende Bots",
|
||||||
|
"no_bots_running": "Derzeit laufen keine Bots.",
|
||||||
|
"select": "Auswählen",
|
||||||
|
"start_bot": "Bot starten",
|
||||||
|
"stop_bot": "Bot stoppen",
|
||||||
|
"restart_bot": "Bot neu starten",
|
||||||
|
"saved_bots": "Gespeicherte Bots",
|
||||||
|
"bot_name": "Bot-Name",
|
||||||
|
"cancel": "Abbrechen",
|
||||||
|
"bot_started": "Bot erfolgreich gestartet",
|
||||||
|
"bot_stopped": "Bot gestoppt",
|
||||||
|
"failed_to_start": "Bot konnte nicht gestartet werden",
|
||||||
|
"failed_to_stop": "Bot konnte nicht gestoppt werden",
|
||||||
|
"delete_bot": "Bot löschen",
|
||||||
|
"export_identity": "Identität exportieren",
|
||||||
|
"bot_deleted": "Bot erfolgreich gelöscht",
|
||||||
|
"failed_to_delete": "Bot konnte nicht gelöscht werden",
|
||||||
|
"more_bots_coming": "Weitere Bots folgen in Kürze!"
|
||||||
|
},
|
||||||
"app": {
|
"app": {
|
||||||
"name": "Reticulum MeshChatX",
|
"name": "Reticulum MeshChatX",
|
||||||
"sync_messages": "Nachrichten synchronisieren",
|
"sync_messages": "Nachrichten synchronisieren",
|
||||||
@@ -183,7 +210,9 @@
|
|||||||
"shutdown": "Ausschalten",
|
"shutdown": "Ausschalten",
|
||||||
"acknowledge_reset": "Bestätigen & Zurücksetzen",
|
"acknowledge_reset": "Bestätigen & Zurücksetzen",
|
||||||
"confirm": "Bestätigen",
|
"confirm": "Bestätigen",
|
||||||
"delete_confirm": "Sind Sie sicher, dass Sie dies löschen möchten? Dies kann nicht rückgängig gemacht werden."
|
"delete_confirm": "Sind Sie sicher, dass Sie dies löschen möchten? Dies kann nicht rückgängig gemacht werden.",
|
||||||
|
"search": "Werkzeuge suchen...",
|
||||||
|
"no_results": "Keine Werkzeuge gefunden"
|
||||||
},
|
},
|
||||||
"identities": {
|
"identities": {
|
||||||
"title": "Identitäten",
|
"title": "Identitäten",
|
||||||
@@ -665,6 +694,10 @@
|
|||||||
"paper_message": {
|
"paper_message": {
|
||||||
"title": "Papiernachricht",
|
"title": "Papiernachricht",
|
||||||
"description": "Erstellen und lesen Sie LXMF-signierte Papiernachrichten über QR-Codes."
|
"description": "Erstellen und lesen Sie LXMF-signierte Papiernachrichten über QR-Codes."
|
||||||
|
},
|
||||||
|
"bots": {
|
||||||
|
"title": "LXMFy-Bots",
|
||||||
|
"description": "Verwalten Sie automatisierte Bots für Echo, Notizen und Erinnerungen mit LXMFy."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"ping": {
|
"ping": {
|
||||||
|
|||||||
@@ -1,4 +1,31 @@
|
|||||||
{
|
{
|
||||||
|
"bots": {
|
||||||
|
"title": "LXMFy Bots",
|
||||||
|
"description": "Manage automated bots for echo, notes, and reminders using LXMFy.",
|
||||||
|
"bot_framework": "Bot Framework",
|
||||||
|
"lxmfy_not_detected": "LXMFy not detected",
|
||||||
|
"lxmfy_not_detected_desc": "To use bots, you must install the LXMFy package:",
|
||||||
|
"install_via_pip": "Install via pip",
|
||||||
|
"create_new_bot": "Create New Bot",
|
||||||
|
"running_bots": "Running Bots",
|
||||||
|
"no_bots_running": "No bots are currently running.",
|
||||||
|
"select": "Select",
|
||||||
|
"start_bot": "Start Bot",
|
||||||
|
"stop_bot": "Stop Bot",
|
||||||
|
"restart_bot": "Restart Bot",
|
||||||
|
"saved_bots": "Saved Bots",
|
||||||
|
"bot_name": "Bot Name",
|
||||||
|
"cancel": "Cancel",
|
||||||
|
"bot_started": "Bot started successfully",
|
||||||
|
"bot_stopped": "Bot stopped",
|
||||||
|
"failed_to_start": "Failed to start bot",
|
||||||
|
"failed_to_stop": "Failed to stop bot",
|
||||||
|
"delete_bot": "Delete Bot",
|
||||||
|
"export_identity": "Export Identity",
|
||||||
|
"bot_deleted": "Bot deleted successfully",
|
||||||
|
"failed_to_delete": "Failed to delete bot",
|
||||||
|
"more_bots_coming": "More bots coming soon!"
|
||||||
|
},
|
||||||
"app": {
|
"app": {
|
||||||
"name": "Reticulum MeshChatX",
|
"name": "Reticulum MeshChatX",
|
||||||
"sync_messages": "Sync Messages",
|
"sync_messages": "Sync Messages",
|
||||||
@@ -183,7 +210,9 @@
|
|||||||
"shutdown": "Shutdown",
|
"shutdown": "Shutdown",
|
||||||
"acknowledge_reset": "Acknowledge & Reset",
|
"acknowledge_reset": "Acknowledge & Reset",
|
||||||
"confirm": "Confirm",
|
"confirm": "Confirm",
|
||||||
"delete_confirm": "Are you sure you want to delete this? This cannot be undone."
|
"delete_confirm": "Are you sure you want to delete this? This cannot be undone.",
|
||||||
|
"search": "Search tools...",
|
||||||
|
"no_results": "No tools found"
|
||||||
},
|
},
|
||||||
"identities": {
|
"identities": {
|
||||||
"title": "Identities",
|
"title": "Identities",
|
||||||
@@ -665,6 +694,10 @@
|
|||||||
"paper_message": {
|
"paper_message": {
|
||||||
"title": "Paper Message",
|
"title": "Paper Message",
|
||||||
"description": "Generate and read LXMF signed paper messages via QR codes."
|
"description": "Generate and read LXMF signed paper messages via QR codes."
|
||||||
|
},
|
||||||
|
"bots": {
|
||||||
|
"title": "LXMFy Bots",
|
||||||
|
"description": "Manage automated bots for echo, notes, and reminders using LXMFy."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"ping": {
|
"ping": {
|
||||||
|
|||||||
@@ -1,4 +1,31 @@
|
|||||||
{
|
{
|
||||||
|
"bots": {
|
||||||
|
"title": "Боты LXMFy",
|
||||||
|
"description": "Управление автоматическими ботами для эха, заметок и напоминаний с помощью LXMFy.",
|
||||||
|
"bot_framework": "Фреймворк ботов",
|
||||||
|
"lxmfy_not_detected": "LXMFy не обнаружен",
|
||||||
|
"lxmfy_not_detected_desc": "Для использования ботов необходимо установить пакет LXMFy:",
|
||||||
|
"install_via_pip": "Установить через pip",
|
||||||
|
"create_new_bot": "Создать нового бота",
|
||||||
|
"running_bots": "Запущенные боты",
|
||||||
|
"no_bots_running": "В данный момент нет запущенных ботов.",
|
||||||
|
"select": "Выбрать",
|
||||||
|
"start_bot": "Запустить бота",
|
||||||
|
"stop_bot": "Остановить бота",
|
||||||
|
"restart_bot": "Перезапустить бота",
|
||||||
|
"saved_bots": "Сохраненные боты",
|
||||||
|
"bot_name": "Имя бота",
|
||||||
|
"cancel": "Отмена",
|
||||||
|
"bot_started": "Бот успешно запущен",
|
||||||
|
"bot_stopped": "Бот остановлен",
|
||||||
|
"failed_to_start": "Не удалось запустить бота",
|
||||||
|
"failed_to_stop": "Не удалось остановить бота",
|
||||||
|
"delete_bot": "Удалить бота",
|
||||||
|
"export_identity": "Экспорт личности",
|
||||||
|
"bot_deleted": "Бот успешно удален",
|
||||||
|
"failed_to_delete": "Не удалось удалить бота",
|
||||||
|
"more_bots_coming": "Скоро появятся новые боты!"
|
||||||
|
},
|
||||||
"app": {
|
"app": {
|
||||||
"name": "Reticulum MeshChatX",
|
"name": "Reticulum MeshChatX",
|
||||||
"sync_messages": "Синхронизировать сообщения",
|
"sync_messages": "Синхронизировать сообщения",
|
||||||
@@ -183,7 +210,9 @@
|
|||||||
"shutdown": "Выключить",
|
"shutdown": "Выключить",
|
||||||
"acknowledge_reset": "Подтвердить и сбросить",
|
"acknowledge_reset": "Подтвердить и сбросить",
|
||||||
"confirm": "Подтвердить",
|
"confirm": "Подтвердить",
|
||||||
"delete_confirm": "Вы уверены, что хотите удалить это? Это действие нельзя отменить."
|
"delete_confirm": "Вы уверены, что хотите удалить это? Это действие нельзя отменить.",
|
||||||
|
"search": "Поиск инструментов...",
|
||||||
|
"no_results": "Инструменты не найдены"
|
||||||
},
|
},
|
||||||
"identities": {
|
"identities": {
|
||||||
"title": "Личности",
|
"title": "Личности",
|
||||||
@@ -665,6 +694,10 @@
|
|||||||
"paper_message": {
|
"paper_message": {
|
||||||
"title": "Бумажное сообщение",
|
"title": "Бумажное сообщение",
|
||||||
"description": "Создание и чтение подписанных бумажных сообщений LXMF через QR-коды."
|
"description": "Создание и чтение подписанных бумажных сообщений LXMF через QR-коды."
|
||||||
|
},
|
||||||
|
"bots": {
|
||||||
|
"title": "LXMFy Боты",
|
||||||
|
"description": "Управление автоматизированными ботами для эха, заметок и напоминаний с помощью LXMFy."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"ping": {
|
"ping": {
|
||||||
|
|||||||
@@ -189,6 +189,11 @@ const router = createRouter({
|
|||||||
path: "/translator",
|
path: "/translator",
|
||||||
component: defineAsyncComponent(() => import("./components/translator/TranslatorPage.vue")),
|
component: defineAsyncComponent(() => import("./components/translator/TranslatorPage.vue")),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "bots",
|
||||||
|
path: "/bots",
|
||||||
|
component: defineAsyncComponent(() => import("./components/tools/BotsPage.vue")),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: "forwarder",
|
name: "forwarder",
|
||||||
path: "/forwarder",
|
path: "/forwarder",
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ def main():
|
|||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
with open(en_path, "r", encoding="utf-8") as f:
|
with open(en_path, encoding="utf-8") as f:
|
||||||
en_data = json.load(f)
|
en_data = json.load(f)
|
||||||
|
|
||||||
template = clear_values(en_data)
|
template = clear_values(en_data)
|
||||||
@@ -29,7 +29,7 @@ def main():
|
|||||||
json.dump(template, f, indent=4, ensure_ascii=False)
|
json.dump(template, f, indent=4, ensure_ascii=False)
|
||||||
|
|
||||||
print(
|
print(
|
||||||
f"Successfully generated {out_path} with all keys from {en_path} (empty values)."
|
f"Successfully generated {out_path} with all keys from {en_path} (empty values).",
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Error generating locale template: {e}")
|
print(f"Error generating locale template: {e}")
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import os
|
import os
|
||||||
|
import random
|
||||||
|
import secrets
|
||||||
import shutil
|
import shutil
|
||||||
import tempfile
|
import tempfile
|
||||||
import time
|
import time
|
||||||
import random
|
|
||||||
import secrets
|
|
||||||
from meshchatx.src.backend.database import Database
|
from meshchatx.src.backend.database import Database
|
||||||
|
|
||||||
|
|
||||||
@@ -68,7 +69,7 @@ def test_db_performance():
|
|||||||
convs = db.messages.get_conversations()
|
convs = db.messages.get_conversations()
|
||||||
end_time = time.time()
|
end_time = time.time()
|
||||||
print(
|
print(
|
||||||
f"get_conversations() returned {len(convs)} conversations in {end_time - start_time:.4f} seconds"
|
f"get_conversations() returned {len(convs)} conversations in {end_time - start_time:.4f} seconds",
|
||||||
)
|
)
|
||||||
|
|
||||||
# Test get_conversation_messages for a random peer
|
# Test get_conversation_messages for a random peer
|
||||||
@@ -78,7 +79,7 @@ def test_db_performance():
|
|||||||
msgs = db.messages.get_conversation_messages(target_peer, limit=50)
|
msgs = db.messages.get_conversation_messages(target_peer, limit=50)
|
||||||
end_time = time.time()
|
end_time = time.time()
|
||||||
print(
|
print(
|
||||||
f"get_conversation_messages() returned {len(msgs)} messages in {end_time - start_time:.4f} seconds"
|
f"get_conversation_messages() returned {len(msgs)} messages in {end_time - start_time:.4f} seconds",
|
||||||
)
|
)
|
||||||
|
|
||||||
# Test unread states for all peers
|
# Test unread states for all peers
|
||||||
@@ -87,7 +88,7 @@ def test_db_performance():
|
|||||||
_ = db.messages.get_conversations_unread_states(peer_hashes)
|
_ = db.messages.get_conversations_unread_states(peer_hashes)
|
||||||
end_time = time.time()
|
end_time = time.time()
|
||||||
print(
|
print(
|
||||||
f"get_conversations_unread_states() for {len(peer_hashes)} peers took {end_time - start_time:.4f} seconds"
|
f"get_conversations_unread_states() for {len(peer_hashes)} peers took {end_time - start_time:.4f} seconds",
|
||||||
)
|
)
|
||||||
|
|
||||||
# Test announces performance
|
# Test announces performance
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import os
|
|
||||||
import psutil
|
|
||||||
import gc
|
import gc
|
||||||
|
import os
|
||||||
import time
|
import time
|
||||||
from functools import wraps
|
from functools import wraps
|
||||||
|
|
||||||
|
import psutil
|
||||||
|
|
||||||
|
|
||||||
def get_memory_usage_mb():
|
def get_memory_usage_mb():
|
||||||
"""Returns the current process memory usage in MB."""
|
"""Returns the current process memory usage in MB."""
|
||||||
@@ -81,5 +82,5 @@ class MemoryTracker:
|
|||||||
self.duration_ms = (self.end_time - self.start_time) * 1000
|
self.duration_ms = (self.end_time - self.start_time) * 1000
|
||||||
self.mem_delta = self.end_mem - self.start_mem
|
self.mem_delta = self.end_mem - self.start_mem
|
||||||
print(
|
print(
|
||||||
f"TRACKER [{self.name}]: {self.duration_ms:.2f}ms, {self.mem_delta:.2f}MB"
|
f"TRACKER [{self.name}]: {self.duration_ms:.2f}ms, {self.mem_delta:.2f}MB",
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import pytest
|
|
||||||
from unittest.mock import patch
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(autouse=True)
|
@pytest.fixture(autouse=True)
|
||||||
|
|||||||
@@ -1,13 +1,15 @@
|
|||||||
|
import gc
|
||||||
|
import json
|
||||||
import os
|
import os
|
||||||
|
import random
|
||||||
|
import secrets
|
||||||
import shutil
|
import shutil
|
||||||
import tempfile
|
import tempfile
|
||||||
import time
|
import time
|
||||||
import random
|
|
||||||
import secrets
|
|
||||||
import psutil
|
|
||||||
import gc
|
|
||||||
import json
|
|
||||||
from unittest.mock import MagicMock
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
|
import psutil
|
||||||
|
|
||||||
from meshchatx.src.backend.database import Database
|
from meshchatx.src.backend.database import Database
|
||||||
|
|
||||||
|
|
||||||
@@ -72,7 +74,9 @@ class MapBenchmarker:
|
|||||||
)
|
)
|
||||||
|
|
||||||
self.record_benchmark(
|
self.record_benchmark(
|
||||||
f"Telemetry Insertion ({count} entries)", run_telemetry, count
|
f"Telemetry Insertion ({count} entries)",
|
||||||
|
run_telemetry,
|
||||||
|
count,
|
||||||
)
|
)
|
||||||
|
|
||||||
def benchmark_telemetry_retrieval(self, count=100):
|
def benchmark_telemetry_retrieval(self, count=100):
|
||||||
@@ -90,7 +94,9 @@ class MapBenchmarker:
|
|||||||
self.db.telemetry.get_telemetry_history(dest_hash, limit=100)
|
self.db.telemetry.get_telemetry_history(dest_hash, limit=100)
|
||||||
|
|
||||||
self.record_benchmark(
|
self.record_benchmark(
|
||||||
f"Telemetry History Retrieval ({count} calls)", run_retrieval, count
|
f"Telemetry History Retrieval ({count} calls)",
|
||||||
|
run_retrieval,
|
||||||
|
count,
|
||||||
)
|
)
|
||||||
|
|
||||||
def benchmark_drawing_storage(self, count=500):
|
def benchmark_drawing_storage(self, count=500):
|
||||||
@@ -112,7 +118,7 @@ class MapBenchmarker:
|
|||||||
}
|
}
|
||||||
for i in range(100)
|
for i in range(100)
|
||||||
],
|
],
|
||||||
}
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
def run_drawings():
|
def run_drawings():
|
||||||
@@ -125,7 +131,9 @@ class MapBenchmarker:
|
|||||||
)
|
)
|
||||||
|
|
||||||
self.record_benchmark(
|
self.record_benchmark(
|
||||||
f"Map Drawing Insertion ({count} layers)", run_drawings, count
|
f"Map Drawing Insertion ({count} layers)",
|
||||||
|
run_drawings,
|
||||||
|
count,
|
||||||
)
|
)
|
||||||
|
|
||||||
def benchmark_drawing_listing(self, count=100):
|
def benchmark_drawing_listing(self, count=100):
|
||||||
@@ -154,7 +162,9 @@ class MapBenchmarker:
|
|||||||
mm.list_mbtiles()
|
mm.list_mbtiles()
|
||||||
|
|
||||||
self.record_benchmark(
|
self.record_benchmark(
|
||||||
f"MBTiles Listing ({count} calls, 5 files)", run_list, count
|
f"MBTiles Listing ({count} calls, 5 files)",
|
||||||
|
run_list,
|
||||||
|
count,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -173,7 +183,7 @@ def main():
|
|||||||
print("-" * 80)
|
print("-" * 80)
|
||||||
for r in bench.results:
|
for r in bench.results:
|
||||||
print(
|
print(
|
||||||
f"{r['name']:40} | {r['duration_ms']:8.2f} ms | {r['memory_growth_mb']:8.2f} MB"
|
f"{r['name']:40} | {r['duration_ms']:8.2f} ms | {r['memory_growth_mb']:8.2f} MB",
|
||||||
)
|
)
|
||||||
print("=" * 80)
|
print("=" * 80)
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
|
import gc
|
||||||
import os
|
import os
|
||||||
|
import random
|
||||||
|
import secrets
|
||||||
import shutil
|
import shutil
|
||||||
import tempfile
|
import tempfile
|
||||||
import time
|
import time
|
||||||
import random
|
|
||||||
import secrets
|
|
||||||
import psutil
|
import psutil
|
||||||
import gc
|
|
||||||
from meshchatx.src.backend.database import Database
|
from meshchatx.src.backend.database import Database
|
||||||
from meshchatx.src.backend.recovery import CrashRecovery
|
from meshchatx.src.backend.recovery import CrashRecovery
|
||||||
|
|
||||||
@@ -112,7 +114,9 @@ class PerformanceBenchmarker:
|
|||||||
recovery.run_diagnosis(file=open(os.devnull, "w"))
|
recovery.run_diagnosis(file=open(os.devnull, "w"))
|
||||||
|
|
||||||
self.record_benchmark(
|
self.record_benchmark(
|
||||||
"CrashRecovery Diagnosis Overhead (50 runs)", run_recovery_check, 50
|
"CrashRecovery Diagnosis Overhead (50 runs)",
|
||||||
|
run_recovery_check,
|
||||||
|
50,
|
||||||
)
|
)
|
||||||
|
|
||||||
def benchmark_identity_generation(self, count=20):
|
def benchmark_identity_generation(self, count=20):
|
||||||
@@ -123,7 +127,9 @@ class PerformanceBenchmarker:
|
|||||||
RNS.Identity(create_keys=True)
|
RNS.Identity(create_keys=True)
|
||||||
|
|
||||||
self.record_benchmark(
|
self.record_benchmark(
|
||||||
f"RNS Identity Generation ({count} identities)", run_gen, count
|
f"RNS Identity Generation ({count} identities)",
|
||||||
|
run_gen,
|
||||||
|
count,
|
||||||
)
|
)
|
||||||
|
|
||||||
def benchmark_identity_listing(self, count=100):
|
def benchmark_identity_listing(self, count=100):
|
||||||
@@ -142,7 +148,9 @@ class PerformanceBenchmarker:
|
|||||||
manager.list_identities(current_identity_hash=hashes[0])
|
manager.list_identities(current_identity_hash=hashes[0])
|
||||||
|
|
||||||
self.record_benchmark(
|
self.record_benchmark(
|
||||||
f"Identity Listing ({count} runs, 10 identities)", run_list, count
|
f"Identity Listing ({count} runs, 10 identities)",
|
||||||
|
run_list,
|
||||||
|
count,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -161,7 +169,7 @@ def main():
|
|||||||
print("-" * 80)
|
print("-" * 80)
|
||||||
for r in bench.results:
|
for r in bench.results:
|
||||||
print(
|
print(
|
||||||
f"{r['name']:40} | {r['duration_ms']:8.2f} ms | {r['memory_growth_mb']:8.2f} MB"
|
f"{r['name']:40} | {r['duration_ms']:8.2f} ms | {r['memory_growth_mb']:8.2f} MB",
|
||||||
)
|
)
|
||||||
print("=" * 80)
|
print("=" * 80)
|
||||||
|
|
||||||
|
|||||||
@@ -1,18 +1,19 @@
|
|||||||
import os
|
import os
|
||||||
import sys
|
|
||||||
import time
|
|
||||||
import shutil
|
|
||||||
import tempfile
|
|
||||||
import random
|
import random
|
||||||
import secrets
|
import secrets
|
||||||
|
import shutil
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import time
|
||||||
|
|
||||||
# Ensure we can import meshchatx
|
# Ensure we can import meshchatx
|
||||||
sys.path.append(os.getcwd())
|
sys.path.append(os.getcwd())
|
||||||
|
|
||||||
import json
|
import json
|
||||||
|
|
||||||
from meshchatx.src.backend.database import Database
|
from meshchatx.src.backend.database import Database
|
||||||
from meshchatx.src.backend.identity_manager import IdentityManager
|
|
||||||
from meshchatx.src.backend.database.telephone import TelephoneDAO
|
from meshchatx.src.backend.database.telephone import TelephoneDAO
|
||||||
|
from meshchatx.src.backend.identity_manager import IdentityManager
|
||||||
from tests.backend.benchmarking_utils import (
|
from tests.backend.benchmarking_utils import (
|
||||||
benchmark,
|
benchmark,
|
||||||
get_memory_usage_mb,
|
get_memory_usage_mb,
|
||||||
@@ -76,7 +77,7 @@ class BackendBenchmarker:
|
|||||||
"delivery_attempts": 1,
|
"delivery_attempts": 1,
|
||||||
"title": f"Extreme Msg {b + i}",
|
"title": f"Extreme Msg {b + i}",
|
||||||
"content": secrets.token_bytes(
|
"content": secrets.token_bytes(
|
||||||
1024
|
1024,
|
||||||
).hex(), # 2KB hex string
|
).hex(), # 2KB hex string
|
||||||
"fields": json.dumps({"test": "data" * 10}),
|
"fields": json.dumps({"test": "data" * 10}),
|
||||||
"timestamp": time.time() - (total_messages - (b + i)),
|
"timestamp": time.time() - (total_messages - (b + i)),
|
||||||
@@ -87,13 +88,15 @@ class BackendBenchmarker:
|
|||||||
}
|
}
|
||||||
self.db.messages.upsert_lxmf_message(msg)
|
self.db.messages.upsert_lxmf_message(msg)
|
||||||
print(
|
print(
|
||||||
f" Progress: {b + batch_size}/{total_messages} messages inserted..."
|
f" Progress: {b + batch_size}/{total_messages} messages inserted...",
|
||||||
)
|
)
|
||||||
|
|
||||||
@benchmark("EXTREME: Search 100k Messages (Wildcard)", iterations=5)
|
@benchmark("EXTREME: Search 100k Messages (Wildcard)", iterations=5)
|
||||||
def run_extreme_search():
|
def run_extreme_search():
|
||||||
return self.db.messages.get_conversation_messages(
|
return self.db.messages.get_conversation_messages(
|
||||||
peer_hashes[0], limit=100, offset=50000
|
peer_hashes[0],
|
||||||
|
limit=100,
|
||||||
|
offset=50000,
|
||||||
)
|
)
|
||||||
|
|
||||||
_, res_flood = run_extreme_flood()
|
_, res_flood = run_extreme_flood()
|
||||||
@@ -115,7 +118,7 @@ class BackendBenchmarker:
|
|||||||
data = {
|
data = {
|
||||||
"destination_hash": secrets.token_hex(16),
|
"destination_hash": secrets.token_hex(16),
|
||||||
"aspect": random.choice(
|
"aspect": random.choice(
|
||||||
["lxmf.delivery", "lxst.telephony", "group.chat"]
|
["lxmf.delivery", "lxst.telephony", "group.chat"],
|
||||||
),
|
),
|
||||||
"identity_hash": secrets.token_hex(16),
|
"identity_hash": secrets.token_hex(16),
|
||||||
"identity_public_key": secrets.token_hex(32),
|
"identity_public_key": secrets.token_hex(32),
|
||||||
@@ -130,7 +133,9 @@ class BackendBenchmarker:
|
|||||||
@benchmark("EXTREME: Filter 50k Announces (Complex)", iterations=10)
|
@benchmark("EXTREME: Filter 50k Announces (Complex)", iterations=10)
|
||||||
def run_ann_filter():
|
def run_ann_filter():
|
||||||
return self.db.announces.get_filtered_announces(
|
return self.db.announces.get_filtered_announces(
|
||||||
aspect="lxmf.delivery", limit=100, offset=25000
|
aspect="lxmf.delivery",
|
||||||
|
limit=100,
|
||||||
|
offset=25000,
|
||||||
)
|
)
|
||||||
|
|
||||||
_, res_flood = run_ann_flood()
|
_, res_flood = run_ann_flood()
|
||||||
@@ -164,7 +169,8 @@ class BackendBenchmarker:
|
|||||||
@benchmark("Database Initialization", iterations=5)
|
@benchmark("Database Initialization", iterations=5)
|
||||||
def run():
|
def run():
|
||||||
tmp_db_path = os.path.join(
|
tmp_db_path = os.path.join(
|
||||||
self.temp_dir, f"init_test_{random.randint(0, 1000)}.db"
|
self.temp_dir,
|
||||||
|
f"init_test_{random.randint(0, 1000)}.db",
|
||||||
)
|
)
|
||||||
db = Database(tmp_db_path)
|
db = Database(tmp_db_path)
|
||||||
db.initialize()
|
db.initialize()
|
||||||
@@ -210,7 +216,9 @@ class BackendBenchmarker:
|
|||||||
@benchmark("Get Messages for Conversation (offset 500)", iterations=20)
|
@benchmark("Get Messages for Conversation (offset 500)", iterations=20)
|
||||||
def get_messages():
|
def get_messages():
|
||||||
return self.db.messages.get_conversation_messages(
|
return self.db.messages.get_conversation_messages(
|
||||||
peer_hashes[0], limit=50, offset=500
|
peer_hashes[0],
|
||||||
|
limit=50,
|
||||||
|
offset=500,
|
||||||
)
|
)
|
||||||
|
|
||||||
_, res = upsert_batch()
|
_, res = upsert_batch()
|
||||||
@@ -295,7 +303,7 @@ class BackendBenchmarker:
|
|||||||
print(f"{'-' * 40}-|-{'-' * 10}-|-{'-' * 10}")
|
print(f"{'-' * 40}-|-{'-' * 10}-|-{'-' * 10}")
|
||||||
for r in self.results:
|
for r in self.results:
|
||||||
print(
|
print(
|
||||||
f"{r.name:40} | {r.duration_ms:8.2f} ms | {r.memory_delta_mb:8.2f} MB"
|
f"{r.name:40} | {r.duration_ms:8.2f} ms | {r.memory_delta_mb:8.2f} MB",
|
||||||
)
|
)
|
||||||
print(f"{'=' * 59}")
|
print(f"{'=' * 59}")
|
||||||
print(f"Final Memory Usage: {get_memory_usage_mb():.2f} MB")
|
print(f"Final Memory Usage: {get_memory_usage_mb():.2f} MB")
|
||||||
@@ -306,7 +314,9 @@ if __name__ == "__main__":
|
|||||||
|
|
||||||
parser = argparse.ArgumentParser(description="MeshChatX Backend Benchmarker")
|
parser = argparse.ArgumentParser(description="MeshChatX Backend Benchmarker")
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--extreme", action="store_true", help="Run extreme stress tests"
|
"--extreme",
|
||||||
|
action="store_true",
|
||||||
|
help="Run extreme stress tests",
|
||||||
)
|
)
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
import os
|
import os
|
||||||
import tempfile
|
import tempfile
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
|
from meshchatx.src.backend.database.announces import AnnounceDAO
|
||||||
from meshchatx.src.backend.database.provider import DatabaseProvider
|
from meshchatx.src.backend.database.provider import DatabaseProvider
|
||||||
from meshchatx.src.backend.database.schema import DatabaseSchema
|
from meshchatx.src.backend.database.schema import DatabaseSchema
|
||||||
from meshchatx.src.backend.database.announces import AnnounceDAO
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
@@ -37,7 +39,7 @@ def test_get_filtered_announces_identity_hash(announce_dao):
|
|||||||
"rssi": -50,
|
"rssi": -50,
|
||||||
"snr": 10,
|
"snr": 10,
|
||||||
"quality": 1.0,
|
"quality": 1.0,
|
||||||
}
|
},
|
||||||
)
|
)
|
||||||
announce_dao.upsert_announce(
|
announce_dao.upsert_announce(
|
||||||
{
|
{
|
||||||
@@ -49,7 +51,7 @@ def test_get_filtered_announces_identity_hash(announce_dao):
|
|||||||
"rssi": -50,
|
"rssi": -50,
|
||||||
"snr": 10,
|
"snr": 10,
|
||||||
"quality": 1.0,
|
"quality": 1.0,
|
||||||
}
|
},
|
||||||
)
|
)
|
||||||
announce_dao.upsert_announce(
|
announce_dao.upsert_announce(
|
||||||
{
|
{
|
||||||
@@ -61,7 +63,7 @@ def test_get_filtered_announces_identity_hash(announce_dao):
|
|||||||
"rssi": -50,
|
"rssi": -50,
|
||||||
"snr": 10,
|
"snr": 10,
|
||||||
"quality": 1.0,
|
"quality": 1.0,
|
||||||
}
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
# Test filtering by identity_hash
|
# Test filtering by identity_hash
|
||||||
@@ -71,7 +73,8 @@ def test_get_filtered_announces_identity_hash(announce_dao):
|
|||||||
|
|
||||||
# Test filtering by identity_hash and aspect
|
# Test filtering by identity_hash and aspect
|
||||||
results = announce_dao.get_filtered_announces(
|
results = announce_dao.get_filtered_announces(
|
||||||
identity_hash="ident1", aspect="lxmf.propagation"
|
identity_hash="ident1",
|
||||||
|
aspect="lxmf.propagation",
|
||||||
)
|
)
|
||||||
assert len(results) == 1
|
assert len(results) == 1
|
||||||
assert results[0]["destination_hash"] == "dest1"
|
assert results[0]["destination_hash"] == "dest1"
|
||||||
@@ -89,6 +92,7 @@ def test_get_filtered_announces_robustness(announce_dao):
|
|||||||
|
|
||||||
# Test with multiple filters that yield no results
|
# Test with multiple filters that yield no results
|
||||||
results = announce_dao.get_filtered_announces(
|
results = announce_dao.get_filtered_announces(
|
||||||
identity_hash="ident1", aspect="non_existent_aspect"
|
identity_hash="ident1",
|
||||||
|
aspect="non_existent_aspect",
|
||||||
)
|
)
|
||||||
assert len(results) == 0
|
assert len(results) == 0
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
|
import asyncio
|
||||||
|
import json
|
||||||
import shutil
|
import shutil
|
||||||
import tempfile
|
import tempfile
|
||||||
import pytest
|
|
||||||
import json
|
|
||||||
from unittest.mock import MagicMock, patch
|
from unittest.mock import MagicMock, patch
|
||||||
from meshchatx.meshchat import ReticulumMeshChat
|
|
||||||
|
import pytest
|
||||||
import RNS
|
import RNS
|
||||||
import asyncio
|
|
||||||
|
from meshchatx.meshchat import ReticulumMeshChat
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
import shutil
|
import shutil
|
||||||
import tempfile
|
import tempfile
|
||||||
import pytest
|
|
||||||
from unittest.mock import MagicMock, patch
|
from unittest.mock import MagicMock, patch
|
||||||
from meshchatx.meshchat import ReticulumMeshChat
|
|
||||||
|
import pytest
|
||||||
import RNS
|
import RNS
|
||||||
|
|
||||||
|
from meshchatx.meshchat import ReticulumMeshChat
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def temp_dir():
|
def temp_dir():
|
||||||
@@ -36,43 +38,43 @@ async def test_app_status_endpoints(mock_rns_minimal, temp_dir):
|
|||||||
with ExitStack() as stack:
|
with ExitStack() as stack:
|
||||||
# Patch all dependencies
|
# Patch all dependencies
|
||||||
stack.enter_context(
|
stack.enter_context(
|
||||||
patch("meshchatx.src.backend.identity_context.MessageHandler")
|
patch("meshchatx.src.backend.identity_context.MessageHandler"),
|
||||||
)
|
)
|
||||||
stack.enter_context(
|
stack.enter_context(
|
||||||
patch("meshchatx.src.backend.identity_context.AnnounceManager")
|
patch("meshchatx.src.backend.identity_context.AnnounceManager"),
|
||||||
)
|
)
|
||||||
stack.enter_context(
|
stack.enter_context(
|
||||||
patch("meshchatx.src.backend.identity_context.ArchiverManager")
|
patch("meshchatx.src.backend.identity_context.ArchiverManager"),
|
||||||
)
|
)
|
||||||
stack.enter_context(patch("meshchatx.src.backend.identity_context.MapManager"))
|
stack.enter_context(patch("meshchatx.src.backend.identity_context.MapManager"))
|
||||||
stack.enter_context(patch("meshchatx.src.backend.identity_context.DocsManager"))
|
stack.enter_context(patch("meshchatx.src.backend.identity_context.DocsManager"))
|
||||||
stack.enter_context(
|
stack.enter_context(
|
||||||
patch("meshchatx.src.backend.identity_context.NomadNetworkManager")
|
patch("meshchatx.src.backend.identity_context.NomadNetworkManager"),
|
||||||
)
|
)
|
||||||
stack.enter_context(
|
stack.enter_context(
|
||||||
patch("meshchatx.src.backend.identity_context.TelephoneManager")
|
patch("meshchatx.src.backend.identity_context.TelephoneManager"),
|
||||||
)
|
)
|
||||||
stack.enter_context(
|
stack.enter_context(
|
||||||
patch("meshchatx.src.backend.identity_context.VoicemailManager")
|
patch("meshchatx.src.backend.identity_context.VoicemailManager"),
|
||||||
)
|
)
|
||||||
stack.enter_context(
|
stack.enter_context(
|
||||||
patch("meshchatx.src.backend.identity_context.RingtoneManager")
|
patch("meshchatx.src.backend.identity_context.RingtoneManager"),
|
||||||
)
|
)
|
||||||
stack.enter_context(patch("meshchatx.src.backend.identity_context.RNCPHandler"))
|
stack.enter_context(patch("meshchatx.src.backend.identity_context.RNCPHandler"))
|
||||||
stack.enter_context(
|
stack.enter_context(
|
||||||
patch("meshchatx.src.backend.identity_context.RNStatusHandler")
|
patch("meshchatx.src.backend.identity_context.RNStatusHandler"),
|
||||||
)
|
)
|
||||||
stack.enter_context(
|
stack.enter_context(
|
||||||
patch("meshchatx.src.backend.identity_context.RNProbeHandler")
|
patch("meshchatx.src.backend.identity_context.RNProbeHandler"),
|
||||||
)
|
)
|
||||||
stack.enter_context(
|
stack.enter_context(
|
||||||
patch("meshchatx.src.backend.identity_context.TranslatorHandler")
|
patch("meshchatx.src.backend.identity_context.TranslatorHandler"),
|
||||||
)
|
)
|
||||||
stack.enter_context(
|
stack.enter_context(
|
||||||
patch("meshchatx.src.backend.identity_context.CommunityInterfacesManager")
|
patch("meshchatx.src.backend.identity_context.CommunityInterfacesManager"),
|
||||||
)
|
)
|
||||||
stack.enter_context(
|
stack.enter_context(
|
||||||
patch("meshchatx.src.backend.sideband_commands.SidebandCommands")
|
patch("meshchatx.src.backend.sideband_commands.SidebandCommands"),
|
||||||
)
|
)
|
||||||
stack.enter_context(patch("meshchatx.meshchat.Telemeter"))
|
stack.enter_context(patch("meshchatx.meshchat.Telemeter"))
|
||||||
stack.enter_context(patch("meshchatx.meshchat.CrashRecovery"))
|
stack.enter_context(patch("meshchatx.meshchat.CrashRecovery"))
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import unittest
|
import hashlib
|
||||||
|
import json
|
||||||
import os
|
import os
|
||||||
import shutil
|
import shutil
|
||||||
import tempfile
|
import tempfile
|
||||||
import json
|
import unittest
|
||||||
import hashlib
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
@@ -55,7 +55,7 @@ class TestBackendIntegrity(unittest.TestCase):
|
|||||||
def test_manifest_generation(self):
|
def test_manifest_generation(self):
|
||||||
"""Test that the build script logic produces a valid manifest."""
|
"""Test that the build script logic produces a valid manifest."""
|
||||||
manifest_path = self.generate_manifest()
|
manifest_path = self.generate_manifest()
|
||||||
with open(manifest_path, "r") as f:
|
with open(manifest_path) as f:
|
||||||
manifest = json.load(f)
|
manifest = json.load(f)
|
||||||
|
|
||||||
self.assertEqual(len(manifest["files"]), 2)
|
self.assertEqual(len(manifest["files"]), 2)
|
||||||
@@ -66,7 +66,7 @@ class TestBackendIntegrity(unittest.TestCase):
|
|||||||
def test_tampering_detection_logic(self):
|
def test_tampering_detection_logic(self):
|
||||||
"""Test that modifying a file changes its hash (logic check)."""
|
"""Test that modifying a file changes its hash (logic check)."""
|
||||||
manifest_path = self.generate_manifest()
|
manifest_path = self.generate_manifest()
|
||||||
with open(manifest_path, "r") as f:
|
with open(manifest_path) as f:
|
||||||
manifest = json.load(f)
|
manifest = json.load(f)
|
||||||
|
|
||||||
old_hash = manifest["files"]["ReticulumMeshChatX"]
|
old_hash = manifest["files"]["ReticulumMeshChatX"]
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
|
import json
|
||||||
import shutil
|
import shutil
|
||||||
import tempfile
|
import tempfile
|
||||||
|
from unittest.mock import AsyncMock, MagicMock, patch
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
import json
|
|
||||||
from unittest.mock import MagicMock, patch, AsyncMock
|
|
||||||
from meshchatx.meshchat import ReticulumMeshChat
|
|
||||||
import RNS
|
import RNS
|
||||||
|
|
||||||
|
from meshchatx.meshchat import ReticulumMeshChat
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def temp_dir():
|
def temp_dir():
|
||||||
@@ -74,7 +76,7 @@ async def test_banish_identity_with_blackhole(mock_rns_minimal, temp_dir):
|
|||||||
|
|
||||||
# Verify DB call
|
# Verify DB call
|
||||||
app_instance.database.misc.add_blocked_destination.assert_called_with(
|
app_instance.database.misc.add_blocked_destination.assert_called_with(
|
||||||
target_hash
|
target_hash,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Verify RNS blackhole call
|
# Verify RNS blackhole call
|
||||||
@@ -100,7 +102,7 @@ async def test_banish_identity_with_resolution(mock_rns_minimal, temp_dir):
|
|||||||
|
|
||||||
# Mock identity resolution
|
# Mock identity resolution
|
||||||
app_instance.database.announces.get_announce_by_hash.return_value = {
|
app_instance.database.announces.get_announce_by_hash.return_value = {
|
||||||
"identity_hash": ident_hash
|
"identity_hash": ident_hash,
|
||||||
}
|
}
|
||||||
|
|
||||||
request = MagicMock()
|
request = MagicMock()
|
||||||
@@ -147,7 +149,7 @@ async def test_banish_identity_disabled_integration(mock_rns_minimal, temp_dir):
|
|||||||
|
|
||||||
# DB call should still happen
|
# DB call should still happen
|
||||||
app_instance.database.misc.add_blocked_destination.assert_called_with(
|
app_instance.database.misc.add_blocked_destination.assert_called_with(
|
||||||
target_hash
|
target_hash,
|
||||||
)
|
)
|
||||||
|
|
||||||
# RNS blackhole call should NOT happen
|
# RNS blackhole call should NOT happen
|
||||||
@@ -189,7 +191,7 @@ async def test_lift_banishment(mock_rns_minimal, temp_dir):
|
|||||||
|
|
||||||
# Verify DB call
|
# Verify DB call
|
||||||
app_instance.database.misc.delete_blocked_destination.assert_called_with(
|
app_instance.database.misc.delete_blocked_destination.assert_called_with(
|
||||||
target_hash
|
target_hash,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Verify RNS unblackhole call
|
# Verify RNS unblackhole call
|
||||||
@@ -213,7 +215,7 @@ async def test_get_blackhole_list(mock_rns_minimal, temp_dir):
|
|||||||
"source": b"\x02" * 32,
|
"source": b"\x02" * 32,
|
||||||
"until": 1234567890,
|
"until": 1234567890,
|
||||||
"reason": "Spam",
|
"reason": "Spam",
|
||||||
}
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
request = MagicMock()
|
request = MagicMock()
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import pytest
|
|
||||||
from unittest.mock import MagicMock, patch
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
from meshchatx.src.backend.community_interfaces import CommunityInterfacesManager
|
from meshchatx.src.backend.community_interfaces import CommunityInterfacesManager
|
||||||
from meshchatx.src.backend.rnstatus_handler import RNStatusHandler
|
from meshchatx.src.backend.rnstatus_handler import RNStatusHandler
|
||||||
|
|
||||||
@@ -42,7 +44,7 @@ async def test_rnstatus_integration_simulated():
|
|||||||
"rxb": 0,
|
"rxb": 0,
|
||||||
"txb": 0,
|
"txb": 0,
|
||||||
},
|
},
|
||||||
]
|
],
|
||||||
}
|
}
|
||||||
|
|
||||||
handler = RNStatusHandler(mock_reticulum)
|
handler = RNStatusHandler(mock_reticulum)
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import os
|
import os
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
|
from meshchatx.src.backend.database.contacts import ContactsDAO
|
||||||
from meshchatx.src.backend.database.provider import DatabaseProvider
|
from meshchatx.src.backend.database.provider import DatabaseProvider
|
||||||
from meshchatx.src.backend.database.schema import DatabaseSchema
|
from meshchatx.src.backend.database.schema import DatabaseSchema
|
||||||
from meshchatx.src.backend.database.contacts import ContactsDAO
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
@@ -39,7 +41,8 @@ def test_contacts_with_custom_image(db_provider):
|
|||||||
|
|
||||||
# Test updating contact image
|
# Test updating contact image
|
||||||
contacts_dao.update_contact(
|
contacts_dao.update_contact(
|
||||||
contact["id"], custom_image="data:image/png;base64,updateddata"
|
contact["id"],
|
||||||
|
custom_image="data:image/png;base64,updateddata",
|
||||||
)
|
)
|
||||||
|
|
||||||
contact = contacts_dao.get_contact(contact["id"])
|
contact = contacts_dao.get_contact(contact["id"])
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import unittest
|
import io
|
||||||
import os
|
import os
|
||||||
import shutil
|
import shutil
|
||||||
import tempfile
|
|
||||||
import sys
|
|
||||||
import io
|
|
||||||
import sqlite3
|
import sqlite3
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
|
||||||
from meshchatx.src.backend.recovery.crash_recovery import CrashRecovery
|
from meshchatx.src.backend.recovery.crash_recovery import CrashRecovery
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import unittest
|
|
||||||
import os
|
import os
|
||||||
import tempfile
|
|
||||||
import shutil
|
import shutil
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
|
||||||
from meshchatx.src.backend.database.provider import DatabaseProvider
|
from meshchatx.src.backend.database.provider import DatabaseProvider
|
||||||
from meshchatx.src.backend.database.schema import DatabaseSchema
|
from meshchatx.src.backend.database.schema import DatabaseSchema
|
||||||
|
|
||||||
@@ -42,7 +43,8 @@ class TestDatabaseRobustness(unittest.TestCase):
|
|||||||
)
|
)
|
||||||
""")
|
""")
|
||||||
self.provider.execute(
|
self.provider.execute(
|
||||||
"INSERT INTO config (key, value) VALUES (?, ?)", ("database_version", "1")
|
"INSERT INTO config (key, value) VALUES (?, ?)",
|
||||||
|
("database_version", "1"),
|
||||||
)
|
)
|
||||||
|
|
||||||
# 3. Attempt initialization.
|
# 3. Attempt initialization.
|
||||||
@@ -77,7 +79,7 @@ class TestDatabaseRobustness(unittest.TestCase):
|
|||||||
|
|
||||||
# 3. Version should now be set to LATEST
|
# 3. Version should now be set to LATEST
|
||||||
row = self.provider.fetchone(
|
row = self.provider.fetchone(
|
||||||
"SELECT value FROM config WHERE key = 'database_version'"
|
"SELECT value FROM config WHERE key = 'database_version'",
|
||||||
)
|
)
|
||||||
self.assertEqual(int(row["value"]), self.schema.LATEST_VERSION)
|
self.assertEqual(int(row["value"]), self.schema.LATEST_VERSION)
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import shutil
|
|||||||
import tempfile
|
import tempfile
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from meshchatx.src.backend.database import Database
|
from meshchatx.src.backend.database import Database
|
||||||
|
|
||||||
|
|
||||||
@@ -20,7 +21,8 @@ def test_database_snapshot_creation(temp_dir):
|
|||||||
|
|
||||||
# Add some data
|
# Add some data
|
||||||
db.execute_sql(
|
db.execute_sql(
|
||||||
"INSERT INTO config (key, value) VALUES (?, ?)", ("test_key", "test_value")
|
"INSERT INTO config (key, value) VALUES (?, ?)",
|
||||||
|
("test_key", "test_value"),
|
||||||
)
|
)
|
||||||
|
|
||||||
# Create snapshot
|
# Create snapshot
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import time
|
|
||||||
import pytest
|
|
||||||
import logging
|
import logging
|
||||||
from meshchatx.src.backend.persistent_log_handler import PersistentLogHandler
|
import time
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
from meshchatx.src.backend.database import Database
|
from meshchatx.src.backend.database import Database
|
||||||
|
from meshchatx.src.backend.persistent_log_handler import PersistentLogHandler
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
|
|||||||
@@ -141,7 +141,7 @@ def create_mock_zip(zip_path, file_list):
|
|||||||
)
|
)
|
||||||
@given(
|
@given(
|
||||||
root_folder_name=st.text(min_size=1, max_size=50).filter(
|
root_folder_name=st.text(min_size=1, max_size=50).filter(
|
||||||
lambda x: "/" not in x and x not in [".", ".."]
|
lambda x: "/" not in x and x not in [".", ".."],
|
||||||
),
|
),
|
||||||
docs_file=st.text(min_size=1, max_size=50).filter(lambda x: "/" not in x),
|
docs_file=st.text(min_size=1, max_size=50).filter(lambda x: "/" not in x),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ from unittest.mock import MagicMock, patch
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
import RNS
|
import RNS
|
||||||
|
|
||||||
from meshchatx.meshchat import ReticulumMeshChat
|
from meshchatx.meshchat import ReticulumMeshChat
|
||||||
|
|
||||||
|
|
||||||
@@ -39,7 +40,9 @@ def mock_rns():
|
|||||||
patch.object(MockIdentityClass, "from_file", return_value=mock_id_instance),
|
patch.object(MockIdentityClass, "from_file", return_value=mock_id_instance),
|
||||||
patch.object(MockIdentityClass, "recall", return_value=mock_id_instance),
|
patch.object(MockIdentityClass, "recall", return_value=mock_id_instance),
|
||||||
patch.object(
|
patch.object(
|
||||||
MockIdentityClass, "from_bytes", return_value=mock_id_instance
|
MockIdentityClass,
|
||||||
|
"from_bytes",
|
||||||
|
return_value=mock_id_instance,
|
||||||
),
|
),
|
||||||
):
|
):
|
||||||
mock_transport.interfaces = []
|
mock_transport.interfaces = []
|
||||||
@@ -73,7 +76,7 @@ def test_emergency_mode_startup_logic(mock_rns, temp_dir):
|
|||||||
patch("meshchatx.src.backend.identity_context.DocsManager"),
|
patch("meshchatx.src.backend.identity_context.DocsManager"),
|
||||||
patch("meshchatx.src.backend.identity_context.NomadNetworkManager"),
|
patch("meshchatx.src.backend.identity_context.NomadNetworkManager"),
|
||||||
patch(
|
patch(
|
||||||
"meshchatx.src.backend.identity_context.TelephoneManager"
|
"meshchatx.src.backend.identity_context.TelephoneManager",
|
||||||
) as mock_tel_class,
|
) as mock_tel_class,
|
||||||
patch("meshchatx.src.backend.identity_context.VoicemailManager"),
|
patch("meshchatx.src.backend.identity_context.VoicemailManager"),
|
||||||
patch("meshchatx.src.backend.identity_context.RingtoneManager"),
|
patch("meshchatx.src.backend.identity_context.RingtoneManager"),
|
||||||
@@ -83,10 +86,10 @@ def test_emergency_mode_startup_logic(mock_rns, temp_dir):
|
|||||||
patch("meshchatx.src.backend.identity_context.TranslatorHandler"),
|
patch("meshchatx.src.backend.identity_context.TranslatorHandler"),
|
||||||
patch("meshchatx.src.backend.identity_context.CommunityInterfacesManager"),
|
patch("meshchatx.src.backend.identity_context.CommunityInterfacesManager"),
|
||||||
patch(
|
patch(
|
||||||
"meshchatx.src.backend.identity_context.IntegrityManager"
|
"meshchatx.src.backend.identity_context.IntegrityManager",
|
||||||
) as mock_integrity_class,
|
) as mock_integrity_class,
|
||||||
patch(
|
patch(
|
||||||
"meshchatx.src.backend.identity_context.IdentityContext.start_background_threads"
|
"meshchatx.src.backend.identity_context.IdentityContext.start_background_threads",
|
||||||
),
|
),
|
||||||
):
|
):
|
||||||
# Initialize app in emergency mode
|
# Initialize app in emergency mode
|
||||||
@@ -139,7 +142,7 @@ def test_emergency_mode_env_var(mock_rns, temp_dir):
|
|||||||
patch("meshchatx.src.backend.identity_context.TranslatorHandler"),
|
patch("meshchatx.src.backend.identity_context.TranslatorHandler"),
|
||||||
patch("meshchatx.src.backend.identity_context.CommunityInterfacesManager"),
|
patch("meshchatx.src.backend.identity_context.CommunityInterfacesManager"),
|
||||||
patch(
|
patch(
|
||||||
"meshchatx.src.backend.identity_context.IdentityContext.start_background_threads"
|
"meshchatx.src.backend.identity_context.IdentityContext.start_background_threads",
|
||||||
),
|
),
|
||||||
):
|
):
|
||||||
# We need to simulate the argparse processing that happens in main()
|
# We need to simulate the argparse processing that happens in main()
|
||||||
@@ -170,7 +173,7 @@ def test_normal_mode_startup_logic(mock_rns, temp_dir):
|
|||||||
patch("meshchatx.src.backend.identity_context.DocsManager"),
|
patch("meshchatx.src.backend.identity_context.DocsManager"),
|
||||||
patch("meshchatx.src.backend.identity_context.NomadNetworkManager"),
|
patch("meshchatx.src.backend.identity_context.NomadNetworkManager"),
|
||||||
patch(
|
patch(
|
||||||
"meshchatx.src.backend.identity_context.TelephoneManager"
|
"meshchatx.src.backend.identity_context.TelephoneManager",
|
||||||
) as mock_tel_class,
|
) as mock_tel_class,
|
||||||
patch("meshchatx.src.backend.identity_context.VoicemailManager"),
|
patch("meshchatx.src.backend.identity_context.VoicemailManager"),
|
||||||
patch("meshchatx.src.backend.identity_context.RingtoneManager"),
|
patch("meshchatx.src.backend.identity_context.RingtoneManager"),
|
||||||
@@ -180,10 +183,10 @@ def test_normal_mode_startup_logic(mock_rns, temp_dir):
|
|||||||
patch("meshchatx.src.backend.identity_context.TranslatorHandler"),
|
patch("meshchatx.src.backend.identity_context.TranslatorHandler"),
|
||||||
patch("meshchatx.src.backend.identity_context.CommunityInterfacesManager"),
|
patch("meshchatx.src.backend.identity_context.CommunityInterfacesManager"),
|
||||||
patch(
|
patch(
|
||||||
"meshchatx.src.backend.identity_context.IntegrityManager"
|
"meshchatx.src.backend.identity_context.IntegrityManager",
|
||||||
) as mock_integrity_class,
|
) as mock_integrity_class,
|
||||||
patch(
|
patch(
|
||||||
"meshchatx.src.backend.identity_context.IdentityContext.start_background_threads"
|
"meshchatx.src.backend.identity_context.IdentityContext.start_background_threads",
|
||||||
),
|
),
|
||||||
):
|
):
|
||||||
# Configure mocks BEFORE instantiating app
|
# Configure mocks BEFORE instantiating app
|
||||||
|
|||||||
@@ -12,6 +12,11 @@ from hypothesis import strategies as st
|
|||||||
|
|
||||||
from meshchatx.meshchat import ReticulumMeshChat
|
from meshchatx.meshchat import ReticulumMeshChat
|
||||||
from meshchatx.src.backend.interface_config_parser import InterfaceConfigParser
|
from meshchatx.src.backend.interface_config_parser import InterfaceConfigParser
|
||||||
|
from meshchatx.src.backend.lxmf_message_fields import (
|
||||||
|
LxmfAudioField,
|
||||||
|
LxmfFileAttachment,
|
||||||
|
LxmfImageField,
|
||||||
|
)
|
||||||
from meshchatx.src.backend.meshchat_utils import (
|
from meshchatx.src.backend.meshchat_utils import (
|
||||||
parse_lxmf_display_name,
|
parse_lxmf_display_name,
|
||||||
parse_nomadnetwork_node_display_name,
|
parse_nomadnetwork_node_display_name,
|
||||||
@@ -20,11 +25,6 @@ from meshchatx.src.backend.nomadnet_utils import (
|
|||||||
convert_nomadnet_field_data_to_map,
|
convert_nomadnet_field_data_to_map,
|
||||||
convert_nomadnet_string_data_to_map,
|
convert_nomadnet_string_data_to_map,
|
||||||
)
|
)
|
||||||
from meshchatx.src.backend.lxmf_message_fields import (
|
|
||||||
LxmfAudioField,
|
|
||||||
LxmfFileAttachment,
|
|
||||||
LxmfImageField,
|
|
||||||
)
|
|
||||||
from meshchatx.src.backend.telemetry_utils import Telemeter
|
from meshchatx.src.backend.telemetry_utils import Telemeter
|
||||||
|
|
||||||
|
|
||||||
@@ -122,39 +122,39 @@ def mock_app(temp_dir):
|
|||||||
# Mock database and other managers to avoid heavy initialization
|
# Mock database and other managers to avoid heavy initialization
|
||||||
stack.enter_context(patch("meshchatx.src.backend.identity_context.Database"))
|
stack.enter_context(patch("meshchatx.src.backend.identity_context.Database"))
|
||||||
stack.enter_context(
|
stack.enter_context(
|
||||||
patch("meshchatx.src.backend.identity_context.ConfigManager")
|
patch("meshchatx.src.backend.identity_context.ConfigManager"),
|
||||||
)
|
)
|
||||||
stack.enter_context(
|
stack.enter_context(
|
||||||
patch("meshchatx.src.backend.identity_context.MessageHandler")
|
patch("meshchatx.src.backend.identity_context.MessageHandler"),
|
||||||
)
|
)
|
||||||
stack.enter_context(
|
stack.enter_context(
|
||||||
patch("meshchatx.src.backend.identity_context.AnnounceManager")
|
patch("meshchatx.src.backend.identity_context.AnnounceManager"),
|
||||||
)
|
)
|
||||||
stack.enter_context(
|
stack.enter_context(
|
||||||
patch("meshchatx.src.backend.identity_context.ArchiverManager")
|
patch("meshchatx.src.backend.identity_context.ArchiverManager"),
|
||||||
)
|
)
|
||||||
stack.enter_context(patch("meshchatx.src.backend.identity_context.MapManager"))
|
stack.enter_context(patch("meshchatx.src.backend.identity_context.MapManager"))
|
||||||
stack.enter_context(
|
stack.enter_context(
|
||||||
patch("meshchatx.src.backend.identity_context.TelephoneManager")
|
patch("meshchatx.src.backend.identity_context.TelephoneManager"),
|
||||||
)
|
)
|
||||||
stack.enter_context(
|
stack.enter_context(
|
||||||
patch("meshchatx.src.backend.identity_context.VoicemailManager")
|
patch("meshchatx.src.backend.identity_context.VoicemailManager"),
|
||||||
)
|
)
|
||||||
stack.enter_context(
|
stack.enter_context(
|
||||||
patch("meshchatx.src.backend.identity_context.RingtoneManager")
|
patch("meshchatx.src.backend.identity_context.RingtoneManager"),
|
||||||
)
|
)
|
||||||
stack.enter_context(patch("meshchatx.src.backend.identity_context.RNCPHandler"))
|
stack.enter_context(patch("meshchatx.src.backend.identity_context.RNCPHandler"))
|
||||||
stack.enter_context(
|
stack.enter_context(
|
||||||
patch("meshchatx.src.backend.identity_context.RNStatusHandler")
|
patch("meshchatx.src.backend.identity_context.RNStatusHandler"),
|
||||||
)
|
)
|
||||||
stack.enter_context(
|
stack.enter_context(
|
||||||
patch("meshchatx.src.backend.identity_context.RNProbeHandler")
|
patch("meshchatx.src.backend.identity_context.RNProbeHandler"),
|
||||||
)
|
)
|
||||||
stack.enter_context(
|
stack.enter_context(
|
||||||
patch("meshchatx.src.backend.identity_context.TranslatorHandler")
|
patch("meshchatx.src.backend.identity_context.TranslatorHandler"),
|
||||||
)
|
)
|
||||||
stack.enter_context(
|
stack.enter_context(
|
||||||
patch("meshchatx.src.backend.identity_context.CommunityInterfacesManager")
|
patch("meshchatx.src.backend.identity_context.CommunityInterfacesManager"),
|
||||||
)
|
)
|
||||||
mock_async_utils = stack.enter_context(patch("meshchatx.meshchat.AsyncUtils"))
|
mock_async_utils = stack.enter_context(patch("meshchatx.meshchat.AsyncUtils"))
|
||||||
stack.enter_context(patch("LXMF.LXMRouter"))
|
stack.enter_context(patch("LXMF.LXMRouter"))
|
||||||
@@ -171,7 +171,9 @@ def mock_app(temp_dir):
|
|||||||
stack.enter_context(patch("threading.Thread"))
|
stack.enter_context(patch("threading.Thread"))
|
||||||
stack.enter_context(
|
stack.enter_context(
|
||||||
patch.object(
|
patch.object(
|
||||||
ReticulumMeshChat, "announce_loop", new=MagicMock(return_value=None)
|
ReticulumMeshChat,
|
||||||
|
"announce_loop",
|
||||||
|
new=MagicMock(return_value=None),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
stack.enter_context(
|
stack.enter_context(
|
||||||
@@ -183,12 +185,16 @@ def mock_app(temp_dir):
|
|||||||
)
|
)
|
||||||
stack.enter_context(
|
stack.enter_context(
|
||||||
patch.object(
|
patch.object(
|
||||||
ReticulumMeshChat, "crawler_loop", new=MagicMock(return_value=None)
|
ReticulumMeshChat,
|
||||||
|
"crawler_loop",
|
||||||
|
new=MagicMock(return_value=None),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
stack.enter_context(
|
stack.enter_context(
|
||||||
patch.object(
|
patch.object(
|
||||||
ReticulumMeshChat, "auto_backup_loop", new=MagicMock(return_value=None)
|
ReticulumMeshChat,
|
||||||
|
"auto_backup_loop",
|
||||||
|
new=MagicMock(return_value=None),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -196,13 +202,13 @@ def mock_app(temp_dir):
|
|||||||
mock_id.get_private_key = MagicMock(return_value=b"test_private_key")
|
mock_id.get_private_key = MagicMock(return_value=b"test_private_key")
|
||||||
|
|
||||||
stack.enter_context(
|
stack.enter_context(
|
||||||
patch.object(MockIdentityClass, "from_file", return_value=mock_id)
|
patch.object(MockIdentityClass, "from_file", return_value=mock_id),
|
||||||
)
|
)
|
||||||
stack.enter_context(
|
stack.enter_context(
|
||||||
patch.object(MockIdentityClass, "recall", return_value=mock_id)
|
patch.object(MockIdentityClass, "recall", return_value=mock_id),
|
||||||
)
|
)
|
||||||
stack.enter_context(
|
stack.enter_context(
|
||||||
patch.object(MockIdentityClass, "from_bytes", return_value=mock_id)
|
patch.object(MockIdentityClass, "from_bytes", return_value=mock_id),
|
||||||
)
|
)
|
||||||
|
|
||||||
# Make run_async a no-op that doesn't trigger coroutine warnings
|
# Make run_async a no-op that doesn't trigger coroutine warnings
|
||||||
|
|||||||
@@ -29,39 +29,39 @@ def mock_app(temp_dir):
|
|||||||
with ExitStack() as stack:
|
with ExitStack() as stack:
|
||||||
stack.enter_context(patch("meshchatx.src.backend.identity_context.Database"))
|
stack.enter_context(patch("meshchatx.src.backend.identity_context.Database"))
|
||||||
stack.enter_context(
|
stack.enter_context(
|
||||||
patch("meshchatx.src.backend.identity_context.ConfigManager")
|
patch("meshchatx.src.backend.identity_context.ConfigManager"),
|
||||||
)
|
)
|
||||||
stack.enter_context(
|
stack.enter_context(
|
||||||
patch("meshchatx.src.backend.identity_context.MessageHandler")
|
patch("meshchatx.src.backend.identity_context.MessageHandler"),
|
||||||
)
|
)
|
||||||
stack.enter_context(
|
stack.enter_context(
|
||||||
patch("meshchatx.src.backend.identity_context.AnnounceManager")
|
patch("meshchatx.src.backend.identity_context.AnnounceManager"),
|
||||||
)
|
)
|
||||||
stack.enter_context(
|
stack.enter_context(
|
||||||
patch("meshchatx.src.backend.identity_context.ArchiverManager")
|
patch("meshchatx.src.backend.identity_context.ArchiverManager"),
|
||||||
)
|
)
|
||||||
stack.enter_context(patch("meshchatx.src.backend.identity_context.MapManager"))
|
stack.enter_context(patch("meshchatx.src.backend.identity_context.MapManager"))
|
||||||
stack.enter_context(
|
stack.enter_context(
|
||||||
patch("meshchatx.src.backend.identity_context.TelephoneManager")
|
patch("meshchatx.src.backend.identity_context.TelephoneManager"),
|
||||||
)
|
)
|
||||||
stack.enter_context(
|
stack.enter_context(
|
||||||
patch("meshchatx.src.backend.identity_context.VoicemailManager")
|
patch("meshchatx.src.backend.identity_context.VoicemailManager"),
|
||||||
)
|
)
|
||||||
stack.enter_context(
|
stack.enter_context(
|
||||||
patch("meshchatx.src.backend.identity_context.RingtoneManager")
|
patch("meshchatx.src.backend.identity_context.RingtoneManager"),
|
||||||
)
|
)
|
||||||
stack.enter_context(patch("meshchatx.src.backend.identity_context.RNCPHandler"))
|
stack.enter_context(patch("meshchatx.src.backend.identity_context.RNCPHandler"))
|
||||||
stack.enter_context(
|
stack.enter_context(
|
||||||
patch("meshchatx.src.backend.identity_context.RNStatusHandler")
|
patch("meshchatx.src.backend.identity_context.RNStatusHandler"),
|
||||||
)
|
)
|
||||||
stack.enter_context(
|
stack.enter_context(
|
||||||
patch("meshchatx.src.backend.identity_context.RNProbeHandler")
|
patch("meshchatx.src.backend.identity_context.RNProbeHandler"),
|
||||||
)
|
)
|
||||||
stack.enter_context(
|
stack.enter_context(
|
||||||
patch("meshchatx.src.backend.identity_context.TranslatorHandler")
|
patch("meshchatx.src.backend.identity_context.TranslatorHandler"),
|
||||||
)
|
)
|
||||||
stack.enter_context(
|
stack.enter_context(
|
||||||
patch("meshchatx.src.backend.identity_context.CommunityInterfacesManager")
|
patch("meshchatx.src.backend.identity_context.CommunityInterfacesManager"),
|
||||||
)
|
)
|
||||||
stack.enter_context(patch("meshchatx.meshchat.AsyncUtils"))
|
stack.enter_context(patch("meshchatx.meshchat.AsyncUtils"))
|
||||||
stack.enter_context(patch("LXMF.LXMRouter"))
|
stack.enter_context(patch("LXMF.LXMRouter"))
|
||||||
@@ -76,31 +76,37 @@ def mock_app(temp_dir):
|
|||||||
stack.enter_context(patch("threading.Thread"))
|
stack.enter_context(patch("threading.Thread"))
|
||||||
stack.enter_context(
|
stack.enter_context(
|
||||||
patch.object(
|
patch.object(
|
||||||
ReticulumMeshChat, "announce_loop", new=MagicMock(return_value=None)
|
ReticulumMeshChat,
|
||||||
)
|
"announce_loop",
|
||||||
|
new=MagicMock(return_value=None),
|
||||||
|
),
|
||||||
)
|
)
|
||||||
stack.enter_context(
|
stack.enter_context(
|
||||||
patch.object(
|
patch.object(
|
||||||
ReticulumMeshChat,
|
ReticulumMeshChat,
|
||||||
"announce_sync_propagation_nodes",
|
"announce_sync_propagation_nodes",
|
||||||
new=MagicMock(return_value=None),
|
new=MagicMock(return_value=None),
|
||||||
)
|
),
|
||||||
)
|
)
|
||||||
stack.enter_context(
|
stack.enter_context(
|
||||||
patch.object(
|
patch.object(
|
||||||
ReticulumMeshChat, "crawler_loop", new=MagicMock(return_value=None)
|
ReticulumMeshChat,
|
||||||
)
|
"crawler_loop",
|
||||||
|
new=MagicMock(return_value=None),
|
||||||
|
),
|
||||||
)
|
)
|
||||||
stack.enter_context(
|
stack.enter_context(
|
||||||
patch.object(
|
patch.object(
|
||||||
ReticulumMeshChat, "auto_backup_loop", new=MagicMock(return_value=None)
|
ReticulumMeshChat,
|
||||||
)
|
"auto_backup_loop",
|
||||||
|
new=MagicMock(return_value=None),
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
mock_id = MockIdentityClass()
|
mock_id = MockIdentityClass()
|
||||||
mock_id.get_private_key = MagicMock(return_value=b"test_private_key")
|
mock_id.get_private_key = MagicMock(return_value=b"test_private_key")
|
||||||
stack.enter_context(
|
stack.enter_context(
|
||||||
patch.object(MockIdentityClass, "from_file", return_value=mock_id)
|
patch.object(MockIdentityClass, "from_file", return_value=mock_id),
|
||||||
)
|
)
|
||||||
|
|
||||||
app = ReticulumMeshChat(
|
app = ReticulumMeshChat(
|
||||||
@@ -136,9 +142,10 @@ def mock_app(temp_dir):
|
|||||||
data=st.recursive(
|
data=st.recursive(
|
||||||
st.one_of(st.none(), st.booleans(), st.floats(), st.text(), st.integers()),
|
st.one_of(st.none(), st.booleans(), st.floats(), st.text(), st.integers()),
|
||||||
lambda children: st.one_of(
|
lambda children: st.one_of(
|
||||||
st.lists(children), st.dictionaries(st.text(), children)
|
st.lists(children),
|
||||||
|
st.dictionaries(st.text(), children),
|
||||||
),
|
),
|
||||||
)
|
),
|
||||||
)
|
)
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_websocket_api_recursive_fuzzing(mock_app, data):
|
async def test_websocket_api_recursive_fuzzing(mock_app, data):
|
||||||
@@ -190,7 +197,8 @@ async def test_lxm_uri_parsing_fuzzing(mock_app, uri):
|
|||||||
# Also test it through the websocket interface if it exists there
|
# Also test it through the websocket interface if it exists there
|
||||||
mock_client = MagicMock()
|
mock_client = MagicMock()
|
||||||
await mock_app.on_websocket_data_received(
|
await mock_app.on_websocket_data_received(
|
||||||
mock_client, {"type": "lxm.ingest_uri", "uri": uri}
|
mock_client,
|
||||||
|
{"type": "lxm.ingest_uri", "uri": uri},
|
||||||
)
|
)
|
||||||
except (KeyError, TypeError, ValueError, AttributeError):
|
except (KeyError, TypeError, ValueError, AttributeError):
|
||||||
pass
|
pass
|
||||||
@@ -232,7 +240,8 @@ def test_lxmf_message_construction_fuzzing(mock_app, content, title, fields):
|
|||||||
@given(
|
@given(
|
||||||
table_name=st.sampled_from(["messages", "announces", "identities", "config"]),
|
table_name=st.sampled_from(["messages", "announces", "identities", "config"]),
|
||||||
data=st.dictionaries(
|
data=st.dictionaries(
|
||||||
st.text(), st.one_of(st.text(), st.integers(), st.binary(), st.none())
|
st.text(),
|
||||||
|
st.one_of(st.text(), st.integers(), st.binary(), st.none()),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
def test_database_record_fuzzing(mock_app, table_name, data):
|
def test_database_record_fuzzing(mock_app, table_name, data):
|
||||||
@@ -266,10 +275,10 @@ def test_database_record_fuzzing(mock_app, table_name, data):
|
|||||||
"map_default_lat",
|
"map_default_lat",
|
||||||
"map_default_lon",
|
"map_default_lon",
|
||||||
"lxmf_inbound_stamp_cost",
|
"lxmf_inbound_stamp_cost",
|
||||||
]
|
],
|
||||||
),
|
),
|
||||||
st.one_of(st.text(), st.integers(), st.booleans(), st.none()),
|
st.one_of(st.text(), st.integers(), st.booleans(), st.none()),
|
||||||
)
|
),
|
||||||
)
|
)
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_config_update_fuzzing(mock_app, config_updates):
|
async def test_config_update_fuzzing(mock_app, config_updates):
|
||||||
@@ -288,7 +297,10 @@ async def test_config_update_fuzzing(mock_app, config_updates):
|
|||||||
@given(destination_hash=st.text(), content=st.text(), title=st.text())
|
@given(destination_hash=st.text(), content=st.text(), title=st.text())
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_lxm_generate_paper_uri_fuzzing(
|
async def test_lxm_generate_paper_uri_fuzzing(
|
||||||
mock_app, destination_hash, content, title
|
mock_app,
|
||||||
|
destination_hash,
|
||||||
|
content,
|
||||||
|
title,
|
||||||
):
|
):
|
||||||
"""Fuzz lxm.generate_paper_uri WebSocket handler."""
|
"""Fuzz lxm.generate_paper_uri WebSocket handler."""
|
||||||
mock_client = MagicMock()
|
mock_client = MagicMock()
|
||||||
@@ -410,7 +422,10 @@ def test_on_lxmf_delivery_fuzzing(mock_app, content, title):
|
|||||||
app_data=st.binary(min_size=0, max_size=1000),
|
app_data=st.binary(min_size=0, max_size=1000),
|
||||||
)
|
)
|
||||||
def test_on_lxmf_announce_received_fuzzing(
|
def test_on_lxmf_announce_received_fuzzing(
|
||||||
mock_app, aspect, destination_hash, app_data
|
mock_app,
|
||||||
|
aspect,
|
||||||
|
destination_hash,
|
||||||
|
app_data,
|
||||||
):
|
):
|
||||||
"""Fuzz the announce received handler."""
|
"""Fuzz the announce received handler."""
|
||||||
try:
|
try:
|
||||||
@@ -457,7 +472,10 @@ def test_telemeter_roundtrip_fuzzing(battery, uptime, load, temperature):
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
t = Telemeter(
|
t = Telemeter(
|
||||||
battery=battery, uptime=uptime, load=load, temperature=temperature
|
battery=battery,
|
||||||
|
uptime=uptime,
|
||||||
|
load=load,
|
||||||
|
temperature=temperature,
|
||||||
)
|
)
|
||||||
packed = t.pack()
|
packed = t.pack()
|
||||||
unpacked = Telemeter.from_packed(packed)
|
unpacked = Telemeter.from_packed(packed)
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import os
|
import os
|
||||||
import shutil
|
import shutil
|
||||||
import tempfile
|
import tempfile
|
||||||
from unittest.mock import AsyncMock, MagicMock, patch
|
|
||||||
from contextlib import ExitStack
|
from contextlib import ExitStack
|
||||||
|
from unittest.mock import AsyncMock, MagicMock, patch
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
import RNS
|
import RNS
|
||||||
|
|
||||||
@@ -64,17 +65,19 @@ def mock_rns():
|
|||||||
# Mock class methods on MockIdentityClass
|
# Mock class methods on MockIdentityClass
|
||||||
mock_id_instance = MockIdentityClass()
|
mock_id_instance = MockIdentityClass()
|
||||||
mock_id_instance.get_private_key = MagicMock(
|
mock_id_instance.get_private_key = MagicMock(
|
||||||
return_value=b"initial_private_key"
|
return_value=b"initial_private_key",
|
||||||
)
|
)
|
||||||
|
|
||||||
stack.enter_context(
|
stack.enter_context(
|
||||||
patch.object(MockIdentityClass, "from_file", return_value=mock_id_instance)
|
patch.object(MockIdentityClass, "from_file", return_value=mock_id_instance),
|
||||||
)
|
)
|
||||||
stack.enter_context(
|
stack.enter_context(
|
||||||
patch.object(MockIdentityClass, "recall", return_value=mock_id_instance)
|
patch.object(MockIdentityClass, "recall", return_value=mock_id_instance),
|
||||||
)
|
)
|
||||||
stack.enter_context(
|
stack.enter_context(
|
||||||
patch.object(MockIdentityClass, "from_bytes", return_value=mock_id_instance)
|
patch.object(
|
||||||
|
MockIdentityClass, "from_bytes", return_value=mock_id_instance
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
# Access specifically the ones we need to configure
|
# Access specifically the ones we need to configure
|
||||||
@@ -118,7 +121,7 @@ async def test_hotswap_identity_success(mock_rns, temp_dir):
|
|||||||
# Mock methods
|
# Mock methods
|
||||||
app.teardown_identity = MagicMock()
|
app.teardown_identity = MagicMock()
|
||||||
app.setup_identity = MagicMock(
|
app.setup_identity = MagicMock(
|
||||||
side_effect=lambda id: setattr(app, "current_context", mock_context)
|
side_effect=lambda id: setattr(app, "current_context", mock_context),
|
||||||
)
|
)
|
||||||
app.websocket_broadcast = AsyncMock()
|
app.websocket_broadcast = AsyncMock()
|
||||||
|
|
||||||
@@ -164,7 +167,7 @@ async def test_hotswap_identity_keep_alive(mock_rns, temp_dir):
|
|||||||
# Mock methods
|
# Mock methods
|
||||||
app.teardown_identity = MagicMock()
|
app.teardown_identity = MagicMock()
|
||||||
app.setup_identity = MagicMock(
|
app.setup_identity = MagicMock(
|
||||||
side_effect=lambda id: setattr(app, "current_context", mock_context)
|
side_effect=lambda id: setattr(app, "current_context", mock_context),
|
||||||
)
|
)
|
||||||
app.websocket_broadcast = AsyncMock()
|
app.websocket_broadcast = AsyncMock()
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import unittest
|
|
||||||
import shutil
|
import shutil
|
||||||
import tempfile
|
import tempfile
|
||||||
|
import unittest
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from meshchatx.src.backend.integrity_manager import IntegrityManager
|
from meshchatx.src.backend.integrity_manager import IntegrityManager
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
201
tests/backend/test_interface_discovery.py
Normal file
201
tests/backend/test_interface_discovery.py
Normal file
@@ -0,0 +1,201 @@
|
|||||||
|
import json
|
||||||
|
import shutil
|
||||||
|
import tempfile
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
import RNS
|
||||||
|
|
||||||
|
from meshchatx.meshchat import ReticulumMeshChat
|
||||||
|
|
||||||
|
|
||||||
|
class ConfigDict(dict):
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
self.write_called = False
|
||||||
|
|
||||||
|
def write(self):
|
||||||
|
self.write_called = True
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def temp_dir():
|
||||||
|
path = tempfile.mkdtemp()
|
||||||
|
try:
|
||||||
|
yield path
|
||||||
|
finally:
|
||||||
|
shutil.rmtree(path)
|
||||||
|
|
||||||
|
|
||||||
|
def build_identity():
|
||||||
|
identity = MagicMock(spec=RNS.Identity)
|
||||||
|
identity.hash = b"test_hash_32_bytes_long_01234567"
|
||||||
|
identity.hexhash = identity.hash.hex()
|
||||||
|
identity.get_private_key.return_value = b"test_private_key"
|
||||||
|
return identity
|
||||||
|
|
||||||
|
|
||||||
|
async def find_route_handler(app_instance, path, method):
|
||||||
|
for route in app_instance.get_routes():
|
||||||
|
if route.path == path and route.method == method:
|
||||||
|
return route.handler
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_reticulum_discovery_get_and_patch(temp_dir):
|
||||||
|
config = ConfigDict(
|
||||||
|
{
|
||||||
|
"reticulum": {
|
||||||
|
"discover_interfaces": "true",
|
||||||
|
"interface_discovery_sources": "abc,def",
|
||||||
|
"required_discovery_value": "16",
|
||||||
|
"autoconnect_discovered_interfaces": "2",
|
||||||
|
"network_identity": "/tmp/net_id",
|
||||||
|
},
|
||||||
|
"interfaces": {},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch("meshchatx.meshchat.generate_ssl_certificate"),
|
||||||
|
patch("RNS.Reticulum") as mock_rns,
|
||||||
|
patch("RNS.Transport"),
|
||||||
|
patch("LXMF.LXMRouter"),
|
||||||
|
):
|
||||||
|
mock_reticulum = mock_rns.return_value
|
||||||
|
mock_reticulum.config = config
|
||||||
|
mock_reticulum.configpath = "/tmp/mock_config"
|
||||||
|
mock_reticulum.is_connected_to_shared_instance = False
|
||||||
|
mock_reticulum.transport_enabled.return_value = True
|
||||||
|
|
||||||
|
app_instance = ReticulumMeshChat(
|
||||||
|
identity=build_identity(),
|
||||||
|
storage_dir=temp_dir,
|
||||||
|
reticulum_config_dir=temp_dir,
|
||||||
|
)
|
||||||
|
|
||||||
|
get_handler = await find_route_handler(
|
||||||
|
app_instance,
|
||||||
|
"/api/v1/reticulum/discovery",
|
||||||
|
"GET",
|
||||||
|
)
|
||||||
|
patch_handler = await find_route_handler(
|
||||||
|
app_instance,
|
||||||
|
"/api/v1/reticulum/discovery",
|
||||||
|
"PATCH",
|
||||||
|
)
|
||||||
|
assert get_handler and patch_handler
|
||||||
|
|
||||||
|
# GET returns current reticulum discovery config
|
||||||
|
get_response = await get_handler(MagicMock())
|
||||||
|
get_data = json.loads(get_response.body)
|
||||||
|
assert get_data["discovery"]["discover_interfaces"] == "true"
|
||||||
|
assert get_data["discovery"]["interface_discovery_sources"] == "abc,def"
|
||||||
|
assert get_data["discovery"]["required_discovery_value"] == "16"
|
||||||
|
assert get_data["discovery"]["autoconnect_discovered_interfaces"] == "2"
|
||||||
|
assert get_data["discovery"]["network_identity"] == "/tmp/net_id"
|
||||||
|
|
||||||
|
# PATCH updates and persists
|
||||||
|
new_config = {
|
||||||
|
"discover_interfaces": False,
|
||||||
|
"interface_discovery_sources": "",
|
||||||
|
"required_discovery_value": 18,
|
||||||
|
"autoconnect_discovered_interfaces": 5,
|
||||||
|
"network_identity": "/tmp/other_id",
|
||||||
|
}
|
||||||
|
|
||||||
|
class PatchRequest:
|
||||||
|
@staticmethod
|
||||||
|
async def json():
|
||||||
|
return new_config
|
||||||
|
|
||||||
|
patch_response = await patch_handler(PatchRequest())
|
||||||
|
patch_data = json.loads(patch_response.body)
|
||||||
|
assert patch_data["discovery"]["discover_interfaces"] is False
|
||||||
|
assert patch_data["discovery"]["interface_discovery_sources"] is None
|
||||||
|
assert patch_data["discovery"]["required_discovery_value"] == 18
|
||||||
|
assert patch_data["discovery"]["autoconnect_discovered_interfaces"] == 5
|
||||||
|
assert patch_data["discovery"]["network_identity"] == "/tmp/other_id"
|
||||||
|
assert config["reticulum"]["discover_interfaces"] is False
|
||||||
|
assert "interface_discovery_sources" not in config["reticulum"]
|
||||||
|
assert config["reticulum"]["required_discovery_value"] == 18
|
||||||
|
assert config["reticulum"]["autoconnect_discovered_interfaces"] == 5
|
||||||
|
assert config["reticulum"]["network_identity"] == "/tmp/other_id"
|
||||||
|
assert config.write_called
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_interface_add_includes_discovery_fields(temp_dir):
|
||||||
|
config = ConfigDict({"reticulum": {}, "interfaces": {}})
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch("meshchatx.meshchat.generate_ssl_certificate"),
|
||||||
|
patch("RNS.Reticulum") as mock_rns,
|
||||||
|
patch("RNS.Transport"),
|
||||||
|
patch("LXMF.LXMRouter"),
|
||||||
|
):
|
||||||
|
mock_reticulum = mock_rns.return_value
|
||||||
|
mock_reticulum.config = config
|
||||||
|
mock_reticulum.configpath = "/tmp/mock_config"
|
||||||
|
mock_reticulum.is_connected_to_shared_instance = False
|
||||||
|
mock_reticulum.transport_enabled.return_value = True
|
||||||
|
|
||||||
|
app_instance = ReticulumMeshChat(
|
||||||
|
identity=build_identity(),
|
||||||
|
storage_dir=temp_dir,
|
||||||
|
reticulum_config_dir=temp_dir,
|
||||||
|
)
|
||||||
|
|
||||||
|
add_handler = await find_route_handler(
|
||||||
|
app_instance,
|
||||||
|
"/api/v1/reticulum/interfaces/add",
|
||||||
|
"POST",
|
||||||
|
)
|
||||||
|
assert add_handler
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"allow_overwriting_interface": False,
|
||||||
|
"name": "TestIface",
|
||||||
|
"type": "TCPClientInterface",
|
||||||
|
"target_host": "example.com",
|
||||||
|
"target_port": "4242",
|
||||||
|
"discoverable": "yes",
|
||||||
|
"discovery_name": "Region A",
|
||||||
|
"announce_interval": 720,
|
||||||
|
"reachable_on": "/usr/bin/get_ip.sh",
|
||||||
|
"discovery_stamp_value": 22,
|
||||||
|
"discovery_encrypt": True,
|
||||||
|
"publish_ifac": True,
|
||||||
|
"latitude": 10.1,
|
||||||
|
"longitude": 20.2,
|
||||||
|
"height": 30,
|
||||||
|
"discovery_frequency": 915000000,
|
||||||
|
"discovery_bandwidth": 125000,
|
||||||
|
"discovery_modulation": "LoRa",
|
||||||
|
}
|
||||||
|
|
||||||
|
class AddRequest:
|
||||||
|
@staticmethod
|
||||||
|
async def json():
|
||||||
|
return payload
|
||||||
|
|
||||||
|
response = await add_handler(AddRequest())
|
||||||
|
data = json.loads(response.body)
|
||||||
|
assert "Interface has been added" in data["message"]
|
||||||
|
saved = config["interfaces"]["TestIface"]
|
||||||
|
assert saved["discoverable"] == "yes"
|
||||||
|
assert saved["discovery_name"] == "Region A"
|
||||||
|
assert saved["announce_interval"] == 720
|
||||||
|
assert saved["reachable_on"] == "/usr/bin/get_ip.sh"
|
||||||
|
assert saved["discovery_stamp_value"] == 22
|
||||||
|
assert saved["discovery_encrypt"] is True
|
||||||
|
assert saved["publish_ifac"] is True
|
||||||
|
assert saved["latitude"] == 10.1
|
||||||
|
assert saved["longitude"] == 20.2
|
||||||
|
assert saved["height"] == 30
|
||||||
|
assert saved["discovery_frequency"] == 915000000
|
||||||
|
assert saved["discovery_bandwidth"] == 125000
|
||||||
|
assert saved["discovery_modulation"] == "LoRa"
|
||||||
|
assert config.write_called
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import json
|
import json
|
||||||
|
|
||||||
from meshchatx.src.backend.meshchat_utils import message_fields_have_attachments
|
from meshchatx.src.backend.meshchat_utils import message_fields_have_attachments
|
||||||
|
|
||||||
|
|
||||||
@@ -22,7 +23,7 @@ def test_message_fields_have_attachments():
|
|||||||
# File attachments - with files
|
# File attachments - with files
|
||||||
assert (
|
assert (
|
||||||
message_fields_have_attachments(
|
message_fields_have_attachments(
|
||||||
json.dumps({"file_attachments": [{"file_name": "test.txt"}]})
|
json.dumps({"file_attachments": [{"file_name": "test.txt"}]}),
|
||||||
)
|
)
|
||||||
is True
|
is True
|
||||||
)
|
)
|
||||||
@@ -36,8 +37,8 @@ def test_message_fields_have_attachments_mixed():
|
|||||||
assert (
|
assert (
|
||||||
message_fields_have_attachments(
|
message_fields_have_attachments(
|
||||||
json.dumps(
|
json.dumps(
|
||||||
{"image": "img", "file_attachments": [{"file_name": "test.txt"}]}
|
{"image": "img", "file_attachments": [{"file_name": "test.txt"}]},
|
||||||
)
|
),
|
||||||
)
|
)
|
||||||
is True
|
is True
|
||||||
)
|
)
|
||||||
@@ -45,7 +46,7 @@ def test_message_fields_have_attachments_mixed():
|
|||||||
# Unrelated fields
|
# Unrelated fields
|
||||||
assert (
|
assert (
|
||||||
message_fields_have_attachments(
|
message_fields_have_attachments(
|
||||||
json.dumps({"title": "hello", "content": "world"})
|
json.dumps({"title": "hello", "content": "world"}),
|
||||||
)
|
)
|
||||||
is False
|
is False
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import shutil
|
import shutil
|
||||||
import tempfile
|
import tempfile
|
||||||
from unittest.mock import AsyncMock, MagicMock, patch
|
|
||||||
from contextlib import ExitStack
|
from contextlib import ExitStack
|
||||||
|
from unittest.mock import AsyncMock, MagicMock, patch
|
||||||
|
|
||||||
|
import LXMF
|
||||||
import pytest
|
import pytest
|
||||||
import RNS
|
import RNS
|
||||||
import LXMF
|
|
||||||
|
|
||||||
from meshchatx.meshchat import ReticulumMeshChat
|
from meshchatx.meshchat import ReticulumMeshChat
|
||||||
|
|
||||||
@@ -80,17 +81,19 @@ def mock_rns():
|
|||||||
# Mock class methods on MockIdentityClass
|
# Mock class methods on MockIdentityClass
|
||||||
mock_id_instance = MockIdentityClass()
|
mock_id_instance = MockIdentityClass()
|
||||||
mock_id_instance.get_private_key = MagicMock(
|
mock_id_instance.get_private_key = MagicMock(
|
||||||
return_value=b"initial_private_key"
|
return_value=b"initial_private_key",
|
||||||
)
|
)
|
||||||
|
|
||||||
stack.enter_context(
|
stack.enter_context(
|
||||||
patch.object(MockIdentityClass, "from_file", return_value=mock_id_instance)
|
patch.object(MockIdentityClass, "from_file", return_value=mock_id_instance),
|
||||||
)
|
)
|
||||||
stack.enter_context(
|
stack.enter_context(
|
||||||
patch.object(MockIdentityClass, "recall", return_value=mock_id_instance)
|
patch.object(MockIdentityClass, "recall", return_value=mock_id_instance),
|
||||||
)
|
)
|
||||||
stack.enter_context(
|
stack.enter_context(
|
||||||
patch.object(MockIdentityClass, "from_bytes", return_value=mock_id_instance)
|
patch.object(
|
||||||
|
MockIdentityClass, "from_bytes", return_value=mock_id_instance
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
# Setup mock LXMessage
|
# Setup mock LXMessage
|
||||||
@@ -249,7 +252,7 @@ async def test_receive_message_updates_icon(mock_rns, temp_dir):
|
|||||||
"new_icon",
|
"new_icon",
|
||||||
b"\xff\xff\xff", # #ffffff
|
b"\xff\xff\xff", # #ffffff
|
||||||
b"\x00\x00\x00", # #000000
|
b"\x00\x00\x00", # #000000
|
||||||
]
|
],
|
||||||
}
|
}
|
||||||
|
|
||||||
# Mock methods
|
# Mock methods
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
|
import json
|
||||||
import shutil
|
import shutil
|
||||||
import tempfile
|
import tempfile
|
||||||
import pytest
|
|
||||||
import json
|
|
||||||
from unittest.mock import MagicMock, patch
|
from unittest.mock import MagicMock, patch
|
||||||
from meshchatx.meshchat import ReticulumMeshChat
|
|
||||||
import RNS
|
|
||||||
import LXMF
|
import LXMF
|
||||||
|
import pytest
|
||||||
|
import RNS
|
||||||
|
|
||||||
|
from meshchatx.meshchat import ReticulumMeshChat
|
||||||
|
|
||||||
# Store original constants
|
# Store original constants
|
||||||
PR_IDLE = LXMF.LXMRouter.PR_IDLE
|
PR_IDLE = LXMF.LXMRouter.PR_IDLE
|
||||||
@@ -58,7 +60,7 @@ def mock_app(temp_dir):
|
|||||||
mock_rns_inst.transport_enabled.return_value = False
|
mock_rns_inst.transport_enabled.return_value = False
|
||||||
|
|
||||||
with patch(
|
with patch(
|
||||||
"meshchatx.src.backend.meshchat_utils.LXMRouter"
|
"meshchatx.src.backend.meshchat_utils.LXMRouter",
|
||||||
) as mock_utils_router:
|
) as mock_utils_router:
|
||||||
mock_utils_router.PR_IDLE = PR_IDLE
|
mock_utils_router.PR_IDLE = PR_IDLE
|
||||||
mock_utils_router.PR_PATH_REQUESTED = PR_PATH_REQUESTED
|
mock_utils_router.PR_PATH_REQUESTED = PR_PATH_REQUESTED
|
||||||
@@ -76,7 +78,9 @@ def mock_app(temp_dir):
|
|||||||
app.current_context.message_router = mock_router
|
app.current_context.message_router = mock_router
|
||||||
|
|
||||||
with patch.object(
|
with patch.object(
|
||||||
app, "send_config_to_websocket_clients", return_value=None
|
app,
|
||||||
|
"send_config_to_websocket_clients",
|
||||||
|
return_value=None,
|
||||||
):
|
):
|
||||||
yield app
|
yield app
|
||||||
|
|
||||||
@@ -87,11 +91,11 @@ async def test_lxmf_propagation_config(mock_app):
|
|||||||
node_hash_bytes = bytes.fromhex(node_hash_hex)
|
node_hash_bytes = bytes.fromhex(node_hash_hex)
|
||||||
|
|
||||||
await mock_app.update_config(
|
await mock_app.update_config(
|
||||||
{"lxmf_preferred_propagation_node_destination_hash": node_hash_hex}
|
{"lxmf_preferred_propagation_node_destination_hash": node_hash_hex},
|
||||||
)
|
)
|
||||||
|
|
||||||
mock_app.current_context.message_router.set_outbound_propagation_node.assert_called_with(
|
mock_app.current_context.message_router.set_outbound_propagation_node.assert_called_with(
|
||||||
node_hash_bytes
|
node_hash_bytes,
|
||||||
)
|
)
|
||||||
assert (
|
assert (
|
||||||
mock_app.config.lxmf_preferred_propagation_node_destination_hash.get()
|
mock_app.config.lxmf_preferred_propagation_node_destination_hash.get()
|
||||||
@@ -159,7 +163,7 @@ async def test_send_failed_via_prop_node(mock_app):
|
|||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_auto_sync_interval_config(mock_app):
|
async def test_auto_sync_interval_config(mock_app):
|
||||||
await mock_app.update_config(
|
await mock_app.update_config(
|
||||||
{"lxmf_preferred_propagation_node_auto_sync_interval_seconds": 3600}
|
{"lxmf_preferred_propagation_node_auto_sync_interval_seconds": 3600},
|
||||||
)
|
)
|
||||||
assert (
|
assert (
|
||||||
mock_app.config.lxmf_preferred_propagation_node_auto_sync_interval_seconds.get()
|
mock_app.config.lxmf_preferred_propagation_node_auto_sync_interval_seconds.get()
|
||||||
@@ -198,17 +202,17 @@ async def test_user_provided_node_hash(mock_app):
|
|||||||
|
|
||||||
# Set this node as preferred
|
# Set this node as preferred
|
||||||
await mock_app.update_config(
|
await mock_app.update_config(
|
||||||
{"lxmf_preferred_propagation_node_destination_hash": node_hash_hex}
|
{"lxmf_preferred_propagation_node_destination_hash": node_hash_hex},
|
||||||
)
|
)
|
||||||
|
|
||||||
# Check if the router was updated with the correct bytes
|
# Check if the router was updated with the correct bytes
|
||||||
mock_app.current_context.message_router.set_outbound_propagation_node.assert_called_with(
|
mock_app.current_context.message_router.set_outbound_propagation_node.assert_called_with(
|
||||||
bytes.fromhex(node_hash_hex)
|
bytes.fromhex(node_hash_hex),
|
||||||
)
|
)
|
||||||
|
|
||||||
# Trigger a sync request
|
# Trigger a sync request
|
||||||
mock_app.current_context.message_router.get_outbound_propagation_node.return_value = bytes.fromhex(
|
mock_app.current_context.message_router.get_outbound_propagation_node.return_value = bytes.fromhex(
|
||||||
node_hash_hex
|
node_hash_hex,
|
||||||
)
|
)
|
||||||
sync_handler = next(
|
sync_handler = next(
|
||||||
r.handler
|
r.handler
|
||||||
@@ -219,5 +223,5 @@ async def test_user_provided_node_hash(mock_app):
|
|||||||
|
|
||||||
# Verify the router was told to sync for our identity
|
# Verify the router was told to sync for our identity
|
||||||
mock_app.current_context.message_router.request_messages_from_propagation_node.assert_called_with(
|
mock_app.current_context.message_router.request_messages_from_propagation_node.assert_called_with(
|
||||||
mock_app.current_context.identity
|
mock_app.current_context.identity,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
|
import json
|
||||||
import shutil
|
import shutil
|
||||||
import tempfile
|
import tempfile
|
||||||
import pytest
|
|
||||||
import json
|
|
||||||
from unittest.mock import MagicMock, patch
|
from unittest.mock import MagicMock, patch
|
||||||
from meshchatx.meshchat import ReticulumMeshChat
|
|
||||||
import RNS
|
|
||||||
import LXMF
|
import LXMF
|
||||||
|
import pytest
|
||||||
|
import RNS
|
||||||
|
|
||||||
|
from meshchatx.meshchat import ReticulumMeshChat
|
||||||
|
|
||||||
# Store original constants
|
# Store original constants
|
||||||
PR_IDLE = LXMF.LXMRouter.PR_IDLE
|
PR_IDLE = LXMF.LXMRouter.PR_IDLE
|
||||||
@@ -48,7 +50,7 @@ def mock_app(temp_dir):
|
|||||||
mock_rns_inst.transport_enabled.return_value = False
|
mock_rns_inst.transport_enabled.return_value = False
|
||||||
|
|
||||||
with patch(
|
with patch(
|
||||||
"meshchatx.src.backend.meshchat_utils.LXMRouter"
|
"meshchatx.src.backend.meshchat_utils.LXMRouter",
|
||||||
) as mock_utils_router:
|
) as mock_utils_router:
|
||||||
mock_utils_router.PR_IDLE = PR_IDLE
|
mock_utils_router.PR_IDLE = PR_IDLE
|
||||||
mock_utils_router.PR_COMPLETE = PR_COMPLETE
|
mock_utils_router.PR_COMPLETE = PR_COMPLETE
|
||||||
@@ -62,7 +64,9 @@ def mock_app(temp_dir):
|
|||||||
app.current_context.message_router = mock_router
|
app.current_context.message_router = mock_router
|
||||||
|
|
||||||
with patch.object(
|
with patch.object(
|
||||||
app, "send_config_to_websocket_clients", return_value=None
|
app,
|
||||||
|
"send_config_to_websocket_clients",
|
||||||
|
return_value=None,
|
||||||
):
|
):
|
||||||
yield app
|
yield app
|
||||||
|
|
||||||
@@ -117,13 +121,13 @@ async def test_specific_node_hash_validation(mock_app):
|
|||||||
with patch.object(mock_app, "send_config_to_websocket_clients", return_value=None):
|
with patch.object(mock_app, "send_config_to_websocket_clients", return_value=None):
|
||||||
# Set the preferred propagation node
|
# Set the preferred propagation node
|
||||||
await mock_app.update_config(
|
await mock_app.update_config(
|
||||||
{"lxmf_preferred_propagation_node_destination_hash": node_hash_hex}
|
{"lxmf_preferred_propagation_node_destination_hash": node_hash_hex},
|
||||||
)
|
)
|
||||||
|
|
||||||
# Verify it was set on the router correctly as 16 bytes
|
# Verify it was set on the router correctly as 16 bytes
|
||||||
expected_bytes = bytes.fromhex(node_hash_hex)
|
expected_bytes = bytes.fromhex(node_hash_hex)
|
||||||
mock_app.current_context.message_router.set_outbound_propagation_node.assert_called_with(
|
mock_app.current_context.message_router.set_outbound_propagation_node.assert_called_with(
|
||||||
expected_bytes
|
expected_bytes,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Trigger sync
|
# Trigger sync
|
||||||
|
|||||||
@@ -3,10 +3,11 @@ import json
|
|||||||
from unittest.mock import MagicMock
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
import LXMF
|
import LXMF
|
||||||
|
|
||||||
from meshchatx.src.backend.lxmf_utils import (
|
from meshchatx.src.backend.lxmf_utils import (
|
||||||
|
convert_db_lxmf_message_to_dict,
|
||||||
convert_lxmf_message_to_dict,
|
convert_lxmf_message_to_dict,
|
||||||
convert_lxmf_state_to_string,
|
convert_lxmf_state_to_string,
|
||||||
convert_db_lxmf_message_to_dict,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -129,9 +130,9 @@ def test_convert_db_lxmf_message_to_dict():
|
|||||||
{
|
{
|
||||||
"file_name": "f.txt",
|
"file_name": "f.txt",
|
||||||
"file_bytes": base64.b64encode(b"file").decode(),
|
"file_bytes": base64.b64encode(b"file").decode(),
|
||||||
}
|
},
|
||||||
],
|
],
|
||||||
}
|
},
|
||||||
),
|
),
|
||||||
"timestamp": 1234567890,
|
"timestamp": 1234567890,
|
||||||
"rssi": -60,
|
"rssi": -60,
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import os
|
import os
|
||||||
import shutil
|
import shutil
|
||||||
import tempfile
|
|
||||||
import sqlite3
|
import sqlite3
|
||||||
|
import tempfile
|
||||||
from unittest.mock import MagicMock, patch
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from meshchatx.src.backend.map_manager import MapManager
|
from meshchatx.src.backend.map_manager import MapManager
|
||||||
|
|
||||||
|
|
||||||
@@ -83,11 +84,12 @@ def test_get_tile(mock_config, temp_dir):
|
|||||||
|
|
||||||
conn = sqlite3.connect(db_path)
|
conn = sqlite3.connect(db_path)
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"CREATE TABLE tiles (zoom_level integer, tile_column integer, tile_row integer, tile_data blob)"
|
"CREATE TABLE tiles (zoom_level integer, tile_column integer, tile_row integer, tile_data blob)",
|
||||||
)
|
)
|
||||||
# Zoom 0, Tile 0,0. TMS y for 0/0/0 is (1<<0)-1-0 = 0
|
# Zoom 0, Tile 0,0. TMS y for 0/0/0 is (1<<0)-1-0 = 0
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"INSERT INTO tiles VALUES (0, 0, 0, ?)", (sqlite3.Binary(b"tile_data"),)
|
"INSERT INTO tiles VALUES (0, 0, 0, ?)",
|
||||||
|
(sqlite3.Binary(b"tile_data"),),
|
||||||
)
|
)
|
||||||
conn.commit()
|
conn.commit()
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import unittest
|
import unittest
|
||||||
|
|
||||||
from meshchatx.src.backend.markdown_renderer import MarkdownRenderer
|
from meshchatx.src.backend.markdown_renderer import MarkdownRenderer
|
||||||
|
|
||||||
|
|
||||||
@@ -24,7 +25,7 @@ class TestMarkdownRenderer(unittest.TestCase):
|
|||||||
# Check for escaped characters
|
# Check for escaped characters
|
||||||
self.assertTrue(
|
self.assertTrue(
|
||||||
"print('hello')" in rendered
|
"print('hello')" in rendered
|
||||||
or "print('hello')" in rendered
|
or "print('hello')" in rendered,
|
||||||
)
|
)
|
||||||
|
|
||||||
def test_lists(self):
|
def test_lists(self):
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import unittest
|
|
||||||
import os
|
import os
|
||||||
|
import secrets
|
||||||
import shutil
|
import shutil
|
||||||
import tempfile
|
import tempfile
|
||||||
import secrets
|
import unittest
|
||||||
|
|
||||||
from meshchatx.src.backend.database import Database
|
from meshchatx.src.backend.database import Database
|
||||||
from meshchatx.src.backend.identity_manager import IdentityManager
|
from meshchatx.src.backend.identity_manager import IdentityManager
|
||||||
from tests.backend.benchmarking_utils import MemoryTracker
|
from tests.backend.benchmarking_utils import MemoryTracker
|
||||||
@@ -54,7 +55,9 @@ class TestMemoryProfiling(unittest.TestCase):
|
|||||||
# 10k messages * 512 bytes is ~5MB of raw content.
|
# 10k messages * 512 bytes is ~5MB of raw content.
|
||||||
# SQLite should handle this efficiently.
|
# SQLite should handle this efficiently.
|
||||||
self.assertLess(
|
self.assertLess(
|
||||||
tracker.mem_delta, 20.0, "Excessive memory growth during DB insertion"
|
tracker.mem_delta,
|
||||||
|
20.0,
|
||||||
|
"Excessive memory growth during DB insertion",
|
||||||
)
|
)
|
||||||
|
|
||||||
def test_identity_manager_memory(self):
|
def test_identity_manager_memory(self):
|
||||||
@@ -70,7 +73,9 @@ class TestMemoryProfiling(unittest.TestCase):
|
|||||||
self.assertEqual(len(identities), 50)
|
self.assertEqual(len(identities), 50)
|
||||||
|
|
||||||
self.assertLess(
|
self.assertLess(
|
||||||
tracker.mem_delta, 10.0, "Identity management consumed too much memory"
|
tracker.mem_delta,
|
||||||
|
10.0,
|
||||||
|
"Identity management consumed too much memory",
|
||||||
)
|
)
|
||||||
|
|
||||||
def test_large_message_processing(self):
|
def test_large_message_processing(self):
|
||||||
@@ -124,7 +129,9 @@ class TestMemoryProfiling(unittest.TestCase):
|
|||||||
self.db.announces.upsert_announce(data)
|
self.db.announces.upsert_announce(data)
|
||||||
|
|
||||||
self.assertLess(
|
self.assertLess(
|
||||||
tracker.mem_delta, 15.0, "Announce updates causing memory bloat"
|
tracker.mem_delta,
|
||||||
|
15.0,
|
||||||
|
"Announce updates causing memory bloat",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import os
|
import os
|
||||||
import shutil
|
import shutil
|
||||||
import tempfile
|
import tempfile
|
||||||
from unittest.mock import MagicMock, patch
|
|
||||||
from contextlib import ExitStack
|
from contextlib import ExitStack
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
import RNS
|
import RNS
|
||||||
@@ -31,36 +31,36 @@ def mock_app(temp_dir):
|
|||||||
with ExitStack() as stack:
|
with ExitStack() as stack:
|
||||||
stack.enter_context(patch("meshchatx.src.backend.identity_context.Database"))
|
stack.enter_context(patch("meshchatx.src.backend.identity_context.Database"))
|
||||||
stack.enter_context(
|
stack.enter_context(
|
||||||
patch("meshchatx.src.backend.identity_context.ConfigManager")
|
patch("meshchatx.src.backend.identity_context.ConfigManager"),
|
||||||
)
|
)
|
||||||
stack.enter_context(
|
stack.enter_context(
|
||||||
patch("meshchatx.src.backend.identity_context.MessageHandler")
|
patch("meshchatx.src.backend.identity_context.MessageHandler"),
|
||||||
)
|
)
|
||||||
stack.enter_context(
|
stack.enter_context(
|
||||||
patch("meshchatx.src.backend.identity_context.AnnounceManager")
|
patch("meshchatx.src.backend.identity_context.AnnounceManager"),
|
||||||
)
|
)
|
||||||
stack.enter_context(
|
stack.enter_context(
|
||||||
patch("meshchatx.src.backend.identity_context.ArchiverManager")
|
patch("meshchatx.src.backend.identity_context.ArchiverManager"),
|
||||||
)
|
)
|
||||||
stack.enter_context(patch("meshchatx.src.backend.identity_context.MapManager"))
|
stack.enter_context(patch("meshchatx.src.backend.identity_context.MapManager"))
|
||||||
stack.enter_context(
|
stack.enter_context(
|
||||||
patch("meshchatx.src.backend.identity_context.TelephoneManager")
|
patch("meshchatx.src.backend.identity_context.TelephoneManager"),
|
||||||
)
|
)
|
||||||
stack.enter_context(
|
stack.enter_context(
|
||||||
patch("meshchatx.src.backend.identity_context.VoicemailManager")
|
patch("meshchatx.src.backend.identity_context.VoicemailManager"),
|
||||||
)
|
)
|
||||||
stack.enter_context(
|
stack.enter_context(
|
||||||
patch("meshchatx.src.backend.identity_context.RingtoneManager")
|
patch("meshchatx.src.backend.identity_context.RingtoneManager"),
|
||||||
)
|
)
|
||||||
stack.enter_context(patch("meshchatx.src.backend.identity_context.RNCPHandler"))
|
stack.enter_context(patch("meshchatx.src.backend.identity_context.RNCPHandler"))
|
||||||
stack.enter_context(
|
stack.enter_context(
|
||||||
patch("meshchatx.src.backend.identity_context.RNStatusHandler")
|
patch("meshchatx.src.backend.identity_context.RNStatusHandler"),
|
||||||
)
|
)
|
||||||
stack.enter_context(
|
stack.enter_context(
|
||||||
patch("meshchatx.src.backend.identity_context.RNProbeHandler")
|
patch("meshchatx.src.backend.identity_context.RNProbeHandler"),
|
||||||
)
|
)
|
||||||
stack.enter_context(
|
stack.enter_context(
|
||||||
patch("meshchatx.src.backend.identity_context.TranslatorHandler")
|
patch("meshchatx.src.backend.identity_context.TranslatorHandler"),
|
||||||
)
|
)
|
||||||
stack.enter_context(patch("LXMF.LXMRouter"))
|
stack.enter_context(patch("LXMF.LXMRouter"))
|
||||||
stack.enter_context(patch("RNS.Identity", MockIdentityClass))
|
stack.enter_context(patch("RNS.Identity", MockIdentityClass))
|
||||||
@@ -72,34 +72,34 @@ def mock_app(temp_dir):
|
|||||||
ReticulumMeshChat,
|
ReticulumMeshChat,
|
||||||
"announce_loop",
|
"announce_loop",
|
||||||
new=MagicMock(return_value=None),
|
new=MagicMock(return_value=None),
|
||||||
)
|
),
|
||||||
)
|
)
|
||||||
stack.enter_context(
|
stack.enter_context(
|
||||||
patch.object(
|
patch.object(
|
||||||
ReticulumMeshChat,
|
ReticulumMeshChat,
|
||||||
"announce_sync_propagation_nodes",
|
"announce_sync_propagation_nodes",
|
||||||
new=MagicMock(return_value=None),
|
new=MagicMock(return_value=None),
|
||||||
)
|
),
|
||||||
)
|
)
|
||||||
stack.enter_context(
|
stack.enter_context(
|
||||||
patch.object(
|
patch.object(
|
||||||
ReticulumMeshChat,
|
ReticulumMeshChat,
|
||||||
"crawler_loop",
|
"crawler_loop",
|
||||||
new=MagicMock(return_value=None),
|
new=MagicMock(return_value=None),
|
||||||
)
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
mock_id = MockIdentityClass()
|
mock_id = MockIdentityClass()
|
||||||
mock_id.get_private_key = MagicMock(return_value=b"test_private_key")
|
mock_id.get_private_key = MagicMock(return_value=b"test_private_key")
|
||||||
|
|
||||||
stack.enter_context(
|
stack.enter_context(
|
||||||
patch.object(MockIdentityClass, "from_file", return_value=mock_id)
|
patch.object(MockIdentityClass, "from_file", return_value=mock_id),
|
||||||
)
|
)
|
||||||
stack.enter_context(
|
stack.enter_context(
|
||||||
patch.object(MockIdentityClass, "recall", return_value=mock_id)
|
patch.object(MockIdentityClass, "recall", return_value=mock_id),
|
||||||
)
|
)
|
||||||
stack.enter_context(
|
stack.enter_context(
|
||||||
patch.object(MockIdentityClass, "from_bytes", return_value=mock_id)
|
patch.object(MockIdentityClass, "from_bytes", return_value=mock_id),
|
||||||
)
|
)
|
||||||
|
|
||||||
app = ReticulumMeshChat(
|
app = ReticulumMeshChat(
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import unittest
|
import unittest
|
||||||
from unittest.mock import MagicMock
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
from meshchatx.src.backend.message_handler import MessageHandler
|
from meshchatx.src.backend.message_handler import MessageHandler
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import unittest
|
import unittest
|
||||||
from unittest.mock import MagicMock
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
from meshchatx.src.backend.nomadnet_downloader import NomadnetDownloader
|
from meshchatx.src.backend.nomadnet_downloader import NomadnetDownloader
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import os
|
import os
|
||||||
import time
|
import time
|
||||||
from unittest.mock import MagicMock, patch
|
|
||||||
from contextlib import ExitStack
|
from contextlib import ExitStack
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
import RNS
|
import RNS
|
||||||
@@ -49,33 +49,33 @@ def mock_app(db, tmp_path):
|
|||||||
stack.enter_context(patch("RNS.Transport"))
|
stack.enter_context(patch("RNS.Transport"))
|
||||||
stack.enter_context(patch("LXMF.LXMRouter"))
|
stack.enter_context(patch("LXMF.LXMRouter"))
|
||||||
stack.enter_context(
|
stack.enter_context(
|
||||||
patch("meshchatx.src.backend.identity_context.TelephoneManager")
|
patch("meshchatx.src.backend.identity_context.TelephoneManager"),
|
||||||
)
|
)
|
||||||
stack.enter_context(
|
stack.enter_context(
|
||||||
patch("meshchatx.src.backend.identity_context.VoicemailManager")
|
patch("meshchatx.src.backend.identity_context.VoicemailManager"),
|
||||||
)
|
)
|
||||||
stack.enter_context(
|
stack.enter_context(
|
||||||
patch("meshchatx.src.backend.identity_context.RingtoneManager")
|
patch("meshchatx.src.backend.identity_context.RingtoneManager"),
|
||||||
)
|
)
|
||||||
stack.enter_context(patch("meshchatx.src.backend.identity_context.RNCPHandler"))
|
stack.enter_context(patch("meshchatx.src.backend.identity_context.RNCPHandler"))
|
||||||
stack.enter_context(
|
stack.enter_context(
|
||||||
patch("meshchatx.src.backend.identity_context.RNStatusHandler")
|
patch("meshchatx.src.backend.identity_context.RNStatusHandler"),
|
||||||
)
|
)
|
||||||
stack.enter_context(
|
stack.enter_context(
|
||||||
patch("meshchatx.src.backend.identity_context.RNProbeHandler")
|
patch("meshchatx.src.backend.identity_context.RNProbeHandler"),
|
||||||
)
|
)
|
||||||
stack.enter_context(
|
stack.enter_context(
|
||||||
patch("meshchatx.src.backend.identity_context.TranslatorHandler")
|
patch("meshchatx.src.backend.identity_context.TranslatorHandler"),
|
||||||
)
|
)
|
||||||
stack.enter_context(
|
stack.enter_context(
|
||||||
patch("meshchatx.src.backend.identity_context.ArchiverManager")
|
patch("meshchatx.src.backend.identity_context.ArchiverManager"),
|
||||||
)
|
)
|
||||||
stack.enter_context(patch("meshchatx.src.backend.identity_context.MapManager"))
|
stack.enter_context(patch("meshchatx.src.backend.identity_context.MapManager"))
|
||||||
stack.enter_context(
|
stack.enter_context(
|
||||||
patch("meshchatx.src.backend.identity_context.MessageHandler")
|
patch("meshchatx.src.backend.identity_context.MessageHandler"),
|
||||||
)
|
)
|
||||||
stack.enter_context(
|
stack.enter_context(
|
||||||
patch("meshchatx.src.backend.identity_context.AnnounceManager")
|
patch("meshchatx.src.backend.identity_context.AnnounceManager"),
|
||||||
)
|
)
|
||||||
stack.enter_context(patch("threading.Thread"))
|
stack.enter_context(patch("threading.Thread"))
|
||||||
|
|
||||||
@@ -83,44 +83,52 @@ def mock_app(db, tmp_path):
|
|||||||
mock_id.get_private_key = MagicMock(return_value=b"test_private_key")
|
mock_id.get_private_key = MagicMock(return_value=b"test_private_key")
|
||||||
|
|
||||||
stack.enter_context(
|
stack.enter_context(
|
||||||
patch.object(MockIdentityClass, "from_file", return_value=mock_id)
|
patch.object(MockIdentityClass, "from_file", return_value=mock_id),
|
||||||
)
|
)
|
||||||
stack.enter_context(
|
stack.enter_context(
|
||||||
patch.object(MockIdentityClass, "recall", return_value=mock_id)
|
patch.object(MockIdentityClass, "recall", return_value=mock_id),
|
||||||
)
|
)
|
||||||
stack.enter_context(
|
stack.enter_context(
|
||||||
patch.object(MockIdentityClass, "from_bytes", return_value=mock_id)
|
patch.object(MockIdentityClass, "from_bytes", return_value=mock_id),
|
||||||
)
|
)
|
||||||
|
|
||||||
# Patch background threads and other heavy init
|
# Patch background threads and other heavy init
|
||||||
stack.enter_context(
|
stack.enter_context(
|
||||||
patch.object(
|
patch.object(
|
||||||
ReticulumMeshChat, "announce_loop", new=MagicMock(return_value=None)
|
ReticulumMeshChat,
|
||||||
)
|
"announce_loop",
|
||||||
|
new=MagicMock(return_value=None),
|
||||||
|
),
|
||||||
)
|
)
|
||||||
stack.enter_context(
|
stack.enter_context(
|
||||||
patch.object(
|
patch.object(
|
||||||
ReticulumMeshChat,
|
ReticulumMeshChat,
|
||||||
"announce_sync_propagation_nodes",
|
"announce_sync_propagation_nodes",
|
||||||
new=MagicMock(return_value=None),
|
new=MagicMock(return_value=None),
|
||||||
)
|
),
|
||||||
)
|
)
|
||||||
stack.enter_context(
|
stack.enter_context(
|
||||||
patch.object(
|
patch.object(
|
||||||
ReticulumMeshChat, "crawler_loop", new=MagicMock(return_value=None)
|
ReticulumMeshChat,
|
||||||
)
|
"crawler_loop",
|
||||||
|
new=MagicMock(return_value=None),
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
stack.enter_context(
|
stack.enter_context(
|
||||||
patch.object(
|
patch.object(
|
||||||
ReticulumMeshChat, "auto_backup_loop", new=MagicMock(return_value=None)
|
ReticulumMeshChat,
|
||||||
)
|
"auto_backup_loop",
|
||||||
|
new=MagicMock(return_value=None),
|
||||||
|
),
|
||||||
)
|
)
|
||||||
# Prevent JSON serialization issues with MagicMocks
|
# Prevent JSON serialization issues with MagicMocks
|
||||||
stack.enter_context(
|
stack.enter_context(
|
||||||
patch.object(
|
patch.object(
|
||||||
ReticulumMeshChat, "send_config_to_websocket_clients", return_value=None
|
ReticulumMeshChat,
|
||||||
)
|
"send_config_to_websocket_clients",
|
||||||
|
return_value=None,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
app = ReticulumMeshChat(
|
app = ReticulumMeshChat(
|
||||||
@@ -262,7 +270,10 @@ async def test_notifications_api(mock_app):
|
|||||||
# Let's test a spike of notifications
|
# Let's test a spike of notifications
|
||||||
for i in range(100):
|
for i in range(100):
|
||||||
mock_app.database.misc.add_notification(
|
mock_app.database.misc.add_notification(
|
||||||
f"type{i}", f"hash{i}", f"title{i}", f"content{i}"
|
f"type{i}",
|
||||||
|
f"hash{i}",
|
||||||
|
f"title{i}",
|
||||||
|
f"content{i}",
|
||||||
)
|
)
|
||||||
|
|
||||||
notifications = mock_app.database.misc.get_notifications(limit=50)
|
notifications = mock_app.database.misc.get_notifications(limit=50)
|
||||||
@@ -295,7 +306,10 @@ def test_voicemail_notification_fuzzing(mock_app, remote_hash, remote_name, dura
|
|||||||
call_was_established=st.booleans(),
|
call_was_established=st.booleans(),
|
||||||
)
|
)
|
||||||
def test_missed_call_notification_fuzzing(
|
def test_missed_call_notification_fuzzing(
|
||||||
mock_app, remote_hash, status_code, call_was_established
|
mock_app,
|
||||||
|
remote_hash,
|
||||||
|
status_code,
|
||||||
|
call_was_established,
|
||||||
):
|
):
|
||||||
"""Fuzz missed call notification triggering."""
|
"""Fuzz missed call notification triggering."""
|
||||||
mock_app.database.misc.provider.execute("DELETE FROM notifications")
|
mock_app.database.misc.provider.execute("DELETE FROM notifications")
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
import unittest
|
|
||||||
import os
|
import os
|
||||||
|
import secrets
|
||||||
import shutil
|
import shutil
|
||||||
import tempfile
|
import tempfile
|
||||||
import time
|
import time
|
||||||
import secrets
|
import unittest
|
||||||
from unittest.mock import MagicMock
|
from unittest.mock import MagicMock
|
||||||
from meshchatx.src.backend.database import Database
|
|
||||||
from meshchatx.src.backend.announce_manager import AnnounceManager
|
from meshchatx.src.backend.announce_manager import AnnounceManager
|
||||||
|
from meshchatx.src.backend.database import Database
|
||||||
|
|
||||||
|
|
||||||
class TestPerformanceBottlenecks(unittest.TestCase):
|
class TestPerformanceBottlenecks(unittest.TestCase):
|
||||||
@@ -60,7 +61,9 @@ class TestPerformanceBottlenecks(unittest.TestCase):
|
|||||||
for offset in offsets:
|
for offset in offsets:
|
||||||
start = time.time()
|
start = time.time()
|
||||||
msgs = self.db.messages.get_conversation_messages(
|
msgs = self.db.messages.get_conversation_messages(
|
||||||
peer_hash, limit=limit, offset=offset
|
peer_hash,
|
||||||
|
limit=limit,
|
||||||
|
offset=offset,
|
||||||
)
|
)
|
||||||
duration = (time.time() - start) * 1000
|
duration = (time.time() - start) * 1000
|
||||||
print(f"Fetch {limit} messages at offset {offset}: {duration:.2f}ms")
|
print(f"Fetch {limit} messages at offset {offset}: {duration:.2f}ms")
|
||||||
@@ -103,7 +106,7 @@ class TestPerformanceBottlenecks(unittest.TestCase):
|
|||||||
duration_total = time.time() - start_total
|
duration_total = time.time() - start_total
|
||||||
avg_duration = (duration_total / num_announces) * 1000
|
avg_duration = (duration_total / num_announces) * 1000
|
||||||
print(
|
print(
|
||||||
f"Processed {num_announces} announces in {duration_total:.2f}s (Avg: {avg_duration:.2f}ms/announce)"
|
f"Processed {num_announces} announces in {duration_total:.2f}s (Avg: {avg_duration:.2f}ms/announce)",
|
||||||
)
|
)
|
||||||
|
|
||||||
self.assertLess(avg_duration, 20, "Announce processing is too slow!")
|
self.assertLess(avg_duration, 20, "Announce processing is too slow!")
|
||||||
@@ -129,7 +132,9 @@ class TestPerformanceBottlenecks(unittest.TestCase):
|
|||||||
# Benchmark filtered search with pagination
|
# Benchmark filtered search with pagination
|
||||||
start = time.time()
|
start = time.time()
|
||||||
results = self.announce_manager.get_filtered_announces(
|
results = self.announce_manager.get_filtered_announces(
|
||||||
aspect="lxmf.delivery", limit=50, offset=1000
|
aspect="lxmf.delivery",
|
||||||
|
limit=50,
|
||||||
|
offset=1000,
|
||||||
)
|
)
|
||||||
duration = (time.time() - start) * 1000
|
duration = (time.time() - start) * 1000
|
||||||
print(f"Filtered announce pagination (offset 1000): {duration:.2f}ms")
|
print(f"Filtered announce pagination (offset 1000): {duration:.2f}ms")
|
||||||
@@ -164,7 +169,7 @@ class TestPerformanceBottlenecks(unittest.TestCase):
|
|||||||
]
|
]
|
||||||
|
|
||||||
print(
|
print(
|
||||||
f"\nRunning {num_threads} threads inserting {announces_per_thread} announces each..."
|
f"\nRunning {num_threads} threads inserting {announces_per_thread} announces each...",
|
||||||
)
|
)
|
||||||
start = time.time()
|
start = time.time()
|
||||||
for t in threads:
|
for t in threads:
|
||||||
@@ -174,7 +179,7 @@ class TestPerformanceBottlenecks(unittest.TestCase):
|
|||||||
duration = time.time() - start
|
duration = time.time() - start
|
||||||
|
|
||||||
print(
|
print(
|
||||||
f"Concurrent insertion took {duration:.2f}s for {num_threads * announces_per_thread} announces"
|
f"Concurrent insertion took {duration:.2f}s for {num_threads * announces_per_thread} announces",
|
||||||
)
|
)
|
||||||
self.assertLess(duration, 10.0, "Concurrent announce insertion is too slow!")
|
self.assertLess(duration, 10.0, "Concurrent announce insertion is too slow!")
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
|
import json
|
||||||
import shutil
|
import shutil
|
||||||
import tempfile
|
import tempfile
|
||||||
import pytest
|
|
||||||
import json
|
|
||||||
from unittest.mock import MagicMock, patch
|
from unittest.mock import MagicMock, patch
|
||||||
from meshchatx.meshchat import ReticulumMeshChat
|
|
||||||
|
import pytest
|
||||||
import RNS
|
import RNS
|
||||||
|
|
||||||
|
from meshchatx.meshchat import ReticulumMeshChat
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def temp_dir():
|
def temp_dir():
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ from unittest.mock import MagicMock, patch
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
import RNS
|
import RNS
|
||||||
|
|
||||||
from meshchatx.src.backend.rncp_handler import RNCPHandler
|
from meshchatx.src.backend.rncp_handler import RNCPHandler
|
||||||
|
|
||||||
|
|
||||||
@@ -40,7 +41,9 @@ def mock_rns():
|
|||||||
patch.object(MockIdentityClass, "from_file", return_value=mock_id_instance),
|
patch.object(MockIdentityClass, "from_file", return_value=mock_id_instance),
|
||||||
patch.object(MockIdentityClass, "recall", return_value=mock_id_instance),
|
patch.object(MockIdentityClass, "recall", return_value=mock_id_instance),
|
||||||
patch.object(
|
patch.object(
|
||||||
MockIdentityClass, "from_bytes", return_value=mock_id_instance
|
MockIdentityClass,
|
||||||
|
"from_bytes",
|
||||||
|
return_value=mock_id_instance,
|
||||||
),
|
),
|
||||||
):
|
):
|
||||||
mock_dest_instance = MagicMock()
|
mock_dest_instance = MagicMock()
|
||||||
@@ -85,7 +88,9 @@ def test_setup_receive_destination(mock_rns, temp_dir):
|
|||||||
|
|
||||||
mock_rns["Reticulum"].identitypath = temp_dir
|
mock_rns["Reticulum"].identitypath = temp_dir
|
||||||
_ = handler.setup_receive_destination(
|
_ = handler.setup_receive_destination(
|
||||||
allowed_hashes=["abc123def456"], fetch_allowed=True, fetch_jail=temp_dir
|
allowed_hashes=["abc123def456"],
|
||||||
|
fetch_allowed=True,
|
||||||
|
fetch_jail=temp_dir,
|
||||||
)
|
)
|
||||||
|
|
||||||
assert handler.receive_destination is not None
|
assert handler.receive_destination is not None
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
import pytest
|
|
||||||
import json
|
import json
|
||||||
from unittest.mock import MagicMock, patch, AsyncMock
|
from unittest.mock import AsyncMock, MagicMock, patch
|
||||||
from meshchatx.meshchat import ReticulumMeshChat
|
|
||||||
|
import pytest
|
||||||
import RNS
|
import RNS
|
||||||
|
|
||||||
|
from meshchatx.meshchat import ReticulumMeshChat
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def temp_dir(tmp_path):
|
def temp_dir(tmp_path):
|
||||||
|
|||||||
@@ -45,7 +45,9 @@ def mock_rns():
|
|||||||
new=MagicMock(return_value=None),
|
new=MagicMock(return_value=None),
|
||||||
),
|
),
|
||||||
patch.object(
|
patch.object(
|
||||||
ReticulumMeshChat, "send_config_to_websocket_clients", return_value=None
|
ReticulumMeshChat,
|
||||||
|
"send_config_to_websocket_clients",
|
||||||
|
return_value=None,
|
||||||
),
|
),
|
||||||
):
|
):
|
||||||
# Setup mock instance
|
# Setup mock instance
|
||||||
@@ -57,10 +59,14 @@ def mock_rns():
|
|||||||
patch.object(MockIdentityClass, "from_file", return_value=mock_id_instance),
|
patch.object(MockIdentityClass, "from_file", return_value=mock_id_instance),
|
||||||
patch.object(MockIdentityClass, "recall", return_value=mock_id_instance),
|
patch.object(MockIdentityClass, "recall", return_value=mock_id_instance),
|
||||||
patch.object(
|
patch.object(
|
||||||
MockIdentityClass, "from_bytes", return_value=mock_id_instance
|
MockIdentityClass,
|
||||||
|
"from_bytes",
|
||||||
|
return_value=mock_id_instance,
|
||||||
),
|
),
|
||||||
patch.object(
|
patch.object(
|
||||||
MockIdentityClass, "full_hash", return_value=b"full_hash_bytes"
|
MockIdentityClass,
|
||||||
|
"full_hash",
|
||||||
|
return_value=b"full_hash_bytes",
|
||||||
),
|
),
|
||||||
):
|
):
|
||||||
# Setup mock transport
|
# Setup mock transport
|
||||||
@@ -263,7 +269,7 @@ async def test_hotswap_identity(mock_rns, temp_dir):
|
|||||||
with (
|
with (
|
||||||
patch("meshchatx.src.backend.identity_context.Database"),
|
patch("meshchatx.src.backend.identity_context.Database"),
|
||||||
patch(
|
patch(
|
||||||
"meshchatx.src.backend.identity_context.ConfigManager"
|
"meshchatx.src.backend.identity_context.ConfigManager",
|
||||||
) as mock_config_class,
|
) as mock_config_class,
|
||||||
patch("meshchatx.src.backend.identity_context.MessageHandler"),
|
patch("meshchatx.src.backend.identity_context.MessageHandler"),
|
||||||
patch("meshchatx.src.backend.identity_context.AnnounceManager"),
|
patch("meshchatx.src.backend.identity_context.AnnounceManager"),
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import pytest
|
|
||||||
from unittest.mock import MagicMock, patch
|
from unittest.mock import MagicMock, patch
|
||||||
from meshchatx.src.backend.rnstatus_handler import RNStatusHandler
|
|
||||||
|
import pytest
|
||||||
import RNS
|
import RNS
|
||||||
|
|
||||||
|
from meshchatx.src.backend.rnstatus_handler import RNStatusHandler
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def mock_reticulum_instance():
|
def mock_reticulum_instance():
|
||||||
@@ -50,7 +52,7 @@ def test_blackhole_status_missing_api(mock_reticulum_instance):
|
|||||||
# But we can patch the RNS object inside rnstatus_handler module.
|
# But we can patch the RNS object inside rnstatus_handler module.
|
||||||
|
|
||||||
with patch(
|
with patch(
|
||||||
"meshchatx.src.backend.rnstatus_handler.RNS.Reticulum"
|
"meshchatx.src.backend.rnstatus_handler.RNS.Reticulum",
|
||||||
) as mock_rns_class:
|
) as mock_rns_class:
|
||||||
del mock_rns_class.publish_blackhole_enabled
|
del mock_rns_class.publish_blackhole_enabled
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
|
import base64
|
||||||
import os
|
import os
|
||||||
import time
|
import time
|
||||||
import base64
|
|
||||||
from contextlib import ExitStack
|
from contextlib import ExitStack
|
||||||
from unittest.mock import MagicMock, patch
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
@@ -27,39 +27,39 @@ def mock_app():
|
|||||||
# Mock core dependencies that interact with the system/network
|
# Mock core dependencies that interact with the system/network
|
||||||
stack.enter_context(patch("meshchatx.src.backend.identity_context.Database"))
|
stack.enter_context(patch("meshchatx.src.backend.identity_context.Database"))
|
||||||
stack.enter_context(
|
stack.enter_context(
|
||||||
patch("meshchatx.src.backend.identity_context.ConfigManager")
|
patch("meshchatx.src.backend.identity_context.ConfigManager"),
|
||||||
)
|
)
|
||||||
stack.enter_context(
|
stack.enter_context(
|
||||||
patch("meshchatx.src.backend.identity_context.MessageHandler")
|
patch("meshchatx.src.backend.identity_context.MessageHandler"),
|
||||||
)
|
)
|
||||||
stack.enter_context(
|
stack.enter_context(
|
||||||
patch("meshchatx.src.backend.identity_context.AnnounceManager")
|
patch("meshchatx.src.backend.identity_context.AnnounceManager"),
|
||||||
)
|
)
|
||||||
stack.enter_context(
|
stack.enter_context(
|
||||||
patch("meshchatx.src.backend.identity_context.ArchiverManager")
|
patch("meshchatx.src.backend.identity_context.ArchiverManager"),
|
||||||
)
|
)
|
||||||
stack.enter_context(patch("meshchatx.src.backend.identity_context.MapManager"))
|
stack.enter_context(patch("meshchatx.src.backend.identity_context.MapManager"))
|
||||||
stack.enter_context(
|
stack.enter_context(
|
||||||
patch("meshchatx.src.backend.identity_context.TelephoneManager")
|
patch("meshchatx.src.backend.identity_context.TelephoneManager"),
|
||||||
)
|
)
|
||||||
stack.enter_context(
|
stack.enter_context(
|
||||||
patch("meshchatx.src.backend.identity_context.VoicemailManager")
|
patch("meshchatx.src.backend.identity_context.VoicemailManager"),
|
||||||
)
|
)
|
||||||
stack.enter_context(
|
stack.enter_context(
|
||||||
patch("meshchatx.src.backend.identity_context.RingtoneManager")
|
patch("meshchatx.src.backend.identity_context.RingtoneManager"),
|
||||||
)
|
)
|
||||||
stack.enter_context(patch("meshchatx.src.backend.identity_context.RNCPHandler"))
|
stack.enter_context(patch("meshchatx.src.backend.identity_context.RNCPHandler"))
|
||||||
stack.enter_context(
|
stack.enter_context(
|
||||||
patch("meshchatx.src.backend.identity_context.RNStatusHandler")
|
patch("meshchatx.src.backend.identity_context.RNStatusHandler"),
|
||||||
)
|
)
|
||||||
stack.enter_context(
|
stack.enter_context(
|
||||||
patch("meshchatx.src.backend.identity_context.RNProbeHandler")
|
patch("meshchatx.src.backend.identity_context.RNProbeHandler"),
|
||||||
)
|
)
|
||||||
stack.enter_context(
|
stack.enter_context(
|
||||||
patch("meshchatx.src.backend.identity_context.TranslatorHandler")
|
patch("meshchatx.src.backend.identity_context.TranslatorHandler"),
|
||||||
)
|
)
|
||||||
stack.enter_context(
|
stack.enter_context(
|
||||||
patch("meshchatx.src.backend.identity_context.CommunityInterfacesManager")
|
patch("meshchatx.src.backend.identity_context.CommunityInterfacesManager"),
|
||||||
)
|
)
|
||||||
|
|
||||||
mock_async_utils = stack.enter_context(patch("meshchatx.meshchat.AsyncUtils"))
|
mock_async_utils = stack.enter_context(patch("meshchatx.meshchat.AsyncUtils"))
|
||||||
@@ -72,36 +72,40 @@ def mock_app():
|
|||||||
|
|
||||||
# Stop background loops
|
# Stop background loops
|
||||||
stack.enter_context(
|
stack.enter_context(
|
||||||
patch.object(ReticulumMeshChat, "announce_loop", return_value=None)
|
patch.object(ReticulumMeshChat, "announce_loop", return_value=None),
|
||||||
)
|
)
|
||||||
stack.enter_context(
|
stack.enter_context(
|
||||||
patch.object(
|
patch.object(
|
||||||
ReticulumMeshChat, "announce_sync_propagation_nodes", return_value=None
|
ReticulumMeshChat,
|
||||||
)
|
"announce_sync_propagation_nodes",
|
||||||
|
return_value=None,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
stack.enter_context(
|
stack.enter_context(
|
||||||
patch.object(ReticulumMeshChat, "crawler_loop", return_value=None)
|
patch.object(ReticulumMeshChat, "crawler_loop", return_value=None),
|
||||||
)
|
)
|
||||||
stack.enter_context(
|
stack.enter_context(
|
||||||
patch.object(ReticulumMeshChat, "auto_backup_loop", return_value=None)
|
patch.object(ReticulumMeshChat, "auto_backup_loop", return_value=None),
|
||||||
)
|
)
|
||||||
stack.enter_context(
|
stack.enter_context(
|
||||||
patch.object(
|
patch.object(
|
||||||
ReticulumMeshChat, "send_config_to_websocket_clients", return_value=None
|
ReticulumMeshChat,
|
||||||
)
|
"send_config_to_websocket_clients",
|
||||||
|
return_value=None,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
mock_id = MockIdentityClass()
|
mock_id = MockIdentityClass()
|
||||||
mock_id.get_private_key = MagicMock(return_value=b"test_private_key")
|
mock_id.get_private_key = MagicMock(return_value=b"test_private_key")
|
||||||
|
|
||||||
stack.enter_context(
|
stack.enter_context(
|
||||||
patch.object(MockIdentityClass, "from_file", return_value=mock_id)
|
patch.object(MockIdentityClass, "from_file", return_value=mock_id),
|
||||||
)
|
)
|
||||||
stack.enter_context(
|
stack.enter_context(
|
||||||
patch.object(MockIdentityClass, "recall", return_value=mock_id)
|
patch.object(MockIdentityClass, "recall", return_value=mock_id),
|
||||||
)
|
)
|
||||||
stack.enter_context(
|
stack.enter_context(
|
||||||
patch.object(MockIdentityClass, "from_bytes", return_value=mock_id)
|
patch.object(MockIdentityClass, "from_bytes", return_value=mock_id),
|
||||||
)
|
)
|
||||||
|
|
||||||
def mock_run_async(coro):
|
def mock_run_async(coro):
|
||||||
@@ -117,10 +121,10 @@ def mock_app():
|
|||||||
return MagicMock()
|
return MagicMock()
|
||||||
|
|
||||||
mock_telephone_manager = stack.enter_context(
|
mock_telephone_manager = stack.enter_context(
|
||||||
patch("meshchatx.src.backend.identity_context.TelephoneManager")
|
patch("meshchatx.src.backend.identity_context.TelephoneManager"),
|
||||||
)
|
)
|
||||||
mock_telephone_manager.return_value.initiate = MagicMock(
|
mock_telephone_manager.return_value.initiate = MagicMock(
|
||||||
side_effect=mock_initiate
|
side_effect=mock_initiate,
|
||||||
)
|
)
|
||||||
|
|
||||||
app = ReticulumMeshChat(
|
app = ReticulumMeshChat(
|
||||||
@@ -1015,7 +1019,11 @@ def test_telemetry_unpack_location_fuzzing(mock_app, packed_location):
|
|||||||
bearing=st.one_of(st.floats(allow_nan=True, allow_infinity=True), st.integers()),
|
bearing=st.one_of(st.floats(allow_nan=True, allow_infinity=True), st.integers()),
|
||||||
accuracy=st.one_of(st.floats(allow_nan=True, allow_infinity=True), st.integers()),
|
accuracy=st.one_of(st.floats(allow_nan=True, allow_infinity=True), st.integers()),
|
||||||
last_update=st.one_of(
|
last_update=st.one_of(
|
||||||
st.integers(), st.floats(), st.text(), st.binary(), st.none()
|
st.integers(),
|
||||||
|
st.floats(),
|
||||||
|
st.text(),
|
||||||
|
st.binary(),
|
||||||
|
st.none(),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
def test_telemetry_pack_location_fuzzing(
|
def test_telemetry_pack_location_fuzzing(
|
||||||
@@ -1052,12 +1060,15 @@ def test_telemetry_pack_location_fuzzing(
|
|||||||
st.none(),
|
st.none(),
|
||||||
),
|
),
|
||||||
data=st.one_of(
|
data=st.one_of(
|
||||||
st.text(), st.binary(), st.dictionaries(keys=st.text(), values=st.text())
|
st.text(),
|
||||||
|
st.binary(),
|
||||||
|
st.dictionaries(keys=st.text(), values=st.text()),
|
||||||
),
|
),
|
||||||
received_from=st.one_of(st.text(), st.binary(), st.none()),
|
received_from=st.one_of(st.text(), st.binary(), st.none()),
|
||||||
physical_link=st.one_of(
|
physical_link=st.one_of(
|
||||||
st.dictionaries(
|
st.dictionaries(
|
||||||
keys=st.text(), values=st.one_of(st.integers(), st.floats(), st.text())
|
keys=st.text(),
|
||||||
|
values=st.one_of(st.integers(), st.floats(), st.text()),
|
||||||
),
|
),
|
||||||
st.text(),
|
st.text(),
|
||||||
st.binary(),
|
st.binary(),
|
||||||
@@ -1456,7 +1467,9 @@ def test_lxst_audio_frame_handling_fuzzing(mock_app, audio_frame):
|
|||||||
caller_identity_hash=st.binary(min_size=0, max_size=100),
|
caller_identity_hash=st.binary(min_size=0, max_size=100),
|
||||||
)
|
)
|
||||||
def test_lxst_call_state_transitions_fuzzing(
|
def test_lxst_call_state_transitions_fuzzing(
|
||||||
mock_app, call_status, caller_identity_hash
|
mock_app,
|
||||||
|
call_status,
|
||||||
|
caller_identity_hash,
|
||||||
):
|
):
|
||||||
"""Fuzz LXST call state transitions with invalid states."""
|
"""Fuzz LXST call state transitions with invalid states."""
|
||||||
try:
|
try:
|
||||||
@@ -1490,7 +1503,7 @@ def test_lxst_call_state_transitions_fuzzing(
|
|||||||
"2400",
|
"2400",
|
||||||
"3200",
|
"3200",
|
||||||
"invalid",
|
"invalid",
|
||||||
]
|
],
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
def test_codec2_decode_fuzzing(mock_app, codec2_data, codec_mode):
|
def test_codec2_decode_fuzzing(mock_app, codec2_data, codec_mode):
|
||||||
@@ -1577,7 +1590,8 @@ def test_lxst_profile_switching_fuzzing(mock_app, profile_id):
|
|||||||
@settings(suppress_health_check=[HealthCheck.function_scoped_fixture], deadline=None)
|
@settings(suppress_health_check=[HealthCheck.function_scoped_fixture], deadline=None)
|
||||||
@given(
|
@given(
|
||||||
destination_hash=st.one_of(
|
destination_hash=st.one_of(
|
||||||
st.binary(min_size=0, max_size=100), st.text(min_size=0, max_size=100)
|
st.binary(min_size=0, max_size=100),
|
||||||
|
st.text(min_size=0, max_size=100),
|
||||||
),
|
),
|
||||||
timeout=st.one_of(
|
timeout=st.one_of(
|
||||||
st.integers(min_value=-100, max_value=1000),
|
st.integers(min_value=-100, max_value=1000),
|
||||||
@@ -1612,7 +1626,8 @@ def test_lxst_call_initiation_fuzzing(mock_app, destination_hash, timeout):
|
|||||||
|
|
||||||
loop.run_until_complete(
|
loop.run_until_complete(
|
||||||
mock_app.telephone_manager.initiate(
|
mock_app.telephone_manager.initiate(
|
||||||
dest_hash_bytes, timeout_seconds=timeout_int
|
dest_hash_bytes,
|
||||||
|
timeout_seconds=timeout_int,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
finally:
|
finally:
|
||||||
@@ -1716,16 +1731,21 @@ def test_lxmf_message_unpacking_fuzzing(mock_app, lxmf_message_data):
|
|||||||
pipeline_config=st.dictionaries(
|
pipeline_config=st.dictionaries(
|
||||||
keys=st.text(),
|
keys=st.text(),
|
||||||
values=st.one_of(
|
values=st.one_of(
|
||||||
st.text(), st.binary(), st.integers(), st.floats(), st.booleans(), st.none()
|
st.text(),
|
||||||
|
st.binary(),
|
||||||
|
st.integers(),
|
||||||
|
st.floats(),
|
||||||
|
st.booleans(),
|
||||||
|
st.none(),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
def test_lxst_pipeline_config_fuzzing(mock_app, pipeline_config):
|
def test_lxst_pipeline_config_fuzzing(mock_app, pipeline_config):
|
||||||
"""Fuzz LXST Pipeline configuration."""
|
"""Fuzz LXST Pipeline configuration."""
|
||||||
from LXST.Pipeline import Pipeline
|
|
||||||
from LXST.Codecs import Null
|
from LXST.Codecs import Null
|
||||||
from LXST.Sources import Source
|
from LXST.Pipeline import Pipeline
|
||||||
from LXST.Sinks import Sink
|
from LXST.Sinks import Sink
|
||||||
|
from LXST.Sources import Source
|
||||||
|
|
||||||
class DummySource(Source):
|
class DummySource(Source):
|
||||||
pass
|
pass
|
||||||
@@ -1748,7 +1768,8 @@ def test_lxst_pipeline_config_fuzzing(mock_app, pipeline_config):
|
|||||||
@settings(suppress_health_check=[HealthCheck.function_scoped_fixture], deadline=None)
|
@settings(suppress_health_check=[HealthCheck.function_scoped_fixture], deadline=None)
|
||||||
@given(
|
@given(
|
||||||
sink_data=st.one_of(
|
sink_data=st.one_of(
|
||||||
st.binary(min_size=0, max_size=10000), st.text(min_size=0, max_size=1000)
|
st.binary(min_size=0, max_size=10000),
|
||||||
|
st.text(min_size=0, max_size=1000),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
def test_lxst_sink_handling_fuzzing(mock_app, sink_data):
|
def test_lxst_sink_handling_fuzzing(mock_app, sink_data):
|
||||||
@@ -1784,7 +1805,8 @@ def test_telemetry_packing_invariants_regression():
|
|||||||
}
|
}
|
||||||
|
|
||||||
packed = Telemeter.pack(
|
packed = Telemeter.pack(
|
||||||
time_utc=original_data["time"]["utc"], location=original_data["location"]
|
time_utc=original_data["time"]["utc"],
|
||||||
|
location=original_data["location"],
|
||||||
)
|
)
|
||||||
unpacked = Telemeter.from_packed(packed)
|
unpacked = Telemeter.from_packed(packed)
|
||||||
|
|
||||||
|
|||||||
@@ -34,12 +34,16 @@ def mock_rns():
|
|||||||
patch("meshchatx.meshchat.get_file_path", return_value="/tmp/mock_path"),
|
patch("meshchatx.meshchat.get_file_path", return_value="/tmp/mock_path"),
|
||||||
patch.object(ReticulumMeshChat, "announce_loop", return_value=None),
|
patch.object(ReticulumMeshChat, "announce_loop", return_value=None),
|
||||||
patch.object(
|
patch.object(
|
||||||
ReticulumMeshChat, "announce_sync_propagation_nodes", return_value=None
|
ReticulumMeshChat,
|
||||||
|
"announce_sync_propagation_nodes",
|
||||||
|
return_value=None,
|
||||||
),
|
),
|
||||||
patch.object(ReticulumMeshChat, "crawler_loop", return_value=None),
|
patch.object(ReticulumMeshChat, "crawler_loop", return_value=None),
|
||||||
patch.object(ReticulumMeshChat, "auto_backup_loop", return_value=None),
|
patch.object(ReticulumMeshChat, "auto_backup_loop", return_value=None),
|
||||||
patch.object(
|
patch.object(
|
||||||
ReticulumMeshChat, "send_config_to_websocket_clients", return_value=None
|
ReticulumMeshChat,
|
||||||
|
"send_config_to_websocket_clients",
|
||||||
|
return_value=None,
|
||||||
),
|
),
|
||||||
):
|
):
|
||||||
# Setup mock instance
|
# Setup mock instance
|
||||||
@@ -51,7 +55,9 @@ def mock_rns():
|
|||||||
patch.object(MockIdentityClass, "from_file", return_value=mock_id_instance),
|
patch.object(MockIdentityClass, "from_file", return_value=mock_id_instance),
|
||||||
patch.object(MockIdentityClass, "recall", return_value=mock_id_instance),
|
patch.object(MockIdentityClass, "recall", return_value=mock_id_instance),
|
||||||
patch.object(
|
patch.object(
|
||||||
MockIdentityClass, "from_bytes", return_value=mock_id_instance
|
MockIdentityClass,
|
||||||
|
"from_bytes",
|
||||||
|
return_value=mock_id_instance,
|
||||||
),
|
),
|
||||||
):
|
):
|
||||||
# Setup mock transport
|
# Setup mock transport
|
||||||
@@ -83,7 +89,7 @@ def test_reticulum_meshchat_init(mock_rns, temp_dir):
|
|||||||
with (
|
with (
|
||||||
patch("meshchatx.src.backend.identity_context.Database") as mock_db_class,
|
patch("meshchatx.src.backend.identity_context.Database") as mock_db_class,
|
||||||
patch(
|
patch(
|
||||||
"meshchatx.src.backend.identity_context.ConfigManager"
|
"meshchatx.src.backend.identity_context.ConfigManager",
|
||||||
) as mock_config_class,
|
) as mock_config_class,
|
||||||
patch("meshchatx.src.backend.identity_context.MessageHandler"),
|
patch("meshchatx.src.backend.identity_context.MessageHandler"),
|
||||||
patch("meshchatx.src.backend.identity_context.AnnounceManager"),
|
patch("meshchatx.src.backend.identity_context.AnnounceManager"),
|
||||||
@@ -152,7 +158,7 @@ def test_reticulum_meshchat_init_with_auth(mock_rns, temp_dir):
|
|||||||
with (
|
with (
|
||||||
patch("meshchatx.src.backend.identity_context.Database"),
|
patch("meshchatx.src.backend.identity_context.Database"),
|
||||||
patch(
|
patch(
|
||||||
"meshchatx.src.backend.identity_context.ConfigManager"
|
"meshchatx.src.backend.identity_context.ConfigManager",
|
||||||
) as mock_config_class,
|
) as mock_config_class,
|
||||||
patch("meshchatx.src.backend.identity_context.MessageHandler"),
|
patch("meshchatx.src.backend.identity_context.MessageHandler"),
|
||||||
patch("meshchatx.src.backend.identity_context.AnnounceManager"),
|
patch("meshchatx.src.backend.identity_context.AnnounceManager"),
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
import shutil
|
|
||||||
import tempfile
|
|
||||||
import base64
|
import base64
|
||||||
import secrets
|
import secrets
|
||||||
from unittest.mock import MagicMock, patch, mock_open
|
import shutil
|
||||||
|
import tempfile
|
||||||
|
from unittest.mock import MagicMock, mock_open, patch
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
import RNS
|
import RNS
|
||||||
|
|
||||||
from meshchatx.meshchat import ReticulumMeshChat, main
|
from meshchatx.meshchat import ReticulumMeshChat, main
|
||||||
|
|
||||||
|
|
||||||
@@ -43,12 +44,16 @@ def mock_rns():
|
|||||||
patch("LXMF.LXMRouter"),
|
patch("LXMF.LXMRouter"),
|
||||||
patch.object(ReticulumMeshChat, "announce_loop", return_value=None),
|
patch.object(ReticulumMeshChat, "announce_loop", return_value=None),
|
||||||
patch.object(
|
patch.object(
|
||||||
ReticulumMeshChat, "announce_sync_propagation_nodes", return_value=None
|
ReticulumMeshChat,
|
||||||
|
"announce_sync_propagation_nodes",
|
||||||
|
return_value=None,
|
||||||
),
|
),
|
||||||
patch.object(ReticulumMeshChat, "crawler_loop", return_value=None),
|
patch.object(ReticulumMeshChat, "crawler_loop", return_value=None),
|
||||||
patch.object(ReticulumMeshChat, "auto_backup_loop", return_value=None),
|
patch.object(ReticulumMeshChat, "auto_backup_loop", return_value=None),
|
||||||
patch.object(
|
patch.object(
|
||||||
ReticulumMeshChat, "send_config_to_websocket_clients", return_value=None
|
ReticulumMeshChat,
|
||||||
|
"send_config_to_websocket_clients",
|
||||||
|
return_value=None,
|
||||||
),
|
),
|
||||||
):
|
):
|
||||||
mock_id_instance = MockIdentityClass()
|
mock_id_instance = MockIdentityClass()
|
||||||
@@ -57,7 +62,9 @@ def mock_rns():
|
|||||||
patch.object(MockIdentityClass, "from_file", return_value=mock_id_instance),
|
patch.object(MockIdentityClass, "from_file", return_value=mock_id_instance),
|
||||||
patch.object(MockIdentityClass, "recall", return_value=mock_id_instance),
|
patch.object(MockIdentityClass, "recall", return_value=mock_id_instance),
|
||||||
patch.object(
|
patch.object(
|
||||||
MockIdentityClass, "from_bytes", return_value=mock_id_instance
|
MockIdentityClass,
|
||||||
|
"from_bytes",
|
||||||
|
return_value=mock_id_instance,
|
||||||
),
|
),
|
||||||
):
|
):
|
||||||
yield {
|
yield {
|
||||||
@@ -73,7 +80,7 @@ def test_run_https_logic(mock_rns, temp_dir):
|
|||||||
with (
|
with (
|
||||||
patch("meshchatx.src.backend.identity_context.Database"),
|
patch("meshchatx.src.backend.identity_context.Database"),
|
||||||
patch(
|
patch(
|
||||||
"meshchatx.src.backend.identity_context.ConfigManager"
|
"meshchatx.src.backend.identity_context.ConfigManager",
|
||||||
) as mock_config_class,
|
) as mock_config_class,
|
||||||
patch("meshchatx.meshchat.generate_ssl_certificate") as mock_gen_cert,
|
patch("meshchatx.meshchat.generate_ssl_certificate") as mock_gen_cert,
|
||||||
patch("ssl.SSLContext") as mock_ssl_context,
|
patch("ssl.SSLContext") as mock_ssl_context,
|
||||||
@@ -97,7 +104,7 @@ def test_run_https_logic(mock_rns, temp_dir):
|
|||||||
mock_config = mock_config_class.return_value
|
mock_config = mock_config_class.return_value
|
||||||
# provide a real-looking secret key
|
# provide a real-looking secret key
|
||||||
mock_config.auth_session_secret.get.return_value = base64.urlsafe_b64encode(
|
mock_config.auth_session_secret.get.return_value = base64.urlsafe_b64encode(
|
||||||
secrets.token_bytes(32)
|
secrets.token_bytes(32),
|
||||||
).decode()
|
).decode()
|
||||||
mock_config.display_name.get.return_value = "Test"
|
mock_config.display_name.get.return_value = "Test"
|
||||||
mock_config.lxmf_propagation_node_stamp_cost.get.return_value = 0
|
mock_config.lxmf_propagation_node_stamp_cost.get.return_value = 0
|
||||||
@@ -137,7 +144,7 @@ def test_database_integrity_recovery(mock_rns, temp_dir):
|
|||||||
with (
|
with (
|
||||||
patch("meshchatx.src.backend.identity_context.Database") as mock_db_class,
|
patch("meshchatx.src.backend.identity_context.Database") as mock_db_class,
|
||||||
patch(
|
patch(
|
||||||
"meshchatx.src.backend.identity_context.ConfigManager"
|
"meshchatx.src.backend.identity_context.ConfigManager",
|
||||||
) as mock_config_class,
|
) as mock_config_class,
|
||||||
patch("meshchatx.src.backend.identity_context.MessageHandler"),
|
patch("meshchatx.src.backend.identity_context.MessageHandler"),
|
||||||
patch("meshchatx.src.backend.identity_context.AnnounceManager"),
|
patch("meshchatx.src.backend.identity_context.AnnounceManager"),
|
||||||
@@ -190,7 +197,7 @@ def test_identity_loading_fallback(mock_rns, temp_dir):
|
|||||||
with (
|
with (
|
||||||
patch("meshchatx.src.backend.identity_context.Database"),
|
patch("meshchatx.src.backend.identity_context.Database"),
|
||||||
patch(
|
patch(
|
||||||
"meshchatx.src.backend.identity_context.ConfigManager"
|
"meshchatx.src.backend.identity_context.ConfigManager",
|
||||||
) as mock_config_class,
|
) as mock_config_class,
|
||||||
patch("RNS.Identity") as mock_id_class,
|
patch("RNS.Identity") as mock_id_class,
|
||||||
patch("os.path.exists", return_value=False), # Pretend files don't exist
|
patch("os.path.exists", return_value=False), # Pretend files don't exist
|
||||||
@@ -210,7 +217,7 @@ def test_identity_loading_fallback(mock_rns, temp_dir):
|
|||||||
# Mock sys.argv to use default behavior (random generation)
|
# Mock sys.argv to use default behavior (random generation)
|
||||||
with patch("sys.argv", ["meshchat.py", "--storage-dir", temp_dir]):
|
with patch("sys.argv", ["meshchat.py", "--storage-dir", temp_dir]):
|
||||||
with patch(
|
with patch(
|
||||||
"meshchatx.meshchat.ReticulumMeshChat"
|
"meshchatx.meshchat.ReticulumMeshChat",
|
||||||
): # Mock ReticulumMeshChat to avoid full init
|
): # Mock ReticulumMeshChat to avoid full init
|
||||||
with patch("aiohttp.web.run_app"):
|
with patch("aiohttp.web.run_app"):
|
||||||
main()
|
main()
|
||||||
@@ -235,25 +242,25 @@ def test_cli_flags_and_envs(mock_rns, temp_dir):
|
|||||||
"MESHCHAT_AUTH": "1",
|
"MESHCHAT_AUTH": "1",
|
||||||
"MESHCHAT_STORAGE_DIR": temp_dir,
|
"MESHCHAT_STORAGE_DIR": temp_dir,
|
||||||
}
|
}
|
||||||
with patch.dict("os.environ", env):
|
with patch.dict("os.environ", env), patch("sys.argv", ["meshchat.py"]):
|
||||||
with patch("sys.argv", ["meshchat.py"]):
|
main()
|
||||||
main()
|
|
||||||
|
|
||||||
# Verify ReticulumMeshChat was called with values from ENV
|
# Verify ReticulumMeshChat was called with values from ENV
|
||||||
args, kwargs = mock_app_class.call_args
|
args, kwargs = mock_app_class.call_args
|
||||||
assert kwargs["auto_recover"] is True
|
assert kwargs["auto_recover"] is True
|
||||||
assert kwargs["auth_enabled"] is True
|
assert kwargs["auth_enabled"] is True
|
||||||
|
|
||||||
# Verify run was called with host/port from ENV
|
# Verify run was called with host/port from ENV
|
||||||
mock_app_instance = mock_app_class.return_value
|
mock_app_instance = mock_app_class.return_value
|
||||||
run_args, run_kwargs = mock_app_instance.run.call_args
|
run_args, run_kwargs = mock_app_instance.run.call_args
|
||||||
assert run_args[0] == "1.2.3.4"
|
assert run_args[0] == "1.2.3.4"
|
||||||
assert run_args[1] == 9000
|
assert run_args[1] == 9000
|
||||||
|
|
||||||
# Test CLI Flags (override Envs)
|
# Test CLI Flags (override Envs)
|
||||||
mock_app_class.reset_mock()
|
mock_app_class.reset_mock()
|
||||||
with patch.dict("os.environ", env):
|
with (
|
||||||
with patch(
|
patch.dict("os.environ", env),
|
||||||
|
patch(
|
||||||
"sys.argv",
|
"sys.argv",
|
||||||
[
|
[
|
||||||
"meshchat.py",
|
"meshchat.py",
|
||||||
@@ -265,11 +272,12 @@ def test_cli_flags_and_envs(mock_rns, temp_dir):
|
|||||||
"--storage-dir",
|
"--storage-dir",
|
||||||
temp_dir,
|
temp_dir,
|
||||||
],
|
],
|
||||||
):
|
),
|
||||||
main()
|
):
|
||||||
|
main()
|
||||||
|
|
||||||
mock_app_instance = mock_app_class.return_value
|
mock_app_instance = mock_app_class.return_value
|
||||||
run_args, run_kwargs = mock_app_instance.run.call_args
|
run_args, run_kwargs = mock_app_instance.run.call_args
|
||||||
assert run_args[0] == "5.6.7.8"
|
assert run_args[0] == "5.6.7.8"
|
||||||
assert run_args[1] == 7000
|
assert run_args[1] == 7000
|
||||||
assert run_kwargs["enable_https"] is False
|
assert run_kwargs["enable_https"] is False
|
||||||
|
|||||||
@@ -38,7 +38,9 @@ def temp_storage(tmp_path):
|
|||||||
|
|
||||||
def test_telephone_manager_init(mock_identity, mock_config, temp_storage):
|
def test_telephone_manager_init(mock_identity, mock_config, temp_storage):
|
||||||
tm = TelephoneManager(
|
tm = TelephoneManager(
|
||||||
mock_identity, config_manager=mock_config, storage_dir=temp_storage
|
mock_identity,
|
||||||
|
config_manager=mock_config,
|
||||||
|
storage_dir=temp_storage,
|
||||||
)
|
)
|
||||||
assert tm.identity == mock_identity
|
assert tm.identity == mock_identity
|
||||||
assert tm.config_manager == mock_config
|
assert tm.config_manager == mock_config
|
||||||
@@ -48,7 +50,11 @@ def test_telephone_manager_init(mock_identity, mock_config, temp_storage):
|
|||||||
|
|
||||||
@patch("meshchatx.src.backend.telephone_manager.Telephone")
|
@patch("meshchatx.src.backend.telephone_manager.Telephone")
|
||||||
def test_call_recording_lifecycle(
|
def test_call_recording_lifecycle(
|
||||||
mock_telephone_class, mock_identity, mock_config, mock_db, temp_storage
|
mock_telephone_class,
|
||||||
|
mock_identity,
|
||||||
|
mock_config,
|
||||||
|
mock_db,
|
||||||
|
temp_storage,
|
||||||
):
|
):
|
||||||
# Setup mocks
|
# Setup mocks
|
||||||
mock_telephone = mock_telephone_class.return_value
|
mock_telephone = mock_telephone_class.return_value
|
||||||
@@ -63,7 +69,10 @@ def test_call_recording_lifecycle(
|
|||||||
mock_telephone.transmit_mixer = MagicMock()
|
mock_telephone.transmit_mixer = MagicMock()
|
||||||
|
|
||||||
tm = TelephoneManager(
|
tm = TelephoneManager(
|
||||||
mock_identity, config_manager=mock_config, storage_dir=temp_storage, db=mock_db
|
mock_identity,
|
||||||
|
config_manager=mock_config,
|
||||||
|
storage_dir=temp_storage,
|
||||||
|
db=mock_db,
|
||||||
)
|
)
|
||||||
tm.get_name_for_identity_hash = MagicMock(return_value="Remote User")
|
tm.get_name_for_identity_hash = MagicMock(return_value="Remote User")
|
||||||
tm.init_telephone()
|
tm.init_telephone()
|
||||||
@@ -90,7 +99,10 @@ def test_call_recording_lifecycle(
|
|||||||
def test_call_recording_disabled(mock_identity, mock_config, mock_db, temp_storage):
|
def test_call_recording_disabled(mock_identity, mock_config, mock_db, temp_storage):
|
||||||
mock_config.call_recording_enabled.get.return_value = False
|
mock_config.call_recording_enabled.get.return_value = False
|
||||||
tm = TelephoneManager(
|
tm = TelephoneManager(
|
||||||
mock_identity, config_manager=mock_config, storage_dir=temp_storage, db=mock_db
|
mock_identity,
|
||||||
|
config_manager=mock_config,
|
||||||
|
storage_dir=temp_storage,
|
||||||
|
db=mock_db,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Mock telephone and active call
|
# Mock telephone and active call
|
||||||
@@ -105,13 +117,15 @@ def test_call_recording_disabled(mock_identity, mock_config, mock_db, temp_stora
|
|||||||
|
|
||||||
def test_audio_profile_persistence(mock_identity, mock_config, temp_storage):
|
def test_audio_profile_persistence(mock_identity, mock_config, temp_storage):
|
||||||
with patch(
|
with patch(
|
||||||
"meshchatx.src.backend.telephone_manager.Telephone"
|
"meshchatx.src.backend.telephone_manager.Telephone",
|
||||||
) as mock_telephone_class:
|
) as mock_telephone_class:
|
||||||
mock_telephone = mock_telephone_class.return_value
|
mock_telephone = mock_telephone_class.return_value
|
||||||
mock_config.telephone_audio_profile_id.get.return_value = 4
|
mock_config.telephone_audio_profile_id.get.return_value = 4
|
||||||
|
|
||||||
tm = TelephoneManager(
|
tm = TelephoneManager(
|
||||||
mock_identity, config_manager=mock_config, storage_dir=temp_storage
|
mock_identity,
|
||||||
|
config_manager=mock_config,
|
||||||
|
storage_dir=temp_storage,
|
||||||
)
|
)
|
||||||
tm.init_telephone()
|
tm.init_telephone()
|
||||||
|
|
||||||
@@ -121,7 +135,11 @@ def test_audio_profile_persistence(mock_identity, mock_config, temp_storage):
|
|||||||
|
|
||||||
@patch("meshchatx.src.backend.telephone_manager.Telephone")
|
@patch("meshchatx.src.backend.telephone_manager.Telephone")
|
||||||
def test_call_recording_saves_after_disconnect(
|
def test_call_recording_saves_after_disconnect(
|
||||||
mock_telephone_class, mock_identity, mock_config, mock_db, temp_storage
|
mock_telephone_class,
|
||||||
|
mock_identity,
|
||||||
|
mock_config,
|
||||||
|
mock_db,
|
||||||
|
temp_storage,
|
||||||
):
|
):
|
||||||
# Setup mocks
|
# Setup mocks
|
||||||
mock_telephone = mock_telephone_class.return_value
|
mock_telephone = mock_telephone_class.return_value
|
||||||
@@ -136,7 +154,10 @@ def test_call_recording_saves_after_disconnect(
|
|||||||
mock_telephone.transmit_mixer = MagicMock()
|
mock_telephone.transmit_mixer = MagicMock()
|
||||||
|
|
||||||
tm = TelephoneManager(
|
tm = TelephoneManager(
|
||||||
mock_identity, config_manager=mock_config, storage_dir=temp_storage, db=mock_db
|
mock_identity,
|
||||||
|
config_manager=mock_config,
|
||||||
|
storage_dir=temp_storage,
|
||||||
|
db=mock_db,
|
||||||
)
|
)
|
||||||
tm.init_telephone()
|
tm.init_telephone()
|
||||||
|
|
||||||
@@ -162,11 +183,16 @@ def test_call_recording_saves_after_disconnect(
|
|||||||
|
|
||||||
@patch("meshchatx.src.backend.telephone_manager.Telephone")
|
@patch("meshchatx.src.backend.telephone_manager.Telephone")
|
||||||
def test_manual_mute_overrides(
|
def test_manual_mute_overrides(
|
||||||
mock_telephone_class, mock_identity, mock_config, temp_storage
|
mock_telephone_class,
|
||||||
|
mock_identity,
|
||||||
|
mock_config,
|
||||||
|
temp_storage,
|
||||||
):
|
):
|
||||||
mock_telephone = mock_telephone_class.return_value
|
mock_telephone = mock_telephone_class.return_value
|
||||||
tm = TelephoneManager(
|
tm = TelephoneManager(
|
||||||
mock_identity, config_manager=mock_config, storage_dir=temp_storage
|
mock_identity,
|
||||||
|
config_manager=mock_config,
|
||||||
|
storage_dir=temp_storage,
|
||||||
)
|
)
|
||||||
tm.init_telephone()
|
tm.init_telephone()
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import unittest
|
import unittest
|
||||||
from unittest.mock import MagicMock, patch
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
from meshchatx.src.backend.translator_handler import TranslatorHandler
|
from meshchatx.src.backend.translator_handler import TranslatorHandler
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import tempfile
|
|||||||
from unittest.mock import MagicMock, patch
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from meshchatx.src.backend.voicemail_manager import VoicemailManager
|
from meshchatx.src.backend.voicemail_manager import VoicemailManager
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
|
import socket
|
||||||
import unittest
|
import unittest
|
||||||
from unittest.mock import MagicMock, patch
|
from unittest.mock import MagicMock, patch
|
||||||
import socket
|
|
||||||
from meshchatx.src.backend.interfaces.WebsocketServerInterface import (
|
|
||||||
WebsocketServerInterface,
|
|
||||||
)
|
|
||||||
from meshchatx.src.backend.interfaces.WebsocketClientInterface import (
|
from meshchatx.src.backend.interfaces.WebsocketClientInterface import (
|
||||||
WebsocketClientInterface,
|
WebsocketClientInterface,
|
||||||
)
|
)
|
||||||
|
from meshchatx.src.backend.interfaces.WebsocketServerInterface import (
|
||||||
|
WebsocketServerInterface,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class TestWebsocketInterfaces(unittest.TestCase):
|
class TestWebsocketInterfaces(unittest.TestCase):
|
||||||
@@ -43,7 +44,7 @@ class TestWebsocketInterfaces(unittest.TestCase):
|
|||||||
|
|
||||||
# We don't want it to actually try connecting in this basic test
|
# We don't want it to actually try connecting in this basic test
|
||||||
with patch(
|
with patch(
|
||||||
"meshchatx.src.backend.interfaces.WebsocketClientInterface.threading.Thread"
|
"meshchatx.src.backend.interfaces.WebsocketClientInterface.threading.Thread",
|
||||||
):
|
):
|
||||||
client = WebsocketClientInterface(self.owner, config)
|
client = WebsocketClientInterface(self.owner, config)
|
||||||
self.assertEqual(client.name, "test_ws_client")
|
self.assertEqual(client.name, "test_ws_client")
|
||||||
|
|||||||
@@ -182,13 +182,13 @@ describe("AboutPage.vue", () => {
|
|||||||
});
|
});
|
||||||
mountAboutPage();
|
mountAboutPage();
|
||||||
|
|
||||||
expect(axiosMock.get).toHaveBeenCalledTimes(4); // info, config, health, snapshots
|
expect(axiosMock.get).toHaveBeenCalledTimes(5); // info, config, health, snapshots, backups
|
||||||
|
|
||||||
vi.advanceTimersByTime(5000);
|
vi.advanceTimersByTime(5000);
|
||||||
expect(axiosMock.get).toHaveBeenCalledTimes(5);
|
expect(axiosMock.get).toHaveBeenCalledTimes(6); // +1 from updateInterval
|
||||||
|
|
||||||
vi.advanceTimersByTime(5000);
|
vi.advanceTimersByTime(5000);
|
||||||
expect(axiosMock.get).toHaveBeenCalledTimes(6);
|
expect(axiosMock.get).toHaveBeenCalledTimes(7); // +2 from updateInterval
|
||||||
});
|
});
|
||||||
|
|
||||||
it("handles vacuum database action", async () => {
|
it("handles vacuum database action", async () => {
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user