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,
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 = []
if aspect:
sql += " AND aspect = ?"
sql += " AND a.aspect = ?"
params.append(aspect)
if identity_hash:
sql += " AND identity_hash = ?"
sql += " AND a.identity_hash = ?"
params.append(identity_hash)
if destination_hash:
sql += " AND destination_hash = ?"
sql += " AND a.destination_hash = ?"
params.append(destination_hash)
if 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])
if 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)
sql += " ORDER BY updated_at DESC"
sql += " ORDER BY a.updated_at DESC"
if limit is not None:
sql += " LIMIT ? OFFSET ?"

View File

@@ -9,8 +9,7 @@ class AsyncUtils:
@staticmethod
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
And: https://github.com/aio-libs/aiohttp/issues/8863
"""
@@ -23,14 +22,25 @@ class AsyncUtils:
original_sendfile = asyncio.base_events.BaseEventLoop.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"):
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(
self, transport, file, offset, count, fallback=fallback
self,
transport,
file,
offset,
count,
fallback=fallback,
)
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 time
from typing import List, Dict, Any
from typing import Any
class CommunityInterfacesManager:
@@ -67,7 +67,8 @@ class CommunityInterfacesManager:
# but that requires Reticulum to be running with a configured interface to that target.
# For "suggested" interfaces, we just check if they are reachable.
reader, writer = await asyncio.wait_for(
asyncio.open_connection(host, port), timeout=3.0
asyncio.open_connection(host, port),
timeout=3.0,
)
writer.close()
await writer.wait_closed()
@@ -90,7 +91,7 @@ class CommunityInterfacesManager:
}
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 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
@@ -100,14 +101,15 @@ class CommunityInterfacesManager:
results = []
for iface in self.interfaces:
status = self.status_cache.get(
iface["name"], {"online": False, "last_check": 0}
iface["name"],
{"online": False, "last_check": 0},
)
results.append(
{
**iface,
"online": status["online"],
"last_check": status["last_check"],
}
},
)
# Sort so online ones are first

View File

@@ -60,6 +60,8 @@ class ConfigManager:
"lxmf_preferred_propagation_node_last_synced_at",
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",
@@ -119,7 +121,9 @@ class ConfigManager:
False,
)
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,
@@ -159,7 +163,9 @@ class ConfigManager:
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_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)

View File

@@ -130,7 +130,7 @@ class Database:
self._checkpoint_wal()
except Exception as e:
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")
@@ -226,7 +226,7 @@ class Database:
os.makedirs(snapshot_dir, exist_ok=True)
# Ensure name is safe for filesystem
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()
if not safe_name:
safe_name = "unnamed_snapshot"
@@ -251,9 +251,10 @@ class Database:
"path": full_path,
"size": stats.st_size,
"created_at": datetime.fromtimestamp(
stats.st_mtime, UTC
stats.st_mtime,
UTC,
).isoformat(),
}
},
)
return sorted(snapshots, key=lambda x: x["created_at"], reverse=True)

View File

@@ -6,19 +6,34 @@ class ContactsDAO:
self.provider = provider
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(
"""
INSERT INTO contacts (name, remote_identity_hash, preferred_ringtone_id, custom_image)
VALUES (?, ?, ?, ?)
INSERT INTO contacts (name, remote_identity_hash, lxmf_address, lxst_address, preferred_ringtone_id, custom_image)
VALUES (?, ?, ?, ?, ?, ?)
ON CONFLICT(remote_identity_hash) DO UPDATE SET
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,
custom_image = EXCLUDED.custom_image,
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):
@@ -26,10 +41,17 @@ class ContactsDAO:
return self.provider.fetchall(
"""
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 ?
""",
(f"%{search}%", f"%{search}%", limit, offset),
(
f"%{search}%",
f"%{search}%",
f"%{search}%",
f"%{search}%",
limit,
offset,
),
)
return self.provider.fetchall(
"SELECT * FROM contacts ORDER BY name ASC LIMIT ? OFFSET ?",
@@ -47,6 +69,8 @@ class ContactsDAO:
contact_id,
name=None,
remote_identity_hash=None,
lxmf_address=None,
lxst_address=None,
preferred_ringtone_id=None,
custom_image=None,
clear_image=False,
@@ -60,6 +84,12 @@ class ContactsDAO:
if remote_identity_hash is not None:
updates.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:
updates.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):
return self.provider.fetchone(
"SELECT * FROM contacts WHERE remote_identity_hash = ?",
(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),
)

View File

@@ -1,4 +1,5 @@
from datetime import UTC, datetime
from .provider import DatabaseProvider
@@ -24,7 +25,13 @@ class DebugLogsDAO:
)
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"
params = []
@@ -83,7 +90,8 @@ class DebugLogsDAO:
if row:
cutoff_ts = row["timestamp"]
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):

View File

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

View File

@@ -69,13 +69,12 @@ class DatabaseProvider:
self.connection.commit()
elif commit is False:
pass
else:
# Default behavior: if we're in a manual transaction, don't commit automatically
if not self.connection.in_transaction:
# In autocommit mode, non-DML statements don't start transactions.
# DML statements might if they are part of a BEGIN block.
# Actually, in isolation_level=None, NOTHING starts a transaction unless we say BEGIN.
pass
# Default behavior: if we're in a manual transaction, don't commit automatically
elif not self.connection.in_transaction:
# In autocommit mode, non-DML statements don't start transactions.
# DML statements might if they are part of a BEGIN block.
# Actually, in isolation_level=None, NOTHING starts a transaction unless we say BEGIN.
pass
return cursor
def begin(self):

View File

@@ -2,7 +2,7 @@ from .provider import DatabaseProvider
class DatabaseSchema:
LATEST_VERSION = 34
LATEST_VERSION = 35
def __init__(self, provider: DatabaseProvider):
self.provider = provider
@@ -63,21 +63,20 @@ class DatabaseSchema:
# Use the connection directly to avoid any middle-ware issues
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
except Exception as e:
# Log but don't crash, we might be able to continue
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 True
return True
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.
"""
# Find the first '(' and the last ')'
@@ -111,7 +110,7 @@ class DatabaseSchema:
definition = definition.strip()
# Skip table-level constraints
if not definition or definition.upper().startswith(
("PRIMARY KEY", "FOREIGN KEY", "UNIQUE", "CHECK")
("PRIMARY KEY", "FOREIGN KEY", "UNIQUE", "CHECK"),
):
continue
@@ -365,6 +364,8 @@ class DatabaseSchema:
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT,
remote_identity_hash TEXT UNIQUE,
lxmf_address TEXT,
lxst_address TEXT,
created_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",
)
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
self._safe_execute(
"""

View File

@@ -1,13 +1,14 @@
import html
import io
import logging
import os
import re
import shutil
import threading
import zipfile
import io
import html
import requests
from meshchatx.src.backend.markdown_renderer import MarkdownRenderer
@@ -46,12 +47,13 @@ class DocsManager:
self._update_current_link()
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)
# Initial population of MeshChatX docs
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()
@@ -115,7 +117,7 @@ class DocsManager:
version_file = os.path.join(self.docs_dir, ".version")
if os.path.exists(version_file):
try:
with open(version_file, "r") as f:
with open(version_file) as f:
return f.read().strip()
except OSError:
pass
@@ -142,7 +144,7 @@ class DocsManager:
# Project root is 3 levels up
this_dir = os.path.dirname(os.path.abspath(__file__))
search_paths.append(
os.path.abspath(os.path.join(this_dir, "..", "..", "..", "docs"))
os.path.abspath(os.path.join(this_dir, "..", "..", "..", "docs")),
)
src_docs = None
@@ -163,13 +165,13 @@ class DocsManager:
# Only copy if source and destination are different
if os.path.abspath(src_path) != os.path.abspath(
dest_path
dest_path,
) and os.access(self.meshchatx_docs_dir, os.W_OK):
shutil.copy2(src_path, dest_path)
# Also pre-render to HTML for easy sharing/viewing
try:
with open(src_path, "r", encoding="utf-8") as f:
with open(src_path, encoding="utf-8") as f:
content = f.read()
html_content = MarkdownRenderer.render(content)
@@ -199,9 +201,9 @@ class DocsManager:
) as f:
f.write(full_html)
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:
logging.error(f"Failed to populate MeshChatX docs: {e}")
logging.exception(f"Failed to populate MeshChatX docs: {e}")
def get_status(self):
return {
@@ -228,15 +230,15 @@ class DocsManager:
if not os.path.exists(self.meshchatx_docs_dir):
return docs
for file in os.listdir(self.meshchatx_docs_dir):
if file.endswith((".md", ".txt")):
docs.append(
{
"name": file,
"path": file,
"type": "markdown" if file.endswith(".md") else "text",
}
)
docs.extend(
{
"name": file,
"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"])
def get_doc_content(self, path):
@@ -244,7 +246,7 @@ class DocsManager:
if not os.path.exists(full_path):
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()
if path.endswith(".md"):
@@ -253,12 +255,11 @@ class DocsManager:
"html": MarkdownRenderer.render(content),
"type": "markdown",
}
else:
return {
"content": content,
"html": f"<pre class='whitespace-pre-wrap font-mono'>{html.escape(content)}</pre>",
"type": "text",
}
return {
"content": content,
"html": f"<pre class='whitespace-pre-wrap font-mono'>{html.escape(content)}</pre>",
"type": "text",
}
def export_docs(self):
"""Creates a zip of all docs and returns the bytes."""
@@ -269,7 +270,8 @@ class DocsManager:
for file in files:
file_path = os.path.join(root, file)
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)
@@ -300,7 +302,9 @@ class DocsManager:
file_path = os.path.join(self.meshchatx_docs_dir, file)
try:
with open(
file_path, "r", encoding="utf-8", errors="ignore"
file_path,
encoding="utf-8",
errors="ignore",
) as f:
content = f.read()
if query in content.lower():
@@ -320,10 +324,10 @@ class DocsManager:
"path": f"/meshchatx-docs/{file}",
"snippet": snippet,
"source": "MeshChatX",
}
},
)
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
if self.has_docs():
@@ -405,7 +409,7 @@ class DocsManager:
"path": f"/reticulum-docs/{rel_path}",
"snippet": snippet,
"source": "Reticulum",
}
},
)
if len(results) >= 25: # Limit results
@@ -469,7 +473,7 @@ class DocsManager:
downloaded_size += len(chunk)
if total_size > 0:
self.download_progress = int(
(downloaded_size / total_size) * 90
(downloaded_size / total_size) * 90,
)
# Extract

View File

@@ -1,28 +1,31 @@
import os
import asyncio
import os
import threading
import RNS
from meshchatx.src.backend.database import Database
from meshchatx.src.backend.integrity_manager import IntegrityManager
from meshchatx.src.backend.config_manager import ConfigManager
from meshchatx.src.backend.message_handler import MessageHandler
from meshchatx.src.backend.announce_handler import AnnounceHandler
from meshchatx.src.backend.announce_manager import AnnounceManager
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.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.telephone_manager import TelephoneManager
from meshchatx.src.backend.voicemail_manager import VoicemailManager
from meshchatx.src.backend.ringtone_manager import RingtoneManager
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.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.forwarding_manager import ForwardingManager
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
from meshchatx.src.backend.voicemail_manager import VoicemailManager
class IdentityContext:
@@ -71,12 +74,15 @@ class IdentityContext:
self.rnstatus_handler = None
self.rnprobe_handler = None
self.translator_handler = None
self.bot_handler = None
self.forwarding_manager = None
self.community_interfaces_manager = None
self.local_lxmf_destination = None
self.announce_handlers = []
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
@@ -102,7 +108,7 @@ class IdentityContext:
is_ok, issues = self.integrity_manager.check_integrity()
if not is_ok:
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"):
self.app.integrity_issues = []
@@ -120,7 +126,7 @@ class IdentityContext:
if not self.app.auto_recover and not getattr(self.app, "emergency", False):
raise
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):
self.app._run_startup_auto_recovery()
@@ -151,8 +157,8 @@ class IdentityContext:
self.app.get_public_path(),
project_root=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,
)
@@ -197,7 +203,7 @@ class IdentityContext:
# 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
@@ -224,6 +230,15 @@ class IdentityContext:
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
self.telephone_manager = TelephoneManager(
self.identity,
@@ -236,17 +251,19 @@ class IdentityContext:
)
self.telephone_manager.on_initiation_status_callback = (
lambda status, target: self.app.on_telephone_initiation_status(
status, target, context=self
status,
target,
context=self,
)
)
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(
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(
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
@@ -287,7 +304,7 @@ class IdentityContext:
):
if not self.docs_manager.has_docs():
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.config.initial_docs_download_attempted.set(True)
@@ -338,13 +355,23 @@ class IdentityContext:
AnnounceHandler(
"lxst.telephony",
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(
"lxmf.delivery",
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(
@@ -354,7 +381,12 @@ class IdentityContext:
ai,
ad,
aph: self.app.on_lxmf_propagation_announce_received(
aspect, dh, ai, ad, aph, context=self
aspect,
dh,
ai,
ad,
aph,
context=self,
),
),
AnnounceHandler(
@@ -364,7 +396,12 @@ class IdentityContext:
ai,
ad,
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 hasattr(self.message_router, "delivery_destinations"):
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]
RNS.Transport.deregister_destination(dest)
@@ -399,7 +436,7 @@ class IdentityContext:
and self.message_router.propagation_destination
):
RNS.Transport.deregister_destination(
self.message_router.propagation_destination
self.message_router.propagation_destination,
)
if self.telephone_manager and self.telephone_manager.telephone:
@@ -408,7 +445,7 @@ class IdentityContext:
and self.telephone_manager.telephone.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)
@@ -423,7 +460,7 @@ class IdentityContext:
self.message_router.exit_handler()
except Exception as e:
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
@@ -432,16 +469,22 @@ class IdentityContext:
self.telephone_manager.teardown()
except Exception as e:
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:
try:
# 1. Checkpoint WAL and close database cleanly to ensure file is stable for hashing
self.database._checkpoint_and_close()
except Exception as e:
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

View File

@@ -50,7 +50,7 @@ class IdentityManager:
metadata = None
if os.path.exists(metadata_path):
try:
with open(metadata_path, "r") as f:
with open(metadata_path) as f:
metadata = json.load(f)
except Exception:
pass
@@ -62,10 +62,10 @@ class IdentityManager:
"display_name": metadata.get("display_name", "Anonymous Peer"),
"icon_name": metadata.get("icon_name"),
"icon_foreground_colour": metadata.get(
"icon_foreground_colour"
"icon_foreground_colour",
),
"icon_background_colour": metadata.get(
"icon_background_colour"
"icon_background_colour",
),
"lxmf_address": metadata.get("lxmf_address"),
"lxst_address": metadata.get("lxst_address"),
@@ -137,14 +137,17 @@ class IdentityManager:
def create_identity(self, display_name=None):
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)
os.makedirs(identity_dir, exist_ok=True)
identity_file = os.path.join(identity_dir, "identity")
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")
@@ -160,7 +163,7 @@ class IdentityManager:
# Save metadata
metadata = {
"display_name": display_name or "Anonymous Peer",
"display_name": display_name,
"icon_name": None,
"icon_foreground_colour": None,
"icon_background_colour": None,
@@ -171,7 +174,7 @@ class IdentityManager:
return {
"hash": identity_hash,
"display_name": display_name or "Anonymous Peer",
"display_name": display_name,
}
def update_metadata_cache(self, identity_hash: str, metadata: dict):
@@ -185,7 +188,7 @@ class IdentityManager:
existing_metadata = {}
if os.path.exists(metadata_path):
try:
with open(metadata_path, "r") as f:
with open(metadata_path) as f:
existing_metadata = json.load(f)
except Exception:
pass
@@ -206,20 +209,20 @@ class IdentityManager:
return False
def restore_identity_from_bytes(self, identity_bytes: bytes) -> dict:
target_path = self.identity_file_path or os.path.join(
self.storage_dir,
"identity",
)
os.makedirs(os.path.dirname(target_path), exist_ok=True)
with open(target_path, "wb") as f:
f.write(identity_bytes)
return {"path": target_path, "size": os.path.getsize(target_path)}
try:
# We use RNS.Identity.from_bytes to validate and get the hash
identity = RNS.Identity.from_bytes(identity_bytes)
if not identity:
raise ValueError("Could not load identity from bytes")
return self._save_new_identity(identity, "Restored Identity")
except Exception as exc:
raise ValueError(f"Failed to restore identity: {exc}")
def restore_identity_from_base32(self, base32_value: str) -> dict:
try:
identity_bytes = base64.b32decode(base32_value, casefold=True)
return self.restore_identity_from_bytes(identity_bytes)
except Exception as exc:
msg = f"Invalid base32 identity: {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 json
from pathlib import Path
import os
from datetime import UTC, datetime
from pathlib import Path
class IntegrityManager:
@@ -30,7 +30,7 @@ class IntegrityManager:
return True, ["Initial run - no manifest yet"]
try:
with open(self.manifest_path, "r") as f:
with open(self.manifest_path) as f:
manifest = json.load(f)
issues = []
@@ -39,7 +39,7 @@ class IntegrityManager:
db_rel = str(self.database_path.relative_to(self.storage_dir))
actual_db_hash = self._hash_file(self.database_path)
if actual_db_hash and actual_db_hash != manifest.get("files", {}).get(
db_rel
db_rel,
):
issues.append(f"Database modified: {db_rel}")
@@ -70,7 +70,8 @@ class IntegrityManager:
m_time = manifest.get("time", "Unknown")
m_id = manifest.get("identity", "Unknown")
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
@@ -84,7 +85,7 @@ class IntegrityManager:
self.issues = issues
return len(issues) == 0, issues
except Exception as e:
return False, [f"Integrity check failed: {str(e)}"]
return False, [f"Integrity check failed: {e!s}"]
def save_manifest(self):
"""Snapshot the current state of critical files."""

View File

@@ -3,10 +3,11 @@ import time
import RNS
from RNS.Interfaces.Interface import Interface
from websockets.sync.server import Server, ServerConnection, serve
from meshchatx.src.backend.interfaces.WebsocketClientInterface import (
WebsocketClientInterface,
)
from websockets.sync.server import Server, ServerConnection, serve
class WebsocketServerInterface(Interface):

View File

@@ -1,6 +1,8 @@
import base64
import json
import LXMF
from meshchatx.src.backend.telemetry_utils import Telemeter

View File

@@ -191,9 +191,9 @@ class MapManager:
for z in zoom_levels:
x1, y1 = self._lonlat_to_tile(min_lon, max_lat, z)
x2, y2 = self._lonlat_to_tile(max_lon, min_lat, z)
for x in range(x1, x2 + 1):
for y in range(y1, y2 + 1):
tiles_to_download.append((z, x, y))
tiles_to_download.extend(
(z, x, y) for x in range(x1, x2 + 1) for y in range(y1, y2 + 1)
)
total_tiles = len(tiles_to_download)
self._export_progress[export_id]["total"] = total_tiles
@@ -265,7 +265,7 @@ class MapManager:
return None
with concurrent.futures.ThreadPoolExecutor(
max_workers=max_workers
max_workers=max_workers,
) as executor:
future_to_tile = {
executor.submit(download_tile, tile): tile
@@ -299,7 +299,8 @@ class MapManager:
):
try:
cursor.executemany(
"INSERT INTO tiles VALUES (?, ?, ?, ?)", batch_data
"INSERT INTO tiles VALUES (?, ?, ?, ?)",
batch_data,
)
conn.commit()
batch_data = []

View File

@@ -1,5 +1,5 @@
import re
import html
import re
class MarkdownRenderer:
@@ -24,12 +24,15 @@ class MarkdownRenderer:
code = match.group(2)
placeholder = f"[[CB{len(code_blocks)}]]"
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
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
@@ -134,7 +137,10 @@ class MarkdownRenderer:
return f'<ul class="my-4 space-y-1">{html_items}</ul>'
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):
@@ -146,7 +152,10 @@ class MarkdownRenderer:
return f'<ol class="my-4 space-y-1">{html_items}</ol>'
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
@@ -169,7 +178,7 @@ class MarkdownRenderer:
# Replace single newlines with <br> for line breaks within paragraphs
part = part.replace("\n", "<br>")
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)

View File

@@ -9,8 +9,7 @@ from LXMF import LXMRouter
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.
"""
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
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 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_conversation_read_state r ON r.destination_hash = m1.peer_hash
"""
@@ -86,7 +90,7 @@ class MessageHandler:
if filter_unread:
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:
@@ -94,7 +98,7 @@ class MessageHandler:
if filter_has_attachments:
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:
@@ -105,7 +109,7 @@ class MessageHandler:
OR m1.peer_hash IN (SELECT peer_hash FROM lxmf_messages WHERE title LIKE ? OR content LIKE ?))
""")
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:

View File

@@ -64,7 +64,8 @@ class PersistentLogHandler(logging.Handler):
# Regex to extract IP and User-Agent from aiohttp access log
# Format: IP [date] "GET ..." status size "referer" "User-Agent"
match = re.search(
r"^([\d\.\:]+) .* \"[^\"]+\" \d+ \d+ \"[^\"]*\" \"([^\"]+)\"", message
r"^([\d\.\:]+) .* \"[^\"]+\" \d+ \d+ \"[^\"]*\" \"([^\"]+)\"",
message,
)
if match:
ip = match.group(1)
@@ -180,7 +181,13 @@ class PersistentLogHandler(logging.Handler):
self.flush_lock.release()
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:
# Flush current buffer first to ensure we have latest logs
@@ -196,34 +203,33 @@ class PersistentLogHandler(logging.Handler):
module=module,
is_anomaly=is_anomaly,
)
else:
# Fallback to in-memory buffer if DB not yet available
logs = list(self.logs_buffer)
if search:
logs = [
log
for log in logs
if search.lower() in log["message"].lower()
or search.lower() in log["module"].lower()
]
if level:
logs = [log for log in logs if log["level"] == level]
if is_anomaly is not None:
logs = [
log
for log in logs
if log["is_anomaly"] == (1 if is_anomaly else 0)
]
# Fallback to in-memory buffer if DB not yet available
logs = list(self.logs_buffer)
if search:
logs = [
log
for log in logs
if search.lower() in log["message"].lower()
or search.lower() in log["module"].lower()
]
if level:
logs = [log for log in logs if log["level"] == level]
if is_anomaly is not None:
logs = [
log for log in logs if log["is_anomaly"] == (1 if is_anomaly else 0)
]
# Sort descending
logs.sort(key=lambda x: x["timestamp"], reverse=True)
return logs[offset : offset + limit]
# Sort descending
logs.sort(key=lambda x: x["timestamp"], reverse=True)
return logs[offset : offset + limit]
def get_total_count(self, search=None, level=None, module=None, is_anomaly=None):
with self.lock:
if self.database:
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 traceback
import platform
import shutil
import sqlite3
import sys
import traceback
import psutil
import RNS
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.
"""
@@ -33,18 +33,14 @@ class CrashRecovery:
self.enabled = False
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:
return
sys.excepthook = self.handle_exception
def disable(self):
"""
Disables the crash recovery system manually.
"""
"""Disables the crash recovery system manually."""
self.enabled = False
def update_paths(
@@ -54,9 +50,7 @@ class CrashRecovery:
public_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:
self.storage_dir = storage_dir
if database_path:
@@ -67,9 +61,7 @@ class CrashRecovery:
self.reticulum_config_dir = reticulum_config_dir
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
if issubclass(exc_type, KeyboardInterrupt):
sys.__excepthook__(exc_type, exc_value, exc_traceback)
@@ -100,13 +92,13 @@ class CrashRecovery:
out.write("Recovery Suggestions:\n")
out.write(" 1. Review the 'System Environment Diagnosis' section above.\n")
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(
" 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(
" 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.flush()
@@ -115,12 +107,10 @@ class CrashRecovery:
sys.exit(1)
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
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")
@@ -128,7 +118,7 @@ class CrashRecovery:
try:
mem = psutil.virtual_memory()
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:
file.write(" [CRITICAL] System memory is dangerously low!\n")
@@ -140,12 +130,12 @@ class CrashRecovery:
file.write(f"- Storage Path: {self.storage_dir}\n")
if not os.path.exists(self.storage_dir):
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:
if not os.access(self.storage_dir, os.W_OK):
file.write(
" [ERROR] Storage path is NOT writable. Check filesystem permissions.\n"
" [ERROR] Storage path is NOT writable. Check filesystem permissions.\n",
)
try:
@@ -154,7 +144,7 @@ class CrashRecovery:
file.write(f" - Disk Space: {free_mb:.1f} MB free\n")
if free_mb < 50:
file.write(
" [CRITICAL] Disk space is critically low (< 50MB)!\n"
" [CRITICAL] Disk space is critically low (< 50MB)!\n",
)
except Exception:
pass
@@ -165,27 +155,28 @@ class CrashRecovery:
if os.path.exists(self.database_path):
if os.path.getsize(self.database_path) == 0:
file.write(
" [WARNING] Database file exists but is empty (0 bytes).\n"
" [WARNING] Database file exists but is empty (0 bytes).\n",
)
else:
try:
# Open in read-only mode for safety during crash handling
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.execute("PRAGMA integrity_check")
res = cursor.fetchone()[0]
if res != "ok":
file.write(
f" [ERROR] Database corruption detected: {res}\n"
f" [ERROR] Database corruption detected: {res}\n",
)
else:
file.write(" - Integrity: OK\n")
conn.close()
except sqlite3.DatabaseError as e:
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:
file.write(f" [ERROR] Database check failed: {e}\n")
@@ -197,13 +188,13 @@ class CrashRecovery:
file.write(f"- Frontend Assets: {self.public_dir}\n")
if not os.path.exists(self.public_dir):
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:
index_path = os.path.join(self.public_dir, "index.html")
if not os.path.exists(index_path):
file.write(
" [ERROR] index.html not found in frontend directory!\n"
" [ERROR] index.html not found in frontend directory!\n",
)
else:
file.write(" - Frontend Status: Assets verified\n")
@@ -212,9 +203,7 @@ class CrashRecovery:
self.run_reticulum_diagnosis(file=file)
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")
# Check config directory
@@ -231,11 +220,11 @@ class CrashRecovery:
else:
try:
# Basic config validation
with open(config_file, "r") as f:
with open(config_file) as f:
content = f.read()
if "[reticulum]" not in content:
file.write(
" [ERROR] Reticulum config file is invalid (missing [reticulum] section).\n"
" [ERROR] Reticulum config file is invalid (missing [reticulum] section).\n",
)
else:
file.write(" - Config File: OK\n")
@@ -255,7 +244,7 @@ class CrashRecovery:
if os.path.exists(logfile):
file.write(f" - Recent Log Entries ({logfile}):\n")
try:
with open(logfile, "r") as f:
with open(logfile) as f:
lines = f.readlines()
if not lines:
file.write(" (Log file is empty)\n")
@@ -283,7 +272,7 @@ class CrashRecovery:
file.write(f" > {iface} [{status}]\n")
else:
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:
pass
@@ -295,7 +284,7 @@ class CrashRecovery:
for conn in psutil.net_connections():
if conn.laddr.port == port and conn.status == "LISTEN":
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:
pass

View File

@@ -55,7 +55,7 @@ class RNPathHandler:
"timestamp": entry.get("timestamp"),
"announce_hash": announce_hash,
"state": state,
}
},
)
# 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,
e["hops"],
e["interface"],
)
),
)
total = len(formatted_table)
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(
[
e
for e in formatted_table
if e["state"] == RNS.Transport.STATE_UNRESPONSIVE
]
],
)
# Pagination
@@ -96,17 +100,16 @@ class RNPathHandler:
def get_rate_table(self):
table = self.reticulum.get_rate_table()
formatted_table = []
for entry in table:
formatted_table.append(
{
"hash": entry["hash"].hex(),
"last": entry["last"],
"timestamps": entry["timestamps"],
"rate_violations": entry["rate_violations"],
"blocked_until": entry["blocked_until"],
}
)
formatted_table = [
{
"hash": entry["hash"].hex(),
"last": entry["last"],
"timestamps": entry["timestamps"],
"rate_violations": entry["rate_violations"],
"blocked_until": entry["blocked_until"],
}
for entry in table
]
return sorted(formatted_table, key=lambda e: e["last"])
def drop_path(self, destination_hash: str) -> bool:

View File

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

View File

@@ -41,7 +41,11 @@ class TelephoneManager:
# 6: STATUS_ESTABLISHED
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.config_manager = config_manager
@@ -177,7 +181,8 @@ class TelephoneManager:
# Pack display name in LXMF-compatible app data format
app_data = msgpack.packb([display_name, None, None])
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()
else:
@@ -190,7 +195,8 @@ class TelephoneManager:
if self.on_initiation_status_callback:
try:
self.on_initiation_status_callback(
self.initiation_status, self.initiation_target_hash
self.initiation_status,
self.initiation_target_hash,
)
except Exception as e:
RNS.log(
@@ -229,7 +235,7 @@ class TelephoneManager:
if not announce:
# 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(
identity_hash=target_hash_hex
identity_hash=target_hash_hex,
)
if announces:
announce = announces[0]
@@ -248,7 +254,7 @@ class TelephoneManager:
if announce.get("identity_public_key"):
try:
return RNS.Identity.from_bytes(
base64.b64decode(announce["identity_public_key"])
base64.b64decode(announce["identity_public_key"]),
)
except Exception:
pass
@@ -297,7 +303,7 @@ class TelephoneManager:
# Use a thread for the blocking LXST call, but monitor status for early exit
# if established elsewhere or timed out/hung up
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()
@@ -340,7 +346,7 @@ class TelephoneManager:
return self.telephone.active_call
except Exception as e:
self._update_initiation_status(f"Failed: {str(e)}")
self._update_initiation_status(f"Failed: {e!s}")
await asyncio.sleep(3)
raise
finally:
@@ -379,7 +385,8 @@ class TelephoneManager:
self.telephone.audio_input.start()
except Exception as e:
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
@@ -415,7 +422,8 @@ class TelephoneManager:
self.telephone.audio_output.start()
except Exception as e:
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

View File

@@ -556,7 +556,8 @@ class VoicemailManager:
os.remove(filepath)
os.rename(temp_path, filepath)
RNS.log(
f"Voicemail: Fixed recording format for {filepath}", RNS.LOG_DEBUG
f"Voicemail: Fixed recording format for {filepath}",
RNS.LOG_DEBUG,
)
else:
RNS.log(

View File

@@ -1,7 +1,6 @@
import asyncio
import json
import threading
from typing import Optional
import numpy as np
import RNS
@@ -89,9 +88,9 @@ class WebAudioBridge:
self.telephone_manager = telephone_manager
self.config_manager = config_manager
self.clients = set()
self.tx_source: Optional[WebAudioSource] = None
self.rx_sink: Optional[WebAudioSink] = None
self.rx_tee: Optional[Tee] = None
self.tx_source: WebAudioSource | None = None
self.rx_sink: WebAudioSink | None = None
self.rx_tee: Tee | None = None
self.loop = asyncio.get_event_loop()
self.lock = threading.Lock()
@@ -137,8 +136,8 @@ class WebAudioBridge:
{
"type": "web_audio.ready",
"frame_ms": frame_ms,
}
)
},
),
)
def push_client_frame(self, pcm_bytes: bytes):
@@ -173,7 +172,8 @@ class WebAudioBridge:
tele.transmit_mixer.start()
except Exception as exc: # noqa: BLE001
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):

View File

@@ -630,6 +630,7 @@ export default {
// stop listening for websocket messages
WebSocketConnection.off("message", this.onWebsocketMessage);
GlobalEmitter.off("config-updated", this.onConfigUpdatedExternally);
},
mounted() {
// listen for websocket messages
@@ -650,6 +651,8 @@ export default {
this.syncPropagationNode();
});
GlobalEmitter.on("config-updated", this.onConfigUpdatedExternally);
GlobalEmitter.on("keyboard-shortcut", (action) => {
this.handleKeyboardShortcut(action);
});
@@ -691,6 +694,12 @@ export default {
}, 15000);
},
methods: {
onConfigUpdatedExternally(newConfig) {
if (!newConfig) return;
this.config = newConfig;
GlobalState.config = newConfig;
this.displayName = newConfig.display_name;
},
applyThemePreference(theme) {
const mode = theme === "dark" ? "dark" : "light";
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="flex items-center justify-between">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">Notifications</h3>
<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 class="flex items-center gap-2">
<button
v-if="notifications.length > 0"
type="button"
class="text-sm text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200 transition-colors"
@click.stop="clearAllNotifications"
>
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>
@@ -139,6 +149,7 @@ export default {
},
},
},
emits: ["notifications-cleared"],
data() {
return {
isDropdownOpen: false,
@@ -233,6 +244,37 @@ export default {
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) {
this.closeDropdown();
if (notification.type === "lxmf_message") {

View File

@@ -556,6 +556,48 @@
</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 -->
<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">
@@ -686,6 +728,7 @@ export default {
snapshotInProgress: false,
snapshotMessage: "",
snapshotError: "",
autoBackups: [],
identityBackupMessage: "",
identityBackupError: "",
identityBase32: "",
@@ -713,6 +756,7 @@ export default {
this.getConfig();
this.getDatabaseHealth();
this.listSnapshots();
this.listAutoBackups();
// Update stats every 5 seconds
this.updateInterval = setInterval(() => {
this.getAppInfo();
@@ -738,6 +782,14 @@ export default {
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() {
if (this.snapshotInProgress) return;
this.snapshotInProgress = true;
@@ -1071,7 +1123,7 @@ export default {
const response = await window.axios.post("/api/v1/identity/restore", formData, {
headers: { "Content-Type": "multipart/form-data" },
});
this.identityRestoreMessage = response.data.message || "Identity restored. Restart app.";
this.identityRestoreMessage = response.data.message || "Identity imported.";
} catch (e) {
this.identityRestoreError = "Identity restore failed";
console.log(e);
@@ -1094,7 +1146,7 @@ export default {
const response = await window.axios.post("/api/v1/identity/restore", {
base32: this.identityRestoreBase32.trim(),
});
this.identityRestoreMessage = response.data.message || "Identity restored. Restart app.";
this.identityRestoreMessage = response.data.message || "Identity imported.";
} catch (e) {
this.identityRestoreError = "Identity restore failed";
console.log(e);

View File

@@ -516,12 +516,18 @@
:label="$t('call.allow_calls_from_contacts_only')"
@update:model-value="toggleAllowCallsFromContactsOnly"
/>
<Toggle
id="web-audio-toggle"
:model-value="config?.telephone_web_audio_enabled"
label="Browser/Electron Audio"
@update:model-value="onToggleWebAudio"
/>
<div class="flex flex-col gap-1">
<Toggle
id="web-audio-toggle"
:model-value="config?.telephone_web_audio_enabled"
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 class="flex flex-col gap-2 shrink-0">
<!-- <Toggle
@@ -676,6 +682,7 @@
<div class="relative shrink-0">
<LxmfUserIcon
:custom-image="
entry.contact_image ||
getContactByHash(entry.remote_identity_hash)?.custom_image
"
:icon-name="entry.remote_icon ? entry.remote_icon.icon_name : ''"
@@ -845,18 +852,12 @@
<div class="flex items-center space-x-4">
<div class="shrink-0">
<LxmfUserIcon
v-if="announce.lxmf_user_icon"
:icon-name="announce.lxmf_user_icon.icon_name"
:icon-foreground-colour="announce.lxmf_user_icon.foreground_colour"
:icon-background-colour="announce.lxmf_user_icon.background_colour"
:custom-image="announce.contact_image"
:icon-name="announce.lxmf_user_icon?.icon_name"
:icon-foreground-colour="announce.lxmf_user_icon?.foreground_colour"
:icon-background-colour="announce.lxmf_user_icon?.background_colour"
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 class="flex-1 min-w-0">
<div class="flex items-center justify-between">
@@ -1461,16 +1462,34 @@
</div>
</div>
<div class="flex items-center justify-between mt-1">
<span
class="text-[10px] text-gray-500 dark:text-zinc-500 font-mono truncate cursor-pointer hover:text-blue-500 transition-colors"
:title="contact.remote_identity_hash"
@click.stop="copyHash(contact.remote_identity_hash)"
>
{{ formatDestinationHash(contact.remote_identity_hash) }}
</span>
<div class="flex flex-col min-w-0">
<span
class="text-[10px] text-gray-500 dark:text-zinc-500 font-mono truncate cursor-pointer hover:text-blue-500 transition-colors"
:title="contact.remote_identity_hash"
@click.stop="copyHash(contact.remote_identity_hash)"
>
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
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="
destinationHash =
contact.remote_telephony_hash ||
@@ -2044,6 +2063,34 @@
placeholder="e.g. a39610c89d18bb48c73e429582423c24"
/>
</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>
<label
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 = {
name: "",
remote_identity_hash: "",
lxmf_address: "",
lxst_address: "",
preferred_ringtone_id: null,
custom_image: null,
};
@@ -2955,6 +3004,8 @@ export default {
id: contact.id,
name: contact.name,
remote_identity_hash: contact.remote_identity_hash,
lxmf_address: contact.lxmf_address || "",
lxst_address: contact.lxst_address || "",
preferred_ringtone_id: contact.preferred_ringtone_id,
custom_image: contact.custom_image,
};

View File

@@ -1076,6 +1076,207 @@
</template>
</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 -->
<div class="p-2 bg-white rounded shadow divide-y divide-gray-200 dark:bg-zinc-900">
<button
@@ -1100,10 +1301,12 @@ import FormLabel from "../forms/FormLabel.vue";
import FormSubLabel from "../forms/FormSubLabel.vue";
import Toggle from "../forms/Toggle.vue";
import GlobalState from "../../js/GlobalState";
import MaterialDesignIcon from "../MaterialDesignIcon.vue";
export default {
name: "AddInterfacePage",
components: {
MaterialDesignIcon,
FormSubLabel,
FormLabel,
ExpandingSection,
@@ -1147,6 +1350,32 @@ export default {
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,
newInterfaceForwardPort: null,
@@ -1250,6 +1479,7 @@ export default {
},
mounted() {
this.getConfig();
this.loadReticulumDiscoveryConfig();
this.loadComports();
this.loadCommunityInterfaces();
@@ -1279,6 +1509,66 @@ export default {
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() {
try {
const response = await window.axios.get(`/api/v1/comports`);
@@ -1408,6 +1698,22 @@ export default {
this.sharedInterfaceSettings.network_name = iface.network_name;
this.sharedInterfaceSettings.passphrase = iface.passphrase;
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 {
// 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
const response = await window.axios.post(`/api/v1/reticulum/interfaces/add`, {
allow_overwriting_interface: this.isEditingInterface,
@@ -1506,6 +1821,32 @@ export default {
airtime_limit_long: this.newInterfaceAirtimeLimitLong,
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
mode: this.sharedInterfaceSettings.mode || "full",
bitrate: this.sharedInterfaceSettings.bitrate,

View File

@@ -42,6 +42,7 @@
<span :class="statusChipClass">{{
isInterfaceEnabled(iface) ? $t("app.enabled") : $t("app.disabled")
}}</span>
<span v-if="isDiscoverable()" class="discoverable-chip">Discoverable</span>
</div>
<div class="text-sm text-gray-600 dark:text-gray-300">
{{ description }}
@@ -244,6 +245,13 @@ export default {
onIFACSignatureClick: function (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) {
return Utils.isInterfaceEnabled(iface);
},
@@ -292,6 +300,9 @@ export default {
.ifac-line {
@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 {
@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>
<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
v-for="iface of filteredInterfaces"
:key="iface._name"
@@ -150,10 +258,12 @@ import DownloadUtils from "../../js/DownloadUtils";
import MaterialDesignIcon from "../MaterialDesignIcon.vue";
import ToastUtils from "../../js/ToastUtils";
import GlobalState from "../../js/GlobalState";
import Toggle from "../forms/Toggle.vue";
export default {
name: "InterfacesPage",
components: {
Toggle,
ImportInterfacesModal,
Interface,
MaterialDesignIcon,
@@ -168,6 +278,14 @@ export default {
typeFilter: "all",
reloadingRns: false,
isReticulumRunning: true,
discoveryConfig: {
discover_interfaces: false,
interface_discovery_sources: "",
required_discovery_value: null,
autoconnect_discovered_interfaces: 0,
network_identity: "",
},
savingDiscovery: false,
};
},
computed: {
@@ -246,6 +364,7 @@ export default {
mounted() {
this.loadInterfaces();
this.updateInterfaceStats();
this.loadDiscoveryConfig();
// update info every few seconds
this.reloadInterval = setInterval(() => {
@@ -387,6 +506,65 @@ export default {
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) {
this.statusFilter = value;
},

View File

@@ -116,6 +116,22 @@
>
<v-icon icon="mdi-trash-can-outline" size="18" class="sm:!size-5"></v-icon>
</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>
<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"
@@ -211,18 +227,27 @@
<!-- note hover tooltip -->
<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"
:style="{
left: map.getPixelFromCoordinate(hoveredNote.getGeometry().getCoordinates())[0] + 'px',
top: map.getPixelFromCoordinate(hoveredNote.getGeometry().getCoordinates())[1] + 'px',
left: map.getPixelFromCoordinate(hoveredFeature.getGeometry().getCoordinates())[0] + 'px',
top: map.getPixelFromCoordinate(hoveredFeature.getGeometry().getCoordinates())[1] + 'px',
}"
>
<div class="font-bold flex items-center gap-1 mb-1 text-amber-500">
<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 class="whitespace-pre-wrap break-words">{{ hoveredNote.get("note") || "Empty note" }}</div>
</div>
<!-- inline note editor (overlay) -->
@@ -266,6 +291,58 @@
</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 -->
<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">
@@ -550,11 +627,11 @@
>
<div class="flex justify-between space-x-4">
<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 class="flex justify-between space-x-4">
<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>
@@ -710,11 +787,11 @@
</div>
<div class="flex justify-between">
<span>Lat:</span>
<span class="font-mono">{{ currentCenter[1].toFixed(5) }}</span>
<span class="font-mono">{{ displayCoords[1].toFixed(5) }}</span>
</div>
<div class="flex justify-between">
<span>Lon:</span>
<span class="font-mono">{{ currentCenter[0].toFixed(5) }}</span>
<span class="font-mono">{{ displayCoords[0].toFixed(5) }}</span>
</div>
</div>
</div>
@@ -967,15 +1044,18 @@ import XYZ from "ol/source/XYZ";
import VectorSource from "ol/source/Vector";
import Feature from "ol/Feature";
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 { defaults as defaultControls } from "ol/control";
import DragBox from "ol/interaction/DragBox";
import Draw from "ol/interaction/Draw";
import Modify from "ol/interaction/Modify";
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 { LineString, Polygon } from "ol/geom";
import { LineString, Polygon, Circle } from "ol/geom";
import { fromCircle } from "ol/geom/Polygon";
import { unByKey } from "ol/Observable";
import Overlay from "ol/Overlay";
import GeoJSON from "ol/format/GeoJSON";
@@ -1001,6 +1081,7 @@ export default {
isSettingsOpen: false,
currentCenter: [0, 0],
currentZoom: 2,
cursorCoords: null,
config: null,
peers: {},
@@ -1059,8 +1140,8 @@ export default {
drawType: null, // 'Point', 'LineString', 'Polygon', 'Circle' or null
isDrawing: false,
drawingTools: [
{ type: "Select", icon: "cursor-default" },
{ type: "Point", icon: "map-marker-plus" },
{ type: "Note", icon: "note-text-outline" },
{ type: "LineString", icon: "vector-line" },
{ type: "Polygon", icon: "vector-polygon" },
{ type: "Circle", icon: "circle-outline" },
@@ -1074,6 +1155,7 @@ export default {
helpTooltip: null,
measureTooltipElement: null,
measureTooltip: null,
measurementOverlays: [],
// drawing storage
savedDrawings: [],
@@ -1081,13 +1163,22 @@ export default {
// note editing
editingFeature: null,
noteText: "",
hoveredNote: null,
hoveredFeature: null,
noteOverlay: null,
showNoteModal: false,
showSaveDrawingModal: false,
newDrawingName: "",
isLoadingDrawings: false,
showLoadDrawingModal: false,
styleCache: {},
selectedFeature: null,
select: null,
translate: null,
// context menu
showContextMenu: false,
contextMenuPos: { x: 0, y: 0 },
contextMenuFeature: null,
contextMenuCoord: null,
};
},
computed: {
@@ -1104,6 +1195,9 @@ export default {
}
return total;
},
displayCoords() {
return this.cursorCoords || this.currentCenter;
},
},
watch: {
showSaveDrawingModal(val) {
@@ -1148,7 +1242,9 @@ export default {
dataProjection: "EPSG:4326",
featureProjection: "EPSG:3857",
});
console.log("Restoring persisted drawings, count:", features.length);
this.drawSource.addFeatures(features);
this.rebuildMeasurementOverlays();
} catch (e) {
console.error("Failed to restore persisted drawings", e);
}
@@ -1201,6 +1297,19 @@ export default {
}, 30000);
},
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.exportInterval) clearInterval(this.exportInterval);
if (this.searchTimeout) clearTimeout(this.searchTimeout);
@@ -1209,17 +1318,37 @@ export default {
WebSocketConnection.off("message", this.onWebsocketMessage);
},
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 {
// Serialize drawings
let drawings = null;
if (this.drawSource) {
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(
JSON.stringify({
center: this.currentCenter,
@@ -1231,6 +1360,7 @@ export default {
})
);
await TileCache.setMapState("last_view", state);
console.log("Map state persisted to cache, drawings size:", drawings ? drawings.length : 0);
} catch (e) {
console.error("Failed to save map state", e);
}
@@ -1340,20 +1470,19 @@ export default {
source: this.drawSource,
style: (feature) => {
const type = feature.get("type");
if (type === "note") {
return new Style({
image: new CircleStyle({
radius: 10,
fill: new Fill({
color: "#f59e0b",
}),
stroke: new Stroke({
color: "#ffffff",
width: 2,
}),
}),
// Use a simple circle for now as custom fonts in canvas can be tricky
// or use the built-in Text style if we are sure it works
const geometry = feature.getGeometry();
const geomType = geometry ? geometry.getType() : null;
if (type === "note" || geomType === "Point") {
const isNote = type === "note";
return this.createMarkerStyle({
iconColor: isNote ? "#f59e0b" : "#3b82f6",
bgColor: "#ffffff",
label: isNote && feature.get("note") ? "Note" : "",
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"
: null,
});
}
return new Style({
@@ -1375,6 +1504,7 @@ export default {
zIndex: 50,
});
this.map.addLayer(this.drawLayer);
this.attachDrawPersistence();
this.noteOverlay = new Overlay({
element: this.$refs.noteOverlayElement,
@@ -1387,16 +1517,83 @@ export default {
this.map.addOverlay(this.noteOverlay);
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.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.map.addInteraction(this.snap);
// Right-click context menu
this.map.getViewport().addEventListener("contextmenu", this.onContextMenu);
// setup telemetry markers
this.markerSource = new VectorSource();
this.markerLayer = new VectorLayer({
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,
});
this.map.addLayer(this.markerLayer);
@@ -1404,12 +1601,19 @@ export default {
this.map.on("pointermove", this.handleMapPointerMove);
this.map.on("click", (evt) => {
this.handleMapClick(evt);
this.closeContextMenu();
const feature = this.map.forEachFeatureAtPixel(evt.pixel, (f) => f);
if (feature && feature.get("telemetry")) {
this.onMarkerClick(feature);
} else {
this.selectedMarker = null;
}
// Deselect drawing if clicking empty space
if (!feature && this.select) {
this.select.getFeatures().clear();
this.selectedFeature = null;
}
});
this.currentCenter = [defaultLon, defaultLat];
@@ -1431,6 +1635,9 @@ export default {
this.map.addInteraction(this.dragBox);
this.isMapLoaded = true;
// Close context menu when clicking elsewhere
document.addEventListener("click", this.handleGlobalClick);
},
isLocalUrl(url) {
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
toggleDraw(type) {
if (!this.map) return;
if (this.drawType === type && !this.isMeasuring) {
if (this.drawType === type && !this.isDrawing) {
this.stopDrawing();
return;
}
@@ -2069,27 +2295,79 @@ export default {
this.isMeasuring = false;
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({
source: this.drawSource,
type: type === "Note" ? "Point" : type,
type: type,
});
this.draw.on("drawstart", () => {
this.draw.on("drawstart", (evt) => {
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.isDrawing = false;
const feature = evt.feature;
if (type === "Note") {
feature.set("type", "note");
feature.set("note", "");
// Open edit box after a short delay to let the feature settle
setTimeout(() => {
this.startEditingNote(feature);
}, 200);
feature.set("type", "draw"); // Tag as custom drawing for styling
// Clean up sketch listener and tooltips unless it was the Measure tool
if (this._drawListener) {
unByKey(this._drawListener);
this._drawListener = null;
}
// 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);
});
@@ -2098,7 +2376,8 @@ export default {
startEditingNote(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) {
this.showNoteModal = true;
} else {
@@ -2109,13 +2388,29 @@ export default {
updateNoteOverlay() {
if (!this.editingFeature || !this.map) return;
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);
},
saveNote() {
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.closeNoteEditor();
@@ -2144,19 +2439,190 @@ export default {
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) {
if (!this.map) return;
const lonLat = toLonLat(evt.coordinate);
this.cursorCoords = [lonLat[0], lonLat[1]];
if (evt.dragging || this.isDrawing || this.isMeasuring) return;
const pixel = this.map.getEventPixel(evt.originalEvent);
const feature = this.map.forEachFeatureAtPixel(pixel, (f) => f, {
layerFilter: (l) => l === this.drawLayer,
});
const feature = this.map.forEachFeatureAtPixel(pixel, (f) => f);
if (feature && feature.get("type") === "note") {
this.hoveredNote = feature;
if (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";
} else {
this.hoveredNote = null;
this.hoveredFeature = null;
this.map.getTargetElement().style.cursor = "";
}
},
@@ -2181,6 +2647,9 @@ export default {
this.map.removeInteraction(this.draw);
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.isDrawing = false;
this.stopMeasuring();
@@ -2402,8 +2871,11 @@ export default {
}
const format = new GeoJSON();
const features = this.drawSource.getFeatures();
const json = format.writeFeatures(features);
const features = this.serializeFeatures(this.drawSource.getFeatures());
const json = format.writeFeatures(features, {
dataProjection: "EPSG:4326",
featureProjection: "EPSG:3857",
});
try {
await window.axios.post("/api/v1/map/drawings", {
@@ -2426,6 +2898,7 @@ export default {
});
this.drawSource.clear();
this.drawSource.addFeatures(features);
await this.saveMapState();
this.showLoadDrawingModal = false;
ToastUtils.success(`Loaded "${drawing.name}"`);
},
@@ -2493,44 +2966,47 @@ export default {
const loc = t.telemetry?.location;
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({
geometry: new Point(fromLonLat([loc.longitude, loc.latitude])),
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);
}
},
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) {
this.selectedMarker = {
telemetry: feature.get("telemetry"),

View File

@@ -180,6 +180,18 @@
<div class="text-[10px] text-gray-500 dark:text-zinc-500 font-mono truncate">
{{ contact.remote_identity_hash }}
</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>
</button>
</div>
@@ -264,11 +276,17 @@
<span class="text-sm font-bold">Contact Shared</span>
</div>
<div class="flex items-center gap-3">
<div
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"
>
{{ getParsedItems(chatItem).contact.name.charAt(0).toUpperCase() }}
</div>
<LxmfUserIcon
:custom-image="getParsedItems(chatItem).contact.custom_image"
:icon-name="getParsedItems(chatItem).contact.lxmf_user_icon?.icon_name"
:icon-foreground-colour="
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="text-sm font-bold text-gray-900 dark:text-white truncate">
{{ getParsedItems(chatItem).contact.name }}
@@ -278,6 +296,18 @@
>
{{ getParsedItems(chatItem).contact.hash }}
</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>
<button
@@ -286,7 +316,9 @@
@click="
addContact(
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,
};
// Parse contact: Contact: ivan <ca314c30b27eacec5f6ca6ac504e94c9>
const contactMatch = content.match(/^Contact:\s+(.+?)\s+<([a-fA-F0-9]{32})>$/i);
// Parse contact: Contact: ivan <ca314c30b27eacec5f6ca6ac504e94c9> [LXMF: ...] [LXST: ...]
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) {
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 = {
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;
},
async addContact(name, hash) {
async addContact(name, hash, lxmf_address = null, lxst_address = null) {
try {
// Check if contact already exists
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", {
name: name,
remote_identity_hash: hash,
lxmf_address: lxmf_address,
lxst_address: lxst_address,
});
ToastUtils.success(`Added ${name} to contacts`);
} catch (e) {
@@ -2539,10 +2591,13 @@ export default {
ToastUtils.error("Failed to load contacts");
}
},
async shareContact(contact) {
this.newMessageText = `Contact: ${contact.name} <${contact.remote_identity_hash}>`;
shareContact(contact) {
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;
await this.sendMessage();
this.sendMessage();
},
shareAsPaperMessage(chatItem) {
this.paperMessageHash = chatItem.lxmf_message.hash;

View File

@@ -192,6 +192,7 @@ export default {
// stop listening for websocket messages
WebSocketConnection.off("message", this.onWebsocketMessage);
GlobalEmitter.off("compose-new-message", this.onComposeNewMessage);
GlobalEmitter.off("refresh-conversations", this.requestConversationsRefresh);
},
mounted() {
// listen for websocket messages
@@ -371,6 +372,20 @@ export default {
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.hasMoreConversations = newConversations.length === this.pageSize;
} catch (e) {
@@ -402,7 +417,8 @@ export default {
return params;
},
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) {
// update selected peer

View File

@@ -267,6 +267,7 @@
<div class="my-auto mr-2">
<LxmfUserIcon
:custom-image="peer.contact_image"
:icon-name="peer.lxmf_user_icon?.icon_name"
:icon-foreground-colour="peer.lxmf_user_icon?.foreground_colour"
:icon-background-colour="peer.lxmf_user_icon?.background_colour"
@@ -457,6 +458,9 @@ export default {
return matchesDisplayName || matchesCustomDisplayName || matchesDestinationHash;
});
},
hasUnreadConversations() {
return this.conversations.some((c) => c.is_unread);
},
},
methods: {
isBlocked(destinationHash) {

View File

@@ -212,6 +212,8 @@ import LxmfUserIcon from "../LxmfUserIcon.vue";
import ToastUtils from "../../js/ToastUtils";
import ColourPickerDropdown from "../ColourPickerDropdown.vue";
import MaterialDesignIcon from "../MaterialDesignIcon.vue";
import GlobalState from "../../js/GlobalState";
import GlobalEmitter from "../../js/GlobalEmitter";
export default {
name: "ProfileIconPage",
@@ -324,6 +326,8 @@ export default {
try {
const response = await window.axios.patch("/api/v1/config", config);
this.config = response.data.config;
GlobalState.config = response.data.config;
GlobalEmitter.emit("config-updated", response.data.config);
this.saveOriginalValues();
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"
>
<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="text-xs uppercase tracking-wide text-gray-500 dark:text-gray-400">
{{ $t("tools.utilities") }}
@@ -16,184 +16,61 @@
</div>
</div>
<div class="grid gap-4 md:grid-cols-2">
<RouterLink :to="{ name: 'ping' }" 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="radar" class="w-6 h-6" />
</div>
<div class="flex-1">
<div class="tool-card__title">{{ $t("tools.ping.title") }}</div>
<div class="tool-card__description">
{{ $t("tools.ping.description") }}
</div>
</div>
<MaterialDesignIcon icon-name="chevron-right" class="tool-card__chevron" />
</RouterLink>
<div class="glass-card">
<div class="relative">
<MaterialDesignIcon
icon-name="magnify"
class="absolute left-3 top-1/2 transform -translate-y-1/2 w-5 h-5 text-gray-400"
/>
<input
v-model="searchQuery"
type="text"
:placeholder="$t('common.search')"
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"
/>
</div>
</div>
<RouterLink :to="{ name: 'rnprobe' }" 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"
>
<MaterialDesignIcon icon-name="radar" class="w-6 h-6" />
<div class="grid gap-4 grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
<RouterLink
v-for="tool in filteredTools"
:key="tool.name"
: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 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">
{{ $t("tools.rnprobe.description") }}
{{ tool.description }}
</div>
</div>
<MaterialDesignIcon icon-name="chevron-right" class="tool-card__chevron" />
</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">
<div v-if="tool.extraAction" class="flex items-center gap-2">
<a
href="/rnode-flasher/index.html"
target="_blank"
:href="tool.extraAction.href"
: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"
@click.stop
>
<MaterialDesignIcon icon-name="open-in-new" class="size-5" />
<MaterialDesignIcon :icon-name="tool.extraAction.icon" class="size-5" />
</a>
<MaterialDesignIcon icon-name="chevron-right" class="tool-card__chevron" />
</div>
<MaterialDesignIcon v-else icon-name="chevron-right" class="tool-card__chevron" />
</RouterLink>
</div>
<RouterLink :to="{ name: 'debug-logs' }" class="tool-card glass-card border-dashed border-2">
<div class="tool-card__icon bg-zinc-100 text-zinc-500 dark:bg-zinc-800 dark:text-zinc-400">
<MaterialDesignIcon icon-name="console" class="w-6 h-6" />
</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 v-if="filteredTools.length === 0" class="glass-card text-center py-12">
<MaterialDesignIcon icon-name="magnify" class="w-12 h-12 mx-auto mb-4 text-gray-400" />
<div class="text-gray-600 dark:text-gray-400">{{ $t("common.no_results") }}</div>
</div>
</div>
</div>
@@ -210,8 +87,148 @@ export default {
data() {
return {
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>

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": {
"name": "Reticulum MeshChatX",
"sync_messages": "Nachrichten synchronisieren",
@@ -183,7 +210,9 @@
"shutdown": "Ausschalten",
"acknowledge_reset": "Bestätigen & Zurücksetzen",
"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": {
"title": "Identitäten",
@@ -665,6 +694,10 @@
"paper_message": {
"title": "Papiernachricht",
"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": {

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": {
"name": "Reticulum MeshChatX",
"sync_messages": "Sync Messages",
@@ -183,7 +210,9 @@
"shutdown": "Shutdown",
"acknowledge_reset": "Acknowledge & Reset",
"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": {
"title": "Identities",
@@ -665,6 +694,10 @@
"paper_message": {
"title": "Paper Message",
"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": {

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": {
"name": "Reticulum MeshChatX",
"sync_messages": "Синхронизировать сообщения",
@@ -183,7 +210,9 @@
"shutdown": "Выключить",
"acknowledge_reset": "Подтвердить и сбросить",
"confirm": "Подтвердить",
"delete_confirm": "Вы уверены, что хотите удалить это? Это действие нельзя отменить."
"delete_confirm": "Вы уверены, что хотите удалить это? Это действие нельзя отменить.",
"search": "Поиск инструментов...",
"no_results": "Инструменты не найдены"
},
"identities": {
"title": "Личности",
@@ -665,6 +694,10 @@
"paper_message": {
"title": "Бумажное сообщение",
"description": "Создание и чтение подписанных бумажных сообщений LXMF через QR-коды."
},
"bots": {
"title": "LXMFy Боты",
"description": "Управление автоматизированными ботами для эха, заметок и напоминаний с помощью LXMFy."
}
},
"ping": {

View File

@@ -189,6 +189,11 @@ const router = createRouter({
path: "/translator",
component: defineAsyncComponent(() => import("./components/translator/TranslatorPage.vue")),
},
{
name: "bots",
path: "/bots",
component: defineAsyncComponent(() => import("./components/tools/BotsPage.vue")),
},
{
name: "forwarder",
path: "/forwarder",