numerous improvements

This commit is contained in:
2026-01-05 11:47:35 -06:00
parent 5694c1ee67
commit fda9187e95
104 changed files with 4567 additions and 1070 deletions

View File

File diff suppressed because it is too large Load Diff

View File

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

View File

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

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

View 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()

View 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()

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,4 +1,5 @@
from datetime import UTC, datetime from datetime import UTC, datetime
from .provider import DatabaseProvider from .provider import DatabaseProvider

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 = []

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,5 +1,6 @@
import time import time
from typing import Any from typing import Any
import RNS import RNS

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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,
}; };

View File

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

View File

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

View File

@@ -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;
}, },

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

@@ -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": {

View File

@@ -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": {

View File

@@ -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": {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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(&#x27;hello&#x27;)" in rendered "print(&#x27;hello&#x27;)" in rendered
or "print(&#039;hello&#039;)" in rendered or "print(&#039;hello&#039;)" in rendered,
) )
def test_lists(self): def test_lists(self):

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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