feat: various updates to filesync, ui, deps, add active sessions in about page and stuff related to new rns 1.4.0

This commit is contained in:
Ivan 2026-07-22 10:32:36 -05:00
parent a40a27c880
commit e5756d7ab0
No known key found for this signature in database
92 changed files with 10075 additions and 3842 deletions

View file

@ -10,17 +10,27 @@ All notable changes to this project will be documented in this file.
- Bundled Bug Reports plugin and plugin i18n / UI slots / contribution registries
- Map overlays from NomadNet and RNGit (KMZ/KML/GeoJSON) with cache and refresh
- RNS Link WebSocket API for external apps (and plugin capabilities)
- RNS File Sync for shared folders on the mesh, with an in-app file manager to browse, upload, download, and delete files in the sync folder
- Settings: Reticulum instance/share controls, tabbed Settings nav, desktop close/tray behavior
- Nomad favourites: per-identity section layout in the database
- Optional pip-rns / rngit install path for RNS packages and docs
- Message export and import with contacts and read state
- Message maintenance in Settings: purge old local messages and clear duplicates
- Host battery status on About and in the header (Electron, Android, Chromium)
- RSM signing and verification for meshchatx.rsm (CI and pre-commit resign)
- System resource monitoring for CPU and memory in the UI
- RSM signing and verification for meshchatx.rsm
- Notification sound settings
- LXMFy 1.6.5 vendor refresh, wasmtime, mutation test tasks
- LXMFy 2.0.1 vendor refresh with RRC hub client support for bots, plus wasmtime
- Network visualiser WebGL + WASM renderer (vis-network fallback) and Settings renderer preference
- Interfaces: internal mode, recursive path requests, announces-from-internal, discovery location command, and Backbone fast-flapping options (RNS 1.3.7 to 1.3.9)
- Dependencies: **RNS** 1.4.0 and **LXMF** 1.1.0, with local propagation node controls for sequential stamp validation, static-peer bypass, max inbound syncs, transfer size reporting, and inbound delivery cancel
- Reticulum interface module management from the UI
- Reticulum 1.4.0 and LXMF 1.1.0, with propagation node options and cancel for incoming large message downloads from the header
- Relay Chat room keys so hosts can require a key to join a room
- Desktop privacy: Windows screen security to omit MeshChatX from screenshots, recording, and Recall
- Android privacy options to block screenshots and clear the clipboard when backgrounded
- Remote management allow-list for identities that may query this instance with rnstatus/rnpath
- Post-install prompts for existing users after upgrades
- Coolify-oriented Docker Compose with resource limits for deployments
### Changed
@ -30,20 +40,19 @@ All notable changes to this project will be documented in this file.
- Conversation list API omits contact image blobs and caps unbounded callers (Map, Network Visualiser)
- Messages page conversation poll is slower and skips while the tab is hidden
- Schema v51 message-flag backfill skips empty databases so fresh init stays fast
- Outbound message status icons and titles reflect delivery method and state more clearly
- Relay Chat: denser hub UI, announce interval, collapsed system lines, reconnect notices
- Relay Chat: clickable Nomad and LXMF links plus basic markdown for code, bold, italic, and strikethrough
- Low-memory cleanup and SQLite pragmas under memory pressure
- CI benches use median-of-medians and quieter regression gates
- Backend benchmarks cover slim conversation list, mark-as-read, call history, and missed-call notification paths
- Benchmark gate fails when a full-suite run drops required benches or loses coverage vs baseline
- Backend tests can run sharded in CI
- Auto-resend keeps attachments and cleans duplicate outbound rows more reliably
- Calls and audio work in Docker and headless setups via hostless LXST backends and the web audio bridge
- Hardened identity path handling, stamp enforcement, plugin integrity, Nomad downloads, and local file path jails
- Plugin strings live in plugin bundles, not main locale files
- Docker frontend build installs Go, builds visualiser WASM, and fails if WASM artifacts are missing
### Fixed
- SLSA provenance jobs compile the generic generator from source so attest no longer fails with a missing slsa-generator-generic-linux-amd64 binary
- Android: lxmfy packaging, flock soft-lock, splash/logo clipping, emulator smoke, Landlock skipped on Android
- Android: lxmfy packaging, flock soft-lock, splash/logo clipping, Landlock skipped on Android
- Android RNode BLE/USB via Chaquopy
- Startup check and disable unsupported interfaces
- Nomad favourites: no more Unknown Node / lost custom sections
@ -51,7 +60,6 @@ All notable changes to this project will be documented in this file.
- Bots and RNSh work in frozen macOS/Windows builds
- Sensitive config no longer mutable over WebSocket. Reticulum config repair on startup
- Paper message URI encoding for non-ASCII title and content
- Nightly releases and broader self-test / CI coverage
- LXMA contact import works with current RNS public-key loading and remembers the peer key before announce
- Android calls: overlay accept opens the phone tab so native audio attaches. Web-audio no longer permanently disabled after a bridge error
- Android Codec2: reliable libcodec2 preload, Gradle fails without Codec2 wheels or jniLibs, and unavailable Codec2 profiles are hidden

0
cogs/__init__.py Normal file
View file

18
cogs/basic.py Normal file
View file

@ -0,0 +1,18 @@
from lxmfy import Command
class BasicCommands:
def __init__(self, bot):
self.bot = bot
@Command(name="hello", description="Says hello")
async def hello(self, ctx):
ctx.reply(f"Hello {ctx.sender}!")
@Command(name="about", description="About this bot")
async def about(self, ctx):
ctx.reply("I'm a bot created with LXMFy!")
def setup(bot):
bot.add_cog(BasicCommands(bot))

0
config/cogs/__init__.py Normal file
View file

View file

@ -26,6 +26,7 @@ Optional editor rules (if present under `.cursor/rules/` or similar):
| [conventions/core.md](conventions/core.md) | Always-on standards |
| [conventions/frontend.md](conventions/frontend.md) | Vue UI |
| [conventions/backend.md](conventions/backend.md) | Python / HTTP / SQLite |
| [conventions/path-jail.md](conventions/path-jail.md) | Local FS APIs: jail, symlinks, tests |
| [conventions/android.md](conventions/android.md) | Android WebView bridge |
| [conventions/tests.md](conventions/tests.md) | Test placement, oracles, verification |
@ -62,6 +63,7 @@ Optional editor rules (if present under `.cursor/rules/` or similar):
| Skill | Use when |
| ------------------------------------------------------------------ | ------------------------------------------- |
| [auth-csrf-ws-security](skills/auth-csrf-ws-security/SKILL.md) | CSRF, auth, WS mutator denylist |
| [path-jail-local-fs](skills/path-jail-local-fs/SKILL.md) | Local file browse/upload/delete path jails |
| [plugin-install-security](skills/plugin-install-security/SKILL.md) | Plugin install, RSG, permissions, integrity |
### Platforms and boot

View file

@ -11,3 +11,4 @@ Applies when editing `meshchatx/**/*.py`.
- Identity restore validates size and empty payloads. Preserve existing identity metadata on re-import.
- No backticks in code comments. Prefer plain words or quoted identifiers.
- RRC / LXMF / LXST changes: open the matching skill under `docs/agents/skills/` and run oracle-style tests when behaviour changes.
- Local filesystem browse/upload/download/delete: follow `docs/agents/conventions/path-jail.md` and `docs/agents/skills/path-jail-local-fs/SKILL.md`.

View file

@ -0,0 +1,37 @@
# Path jail for local filesystem APIs
Applies when adding or changing HTTP or handler code that lists, reads, writes, uploads, downloads, or deletes files under identity storage or a feature root.
## Default threat model
Treat MeshChatX local API access (UI session, shared host, scripted client) as already compromised for the purpose of path checks. The attacker must still fail to:
- Escape the feature root with `../`, absolute paths, null bytes, or Windows separators
- Reach sibling identity storage or host paths outside the active identity
- Touch reserved tops (`identity`, `lxmf`, `database.db`, bots, plugins, backups, keys)
- Follow symlinks that point outside the jail
- Exfil via download or content endpoints that skip the same resolve helper
- Upload filenames that smuggle path segments or reserved sidecars
## Hard rules
1. One dedicated resolve helper per feature root (example: sync-root only for FileSync manager). Do not reuse a looser picker jail for CRUD.
2. Normalize then `realpath`. Success paths must equal the root or start with `root + sep`.
3. Fail closed: generic error, HTTP 400 for bad input, never 500 for jail rejects.
4. Upload: sanitize to basename only. Join under a resolved parent. Cap size.
5. Delete: refuse the root itself. Default to file or empty directory only unless recursive delete is explicit and tested.
6. Skip and refuse mutation of dotfiles and protocol sidecars (for FileSync: `.rns-filesync*`, `.rns-xfer*`).
7. Mutating routes stay CSRF-protected HTTP via `window.api`. No WS mutators for file CRUD.
8. Keep identity-scoped state. Never browse or mutate another identity storage path.
## Tests required
- Adversarial traversal and absolute-path cases with bait files that must survive
- Cross-identity bait directory in the same test
- Symlink-out cases (POSIX) for list, read, write, delete
- Oracle or Hypothesis: accept only when resolved path stays under the root
- Frontend mutators go through `window.api` (apiFetchGuard stays green)
Full workflow: `docs/agents/skills/path-jail-local-fs/SKILL.md`.
Reference implementation: `meshchatx/src/backend/rns_filesync_handler.py` (`_resolve_manager_path` and manager APIs).
Oracle examples: `tests/backend/test_rns_filesync_security.py`, `tests/backend/test_path_jail_oracles.py`.

View file

@ -28,6 +28,7 @@ Prefer:
- Round-trip or shape invariants when the API is pure parsing
Full skill: `docs/agents/skills/test-oracles/SKILL.md`.
Path jail filesystem features: `docs/agents/skills/path-jail-local-fs/SKILL.md` and `docs/agents/conventions/path-jail.md`.
Exploratory bug hunting: `docs/agents/skills/exploratory-testing/SKILL.md`.
## Extended Edge Case Tester (EECT) and Live Validation (LV)

View file

@ -205,6 +205,8 @@ Prefer bind `127.0.0.1`, HTTPS, and auth if other local users share the host.
Sensitive config changes (for example auth enable / password hash) must use CSRF-protected HTTP endpoints, not unrestricted WebSocket mutators.
Local filesystem browse/upload/download/delete APIs must path-jail to a feature or identity root. See `docs/agents/conventions/path-jail.md` and `docs/agents/skills/path-jail-local-fs/SKILL.md`.
Password reset: `--reset-password` or `MESHCHAT_RESET_PASSWORD=true` clears the stored hash so a new password can be set in the UI.
### Plugins

View file

@ -15,13 +15,31 @@ LXMF is store-and-forward mail on Reticulum. Do not require clearnet, DNS, or a
## Key paths
| Area | Path |
| ------------------------ | --------------------------------------------------------- |
| Identity / router wiring | `meshchatx/src/backend/identity_context.py` |
| Message handler | `meshchatx/src/backend/message_handler.py` (and related) |
| HTTP/WS surface | `meshchatx/meshchat.py` |
| Frontend conversations | `meshchatx/src/frontend/components/` conversation viewers |
| Config | config managers / settings UI for LXMF options |
| Area | Path |
| ------------------------ | -------------------------------------------------------------------------------------------------- |
| Identity / router wiring | `meshchatx/src/backend/identity_context.py` |
| Message handler | `meshchatx/src/backend/message_handler.py` (and related) |
| HTTP/WS surface | `meshchatx/meshchat.py` |
| Inbound cancel helpers | `meshchatx/src/backend/meshchat_utils.py` (`list_inbound_deliveries`, `cancel_inbound_deliveries`) |
| Frontend conversations | `meshchatx/src/frontend/components/` conversation viewers |
| Config | config managers / settings UI for LXMF options |
## LXMF 1.1 / RNS 1.4 inbound cancel
Large inbound LXMF deliveries use RNS Resources. LXMF exposes:
- `LXMRouter.inbound_resources()` / `inbound_count()`
- `cancel_inbound(resource_hash)` and `cancel_all_inbound()`
MeshChatX surfaces them as:
- Status: `inbound_delivery_count` and `inbound_deliveries` on `/api/v1/lxmf/propagation-node/status`
- Cancel: `POST /api/v1/lxmf/propagation-node/cancel-inbound` with optional `{ "resource_hash": "..." }`
- Header UI in `App.vue` when active inbound transfers exist
Outbound cancel remains `POST /api/v1/lxmf-messages/{hash}/cancel` via `cancel_outbound`.
Keep minimum versions: `rns>=1.4.0`, `lxmf>=1.1.0`.
## Gates

View file

@ -0,0 +1,118 @@
# Skill: path-jail-local-fs
Add or change local filesystem features (browse, upload, download, mkdir, delete) with a hard path jail, CSRF-safe mutators, and oracle-style tests. Do not ship file CRUD that can escape identity or feature roots.
## When to use
- New in-app file managers, uploads, downloads, or directory browsers
- Extending FileSync, RNCP, page-nodes files, docs packs, repo wheels, or similar storage APIs
- Any handler that takes a client path or multipart filename and touches disk
- Security review of existing list/read/write/delete file endpoints
Also read:
- `docs/agents/conventions/path-jail.md`
- `docs/agents/skills/auth-csrf-ws-security/SKILL.md`
- `docs/agents/skills/test-oracles/SKILL.md`
- `docs/agents/skills/page-toast-tests/SKILL.md` when adding UI
## Threat model (assume without asking)
Caller already has MeshChatX HTTP API access. They must not list, read, write, or delete outside the configured feature root. That includes host home dirs, `/etc`, other identities, and reserved identity-storage tops.
You do not need the user to restate path jail, symlink policy, CSRF, or bait-file tests. Apply them by default.
## Workflow
### 1) Choose the jail root
Pick the tightest correct root for the feature:
| Feature shape | Jail root |
| -------------------------------------- | --------------------------------------------- |
| FileSync in-app manager | Configured `sync_directory` only |
| Folder picker for choosing a sync root | Identity storage with reserved tops blocked |
| RNCP received / shared | That feature directory under identity storage |
| Page-node files | That node file directory |
Never use a looser picker jail for tree/upload/delete/content of a tighter feature.
### 2) One resolve helper
Add something like `_resolve_<feature>_path(...)` that:
1. Takes relative client paths only (reject absolute / drive / UNC / null bytes)
2. Uses vendor or shared normalize helpers when available (`normalize_relpath`, `resolve_under_root`)
3. Rejects forbidden names (dotfiles, protocol sidecars)
4. `realpath` membership: equal root or `root + sep` prefix
5. Symlinks: after realpath still inside root. Prefer rejecting symlink entries for write/delete/content
6. Returns fail-closed errors with generic messages (no out-of-jail path reflection)
Reference: `RnsFilesyncHandler._resolve_manager_path` in `meshchatx/src/backend/rns_filesync_handler.py`.
### 3) Wire APIs
Typical surface:
| Method | Role |
| ------------- | --------------------------------------------------------------------------------- |
| GET tree/list | Browse under jail (works even if a mesh service is stopped when state is on disk) |
| POST mkdir | Relative path under jail |
| POST upload | Multipart. Optional subdir. Basename only. Size cap |
| DELETE entry | File or empty dir by default |
| GET content | Stream only after jail pass |
HTTP: 400 on jail failure / bad input. Register routes in `tests/backend/fixtures/http_api_routes.json`. Add JSON GET contracts or exclude binary download routes in `http_api_response_registry.py`. Add mutating samples to EECT auth surface when relevant.
### 4) Frontend
- Relative paths only in the UI. Never send host absolute paths for CRUD.
- Mutators via `window.api` / FormData so CSRF attaches.
- Confirm before delete. Toasts via `ToastUtils`. User strings via i18n.
- Keep OS "open folder" as optional convenience, not the only management path.
### 5) Mandatory tests
Extend or add:
- Happy-path CRUD under the root (including stopped-service disk CRUD when applicable)
- `_TRAVERSAL_PAYLOADS`-style rejects for tree, content, delete, mkdir, upload subdir
- Absolute path to bait file outside storage (must survive)
- Reserved tops under identity storage
- Second identity storage bait in the same test
- Symlink inside root pointing outside (list/read/write/delete fail closed)
- Upload basename sanitization (path segments stripped or rejected, never escape)
- Hypothesis or explicit oracle: accept only when resolved path stays under root
- Frontend: mock `window.api`, assert upload/delete calls and toasts
Soft fuzz that only checks "did not crash" is not enough. See `test-oracles`.
## Stupid crap to refuse
- Reusing identity-storage browse APIs as the file-manager base for a tighter root
- Trusting `Content-Disposition` or multipart filenames as full save paths
- Recursive delete of arbitrary trees without an explicit, tested flag
- New WebSocket mutators for file upload/delete
- Logging full absolute paths of failed escapes into user-visible errors
- Cross-identity caches or shared temp dirs for uploads
## Verification
```bash
task test:filesync:security
uv run pytest tests/backend/test_path_jail_oracles.py -q --tb=short
pnpm exec vitest run tests/frontend/apiFetchGuard.test.js
```
Adjust pytest and vitest paths to the feature you touched. Prefer `task` targets when they exist.
## Finish gate
Do not ship until all are true:
1. Every list/read/write/delete/upload goes through the feature resolve helper
2. Escape payloads never read or delete bait files outside the root
3. Symlink-out and cross-identity cases fail closed with tests
4. Oracle or Hypothesis coverage exists for the resolve helper
5. Mutators are CSRF HTTP via `window.api`
6. Route fixture / JSON contract registry updated when routes change

View file

@ -105,6 +105,7 @@ WebSocket lxmf_message event on recipient UI
- Enable **auto-announce** so your `lxmf.delivery` aspect stays visible on the mesh.
- Check **Interfaces** if messages stall. No path to the peer means LXMF cannot deliver.
- Review stamp settings before joining busy public meshes.
- While a large message is downloading, the header shows **Cancel incoming**. That stops active LXMF delivery resource transfers (`cancel_all_inbound` / per-resource cancel). Outbound send cancel stays on each message menu.
## See also

View file

@ -33,9 +33,7 @@
class="overflow-hidden rounded-2xl border border-slate-200/80 bg-white/90 shadow-lg shadow-slate-200/50 backdrop-blur-sm dark:border-zinc-700/80 dark:bg-zinc-900/90 dark:shadow-black/40"
>
<div class="px-6 pt-8 pb-2 text-center">
<div
class="mx-auto mb-4 flex h-20 w-20 items-center justify-center overflow-visible rounded-2xl bg-white p-2 shadow-inner ring-1 ring-slate-200/80 dark:bg-zinc-950 dark:ring-zinc-700"
>
<div class="mx-auto mb-4 flex h-20 w-20 items-center justify-center overflow-visible">
<img class="h-14 w-14 object-contain" src="./assets/images/logo.png" alt="" />
</div>
<h1 class="text-xl font-semibold tracking-tight text-slate-900 dark:text-white">MeshChatX</h1>

Binary file not shown.

View file

@ -152,11 +152,13 @@ from meshchatx.src.backend.map_overlay_sources import OverlaySourceParseError
from meshchatx.src.backend.markdown_renderer import MarkdownRenderer
from meshchatx.src.backend.memory_pressure import MemoryPressureManager, cache_stats
from meshchatx.src.backend.meshchat_utils import (
cancel_inbound_deliveries,
convert_db_favourite_to_dict,
convert_propagation_node_state_to_string,
has_attachments,
hex_identifier_to_bytes,
interval_action_due,
list_inbound_deliveries,
message_fields_have_attachments,
normalize_hex_identifier,
normalize_identity_storage_hash,
@ -211,6 +213,10 @@ from meshchatx.src.backend.page_node_manager import PageNodeManager
from meshchatx.src.backend.persistent_log_handler import PersistentLogHandler
from meshchatx.src.backend.plugin_guard import PluginSecurityError
from meshchatx.src.backend.plugin_manager import PluginManager
from meshchatx.src.backend.active_sessions import (
ActiveSessionTracker,
should_warn_multi_session,
)
from meshchatx.src.backend.privacy_mode import (
OutboundHttpBlockedError,
ensure_outbound_http_allowed,
@ -496,6 +502,7 @@ class ReticulumMeshChat:
self.gitea_base_url_override = gitea_base_url
self._rns_loglevel_cli = rns_loglevel
self.websocket_clients: list[web.WebSocketResponse] = []
self.active_sessions = ActiveSessionTracker()
self._websocket_broadcast_lock = asyncio.Lock()
self.listen_host: str | None = None
self.listen_port: int | None = None
@ -3904,6 +3911,7 @@ class ReticulumMeshChat:
),
),
"inbound_delivery_count": 0,
"inbound_deliveries": [],
"max_inbound_syncs": int(
getattr(router, "propagation_max_inbound_syncs", 0) or 0,
),
@ -3914,9 +3922,9 @@ class ReticulumMeshChat:
getattr(router, "propagation_static_peer_sequential", False),
),
}
if hasattr(router, "inbound_count"):
with contextlib.suppress(Exception):
result["inbound_delivery_count"] = int(router.inbound_count() or 0)
inbound_deliveries = list_inbound_deliveries(router)
result["inbound_deliveries"] = inbound_deliveries
result["inbound_delivery_count"] = len(inbound_deliveries)
return result
def _get_reticulum_section(self):
@ -7853,9 +7861,15 @@ class ReticulumMeshChat:
# add client to connected clients list
self.websocket_clients.append(websocket_response)
session = self.active_sessions.add(
ip=request.remote,
user_agent=request.headers.get("User-Agent"),
)
websocket_response._meshchatx_session_id = session["id"]
# send config to all clients
await self.send_config_to_websocket_clients()
await self.send_active_sessions_to_websocket_clients()
# handle websocket messages until disconnected
async for msg in websocket_response:
@ -7877,7 +7891,9 @@ class ReticulumMeshChat:
self.websocket_clients.remove(websocket_response)
except ValueError:
pass
self._detach_active_session(websocket_response)
self._cancel_rns_link_tasks_for_client(websocket_response)
await self.send_active_sessions_to_websocket_clients()
return websocket_response
@ -7937,6 +7953,10 @@ class ReticulumMeshChat:
self.web_audio_bridge.detach_client(websocket_response)
return websocket_response
@routes.get("/api/v1/app/sessions")
async def app_sessions(_request):
return web.json_response(self.get_active_sessions_payload())
# get app info
@routes.get("/api/v1/app/info")
async def app_info(request):
@ -12663,9 +12683,10 @@ class ReticulumMeshChat:
if isinstance(transfer_size, (int, float)) and transfer_size > 0:
transfer_size_bytes = int(transfer_size)
inbound_delivery_count = 0
if router is not None and hasattr(router, "inbound_count"):
with contextlib.suppress(Exception):
inbound_delivery_count = int(router.inbound_count() or 0)
inbound_deliveries = []
if router is not None:
inbound_deliveries = list_inbound_deliveries(router)
inbound_delivery_count = len(inbound_deliveries)
return web.json_response(
{
"propagation_node_status": {
@ -12675,6 +12696,7 @@ class ReticulumMeshChat:
"progress": progress_pct,
"transfer_size_bytes": transfer_size_bytes,
"inbound_delivery_count": inbound_delivery_count,
"inbound_deliveries": inbound_deliveries,
"messages_received": last_result,
"messages_stored": sync_metrics["messages_stored"],
"delivery_confirmations": sync_metrics[
@ -12728,26 +12750,41 @@ class ReticulumMeshChat:
@routes.post("/api/v1/lxmf/propagation-node/cancel-inbound")
async def propagation_node_cancel_inbound(request):
router = self.message_router
if router is None or not hasattr(router, "cancel_all_inbound"):
data = {}
with contextlib.suppress(Exception):
data = await request.json()
if not isinstance(data, dict):
data = {}
resource_hash = data.get("resource_hash")
result = cancel_inbound_deliveries(router, resource_hash=resource_hash)
if not result.get("ok"):
status = 503 if "unavailable" in str(result.get("error") or "") else 400
return web.json_response(
{
"message": "Inbound delivery cancellation is unavailable.",
"message": result.get(
"error",
"Failed to cancel inbound deliveries",
),
"cancelled": result.get("cancelled", 0),
},
status=503,
status=status,
)
try:
cancelled = int(router.cancel_all_inbound() or 0)
except Exception as exc:
return web.json_response(
{
"message": f"Failed to cancel inbound deliveries: {exc}",
},
status=500,
cancelled = int(result.get("cancelled") or 0)
if resource_hash:
message = (
"Cancelled inbound delivery"
if cancelled
else "Inbound delivery was not active"
)
else:
message = f"Cancelled {cancelled} inbound deliveries"
return web.json_response(
{
"message": f"Cancelled {cancelled} inbound deliveries",
"message": message,
"cancelled": cancelled,
"resource_hash": result.get("resource_hash"),
"inbound_delivery_count": len(list_inbound_deliveries(router)),
"inbound_deliveries": list_inbound_deliveries(router),
},
)
@ -13884,6 +13921,149 @@ class ReticulumMeshChat:
return not_ready
return web.json_response({"files": self.rns_filesync_handler.list_files()})
@routes.get("/api/v1/filesync/tree")
async def filesync_tree(request):
not_ready = _filesync_require_handler()
if not_ready is not None:
return not_ready
path = request.rel_url.query.get("path")
try:
result = await asyncio.to_thread(
self.rns_filesync_handler.list_tree,
path,
)
except Exception as e:
return web.json_response({"message": str(e)}, status=500)
if not result.get("ok"):
return web.json_response(
{"message": result.get("error", "list tree failed")},
status=400,
)
return web.json_response(result)
@routes.post("/api/v1/filesync/mkdir")
async def filesync_mkdir(request):
not_ready = _filesync_require_handler()
if not_ready is not None:
return not_ready
data = await request.json()
if not isinstance(data, dict):
return web.json_response({"message": "Invalid JSON body"}, status=400)
try:
result = await asyncio.to_thread(
self.rns_filesync_handler.manager_mkdir,
data.get("path", ""),
)
except Exception as e:
return web.json_response({"message": str(e)}, status=500)
if not result.get("ok"):
return web.json_response(
{"message": result.get("error", "mkdir failed")},
status=400,
)
return web.json_response(result)
@routes.post("/api/v1/filesync/upload")
async def filesync_upload(request):
not_ready = _filesync_require_handler()
if not_ready is not None:
return not_ready
subdir = None
filename = None
file_data = None
try:
reader = await request.multipart()
while True:
field = await reader.next()
if field is None:
break
name = field.name or ""
if name == "path":
subdir = (await field.text()).strip() or None
elif name == "file":
filename = field.filename or "upload"
file_data = await field.read()
else:
with contextlib.suppress(Exception):
await field.read()
except Exception as e:
return web.json_response(
{"message": f"Invalid upload request: {e}"},
status=400,
)
if file_data is None:
return web.json_response({"message": "No file uploaded"}, status=400)
try:
result = await asyncio.to_thread(
self.rns_filesync_handler.manager_upload,
filename=filename,
data=file_data,
subdir=subdir,
)
except Exception as e:
return web.json_response({"message": str(e)}, status=500)
if not result.get("ok"):
return web.json_response(
{"message": result.get("error", "upload failed")},
status=400,
)
return web.json_response(result)
@routes.delete("/api/v1/filesync/entry")
async def filesync_entry_delete(request):
not_ready = _filesync_require_handler()
if not_ready is not None:
return not_ready
data = {}
with contextlib.suppress(Exception):
data = await request.json()
if not isinstance(data, dict):
data = {}
path = data.get("path", "")
try:
result = await asyncio.to_thread(
self.rns_filesync_handler.manager_delete,
path,
)
except Exception as e:
return web.json_response({"message": str(e)}, status=500)
if not result.get("ok"):
return web.json_response(
{"message": result.get("error", "delete failed")},
status=400,
)
return web.json_response(result)
@routes.get("/api/v1/filesync/content")
async def filesync_content(request):
not_ready = _filesync_require_handler()
if not_ready is not None:
return not_ready
path = request.rel_url.query.get("path", "")
try:
result = await asyncio.to_thread(
self.rns_filesync_handler.manager_content,
path,
)
except Exception as e:
return web.json_response({"message": str(e)}, status=500)
if not result.get("ok"):
return web.json_response(
{"message": result.get("error", "content failed")},
status=400,
)
abspath = result.get("abspath")
filename = result.get("filename") or "download"
if not abspath or not os.path.isfile(abspath):
return web.json_response({"message": "file not found"}, status=404)
safe_name = os.path.basename(str(filename)).replace('"', "")
return web.FileResponse(
abspath,
headers={
"Content-Disposition": f'attachment; filename="{safe_name}"',
},
)
@routes.get("/api/v1/filesync/directories")
async def filesync_directories(request):
not_ready = _filesync_require_handler()
@ -19006,6 +19186,11 @@ class ReticulumMeshChat:
self._parse_bool(data["privacy_mode_enabled"]),
)
if "multi_session_warning_enabled" in data:
self.config.multi_session_warning_enabled.set(
self._parse_bool(data["multi_session_warning_enabled"]),
)
# update map settings
if "map_offline_enabled" in data:
self.config.map_offline_enabled.set(
@ -19557,6 +19742,8 @@ class ReticulumMeshChat:
# send config to websocket clients
await self.send_config_to_websocket_clients()
if "multi_session_warning_enabled" in data:
await self.send_active_sessions_to_websocket_clients()
# converts nomadnetwork page variables from a string to a map
# converts: "field1=123|field2=456"
@ -21040,6 +21227,7 @@ class ReticulumMeshChat:
async def websocket_broadcast(self, data):
# Serialize: concurrent callers must not interleave. The second snapshot must run
# only after the first broadcast has finished mutating the live client list.
sessions_changed = False
async with self._websocket_broadcast_lock:
dead = []
# Iterate a copy: awaits allow other tasks to mutate self.websocket_clients.
@ -21054,10 +21242,46 @@ class ReticulumMeshChat:
self.websocket_clients.remove(client)
except ValueError:
pass
if self._detach_active_session(client):
sessions_changed = True
try:
await client.close(code=WSCloseCode.GOING_AWAY)
except Exception:
pass
if sessions_changed:
await self.send_active_sessions_to_websocket_clients()
def _detach_active_session(self, websocket_response) -> bool:
session_id = getattr(websocket_response, "_meshchatx_session_id", None)
if not session_id:
return False
try:
delattr(websocket_response, "_meshchatx_session_id")
except Exception:
pass
return self.active_sessions.remove(session_id)
def get_active_sessions_payload(self) -> dict:
snap = self.active_sessions.snapshot()
warning_enabled = True
try:
cfg = getattr(self, "config", None)
if cfg is not None and hasattr(cfg, "multi_session_warning_enabled"):
warning_enabled = bool(cfg.multi_session_warning_enabled.get())
except Exception:
warning_enabled = True
count = int(snap.get("count") or 0)
return {
"count": count,
"sessions": list(snap.get("sessions") or []),
"warning": should_warn_multi_session(count, warning_enabled),
"warning_enabled": warning_enabled,
}
async def send_active_sessions_to_websocket_clients(self):
payload = self.get_active_sessions_payload()
payload["type"] = "app.sessions.updated"
await self.websocket_broadcast(json.dumps(payload))
# broadcasts config to all websocket clients
async def send_config_to_websocket_clients(self, context=None):
@ -21166,6 +21390,7 @@ class ReticulumMeshChat:
"crawler_max_concurrent": ctx.config.crawler_max_concurrent.get(),
"auth_enabled": self.auth_enabled,
"privacy_mode_enabled": ctx.config.privacy_mode_enabled.get(),
"multi_session_warning_enabled": ctx.config.multi_session_warning_enabled.get(),
"voicemail_enabled": ctx.config.voicemail_enabled.get(),
"voicemail_greeting": ctx.config.voicemail_greeting.get(),
"voicemail_auto_answer_delay_seconds": ctx.config.voicemail_auto_answer_delay_seconds.get(),

View file

@ -0,0 +1,85 @@
# SPDX-License-Identifier: 0BSD
"""Track active UI WebSocket sessions (IP and user-agent) for local multi-client warnings."""
from __future__ import annotations
import threading
import time
import uuid
from typing import Any
_MAX_UA_LEN = 512
_MAX_IP_LEN = 128
def _clean_ip(value: str | None) -> str:
cleaned = str(value or "").strip()
if not cleaned:
return "unknown"
return cleaned[:_MAX_IP_LEN]
def _clean_user_agent(value: str | None) -> str:
cleaned = str(value or "").strip()
if not cleaned:
return "unknown"
# Strip control characters that break logs or JSON displays.
cleaned = "".join(ch for ch in cleaned if ch.isprintable())
if not cleaned:
return "unknown"
return cleaned[:_MAX_UA_LEN]
def should_warn_multi_session(count: int, warning_enabled: bool) -> bool:
"""Oracle: warn when two or more sessions are active and the setting is on."""
try:
active = int(count)
except (TypeError, ValueError):
return False
return bool(warning_enabled) and active >= 2
class ActiveSessionTracker:
"""In-memory registry of connected MeshChatX UI WebSocket clients."""
def __init__(self) -> None:
self._lock = threading.RLock()
self._sessions: dict[str, dict[str, Any]] = {}
def add(self, *, ip: str | None, user_agent: str | None) -> dict[str, Any]:
session_id = uuid.uuid4().hex
entry = {
"id": session_id,
"ip": _clean_ip(ip),
"user_agent": _clean_user_agent(user_agent),
"connected_at": time.time(),
}
with self._lock:
self._sessions[session_id] = entry
return dict(entry)
def remove(self, session_id: str | None) -> bool:
cleaned = str(session_id or "").strip()
if not cleaned:
return False
with self._lock:
return self._sessions.pop(cleaned, None) is not None
def count(self) -> int:
with self._lock:
return len(self._sessions)
def list_sessions(self) -> list[dict[str, Any]]:
with self._lock:
rows = [dict(row) for row in self._sessions.values()]
rows.sort(key=lambda row: float(row.get("connected_at") or 0.0))
return rows
def snapshot(self) -> dict[str, Any]:
sessions = self.list_sessions()
return {
"count": len(sessions),
"sessions": sessions,
}

View file

@ -174,6 +174,11 @@ class ConfigManager:
self.auth_password_hash = self.StringConfig(self, "auth_password_hash", None)
self.auth_session_secret = self.StringConfig(self, "auth_session_secret", None)
self.privacy_mode_enabled = self.BoolConfig(self, "privacy_mode_enabled", False)
self.multi_session_warning_enabled = self.BoolConfig(
self,
"multi_session_warning_enabled",
True,
)
self.gitea_base_url = self.StringConfig(
self,
"gitea_base_url",

File diff suppressed because it is too large Load diff

View file

@ -8,7 +8,7 @@
{
"name": "aiohttp",
"version": "3.14.1",
"author": "\u2014",
"author": "",
"license": "Apache-2.0 AND MIT"
},
{
@ -32,7 +32,7 @@
{
"name": "audioop-lts",
"version": "0.2.2",
"author": "\u2014",
"author": "",
"license": "PSF-2.0"
},
{
@ -50,7 +50,7 @@
{
"name": "cbor2",
"version": "6.1.1",
"author": "Alex Gr\u00f6nholm <alex.gronholm@nextday.fi>",
"author": "Alex Grönholm <alex.gronholm@nextday.fi>",
"license": "MIT"
},
{
@ -91,16 +91,10 @@
},
{
"name": "lxmfy",
"version": "1.6.5",
"version": "2.0.1",
"author": "Quad4 <team@quad4.io>",
"license": "BSD-0-Clause"
},
{
"name": "rns-filesync",
"version": "1.0.0",
"author": "Sudo-Ivan / Quad4.io",
"license": "BSD-2-Clause"
},
{
"name": "lxst",
"version": "0.5.0",
@ -173,6 +167,12 @@
"author": "Mark Qvist",
"license": "Reticulum License"
},
{
"name": "rns-filesync",
"version": "1.0.0",
"author": "—",
"license": "BSD-2-Clause"
},
{
"name": "wasmtime",
"version": "46.0.1",

File diff suppressed because it is too large Load diff

View file

@ -50,6 +50,95 @@ def create_lxmf_router(
return LXMF.LXMRouter(**kwargs)
def list_inbound_deliveries(router) -> list[dict]:
"""Serialize active inbound LXMF delivery resources (LXMF 1.1+ / RNS 1.4+)."""
if router is None or not hasattr(router, "inbound_resources"):
return []
items: list[dict] = []
try:
resources = router.inbound_resources() or []
except Exception:
return []
for resource in resources:
try:
resource_hash = getattr(resource, "hash", None)
if resource_hash is None and hasattr(resource, "get_hash"):
resource_hash = resource.get_hash()
if resource_hash is None:
continue
if isinstance(resource_hash, (bytes, bytearray)):
hash_hex = bytes(resource_hash).hex()
else:
hash_hex = str(resource_hash)
size = None
transfer_size = None
progress = None
with contextlib.suppress(Exception):
size = int(resource.get_data_size() or 0)
with contextlib.suppress(Exception):
transfer_size = int(resource.get_transfer_size() or 0)
with contextlib.suppress(Exception):
progress_raw = float(resource.get_progress() or 0.0)
progress = max(0.0, min(100.0, progress_raw * 100.0))
items.append(
{
"hash": hash_hex,
"size_bytes": size,
"transfer_size_bytes": transfer_size,
"progress": progress,
}
)
except Exception:
continue
return items
def cancel_inbound_deliveries(router, resource_hash: str | None = None) -> dict:
"""Cancel one or all active inbound LXMF delivery resources.
Returns a result dict with ok, cancelled count, and optional error.
"""
if router is None:
return {"ok": False, "error": "router unavailable", "cancelled": 0}
cleaned = str(resource_hash or "").strip().lower().replace(":", "")
if cleaned:
if not hasattr(router, "cancel_inbound"):
return {
"ok": False,
"error": "inbound delivery cancellation is unavailable",
"cancelled": 0,
}
try:
hash_bytes = bytes.fromhex(cleaned)
except ValueError:
return {"ok": False, "error": "invalid resource_hash", "cancelled": 0}
if not hash_bytes:
return {"ok": False, "error": "invalid resource_hash", "cancelled": 0}
try:
ok = bool(router.cancel_inbound(hash_bytes))
except Exception as exc:
return {"ok": False, "error": str(exc), "cancelled": 0}
return {
"ok": ok,
"cancelled": 1 if ok else 0,
"resource_hash": cleaned,
"error": None if ok else "resource not active",
}
if not hasattr(router, "cancel_all_inbound"):
return {
"ok": False,
"error": "inbound delivery cancellation is unavailable",
"cancelled": 0,
}
try:
cancelled = int(router.cancel_all_inbound() or 0)
except Exception as exc:
return {"ok": False, "error": str(exc), "cancelled": 0}
return {"ok": True, "cancelled": cancelled}
def parse_bool_query_param(value: str | None) -> bool:
if value is None:
return False

View file

@ -7,17 +7,21 @@ from __future__ import annotations
import contextlib
import json
import os
import tempfile
import threading
from collections.abc import Callable
from typing import Any
from rns_filesync.constants import ANNOUNCE_INTERVAL_DEFAULT
from rns_filesync.paths import PathJailError, normalize_relpath
from rns_filesync.paths import PathJailError, normalize_relpath, relative_to_root
from rns_filesync.permissions import PermissionStore
from rns_filesync.service import FileSyncService
_ALL_ALIASES = frozenset({"all", "a", "everyone", "*"})
# Cap for in-app sync-tree uploads (local control plane only).
MANAGER_UPLOAD_MAX_BYTES = 64 * 1024 * 1024
def _normalize_peer_hash(value: str | None) -> str | None:
cleaned = str(value or "").strip().lower().replace(":", "")
@ -34,6 +38,33 @@ def _normalize_peer_hash(value: str | None) -> str | None:
return cleaned
def _is_forbidden_entry_name(name: str) -> bool:
"""Reject hidden and protocol sidecar names in the file manager."""
cleaned = str(name or "")
if not cleaned or cleaned in (".", ".."):
return True
if cleaned.startswith("."):
return True
if cleaned == ".rns-filesync.db" or cleaned.startswith(".rns-filesync"):
return True
if cleaned.startswith(".rns-xfer"):
return True
return False
def _sanitize_upload_basename(filename: str | None) -> str | None:
"""Keep only a safe basename for uploads. Fail closed on escape tricks."""
raw = str(filename or "").strip()
if not raw or "\x00" in raw:
return None
base = os.path.basename(raw.replace("\\", "/"))
if not base or base in (".", "..") or _is_forbidden_entry_name(base):
return None
if "/" in base or "\\" in base:
return None
return base
_RESERVED_SYNC_TOP = frozenset(
{
"identity",
@ -111,6 +142,359 @@ class RnsFilesyncHandler:
return None
return resolved
def _sync_root(self) -> str:
"""Real path of the configured sync directory (file manager jail base)."""
os.makedirs(self._sync_directory, exist_ok=True)
return os.path.realpath(self._sync_directory)
def _is_under_sync_root(self, candidate: str) -> bool:
root = self._sync_root()
real = os.path.realpath(candidate)
return real == root or real.startswith(root + os.sep)
def _resolve_manager_path(
self,
path: str | None,
*,
allow_root: bool = False,
must_exist: bool = False,
) -> tuple[str | None, str | None]:
"""Resolve a path for the sync-root file manager.
Returns (abspath, error). Fail closed with a generic error string.
Empty path with allow_root returns the sync root itself.
"""
root = self._sync_root()
cleaned = str(path or "").strip()
if not cleaned:
if allow_root:
return root, None
return None, "path is required"
# Absolute client paths are never accepted for the manager.
if "\x00" in cleaned:
return None, "path not allowed"
if os.path.isabs(cleaned) or cleaned.startswith(("/", "\\")):
return None, "path not allowed"
if len(cleaned) >= 2 and cleaned[1] == ":":
return None, "path not allowed"
try:
safe_rel = normalize_relpath(cleaned)
except PathJailError:
return None, "path not allowed"
parts = safe_rel.replace("\\", "/").split("/")
if any(_is_forbidden_entry_name(part) for part in parts):
return None, "path not allowed"
joined = os.path.join(root, safe_rel)
# Reject symlink parents that escape before realpath of missing leaves.
parent = os.path.dirname(joined)
if parent != root and not self._is_under_sync_root(parent):
return None, "path not allowed"
if os.path.lexists(joined) and os.path.islink(joined):
real = os.path.realpath(joined)
if real != root and not real.startswith(root + os.sep):
return None, "path not allowed"
if must_exist and not os.path.exists(real):
return None, "path not found"
return real, None
if must_exist and not os.path.lexists(joined):
return None, "path not found"
try:
real = os.path.realpath(joined)
except OSError:
return None, "path not allowed"
if real != root and not real.startswith(root + os.sep):
return None, "path not allowed"
if not allow_root and real == root:
return None, "path not allowed"
return real, None
def _relpath_under_sync(self, abspath: str) -> str | None:
try:
return relative_to_root(self._sync_root(), abspath)
except PathJailError:
return None
def _nudge_inventory(self, relpath: str | None = None) -> None:
if self.service is None:
return
inventory = getattr(self.service, "inventory", None)
if inventory is None:
return
with contextlib.suppress(Exception):
if relpath:
inventory.update_from_path(relpath)
else:
inventory.scan()
def list_tree(self, path: str | None = None) -> dict[str, Any]:
"""List files and directories under a relative path inside the sync root."""
with self._lock:
target, err = self._resolve_manager_path(path, allow_root=True)
if err or target is None:
return {"ok": False, "error": err or "path not allowed"}
if not os.path.isdir(target):
return {"ok": False, "error": "not a directory"}
root = self._sync_root()
entries: list[dict[str, Any]] = []
try:
names = sorted(os.listdir(target), key=str.lower)
except OSError as exc:
return {"ok": False, "error": str(exc)}
for name in names:
if _is_forbidden_entry_name(name):
continue
full = os.path.join(target, name)
if os.path.islink(full):
real = os.path.realpath(full)
if real != root and not real.startswith(root + os.sep):
continue
else:
real = os.path.realpath(full)
if real != root and not real.startswith(root + os.sep):
continue
is_dir = os.path.isdir(real) and not os.path.islink(full)
# Treat in-jail symlinks to dirs as dirs for navigation only when target stays inside.
if os.path.islink(full) and os.path.isdir(real):
is_dir = True
rel = self._relpath_under_sync(real)
if rel is None and real == root:
continue
if rel is None:
continue
item: dict[str, Any] = {
"name": name,
"path": rel,
"type": "dir" if is_dir else "file",
}
if not is_dir:
try:
item["size"] = os.path.getsize(real)
except OSError:
item["size"] = 0
entries.append(item)
current_rel = ""
if target != root:
current_rel = self._relpath_under_sync(target) or ""
parent_rel = None
if target != root:
parent_abs = os.path.dirname(target)
if parent_abs == root:
parent_rel = ""
else:
parent_rel = self._relpath_under_sync(parent_abs)
return {
"ok": True,
"root": root,
"current": current_rel,
"parent": parent_rel,
"entries": entries,
}
def manager_mkdir(self, path: str) -> dict[str, Any]:
"""Create a directory under the sync root (relative path)."""
with self._lock:
cleaned = str(path or "").strip()
if not cleaned:
return {"ok": False, "error": "path is required"}
# Resolve parent and create leaf so we do not require the leaf to exist.
try:
safe_rel = normalize_relpath(cleaned)
except PathJailError:
return {"ok": False, "error": "path not allowed"}
parts = safe_rel.replace("\\", "/").split("/")
if any(_is_forbidden_entry_name(part) for part in parts):
return {"ok": False, "error": "path not allowed"}
leaf = parts[-1]
parent_rel = "/".join(parts[:-1]) if len(parts) > 1 else ""
parent_abs, err = self._resolve_manager_path(
parent_rel if parent_rel else None,
allow_root=True,
must_exist=True,
)
if err or parent_abs is None:
return {"ok": False, "error": err or "path not allowed"}
if not os.path.isdir(parent_abs):
return {"ok": False, "error": "parent is not a directory"}
if os.path.islink(parent_abs):
return {"ok": False, "error": "path not allowed"}
new_path = os.path.join(parent_abs, leaf)
if not self._is_under_sync_root(os.path.dirname(new_path)):
return {"ok": False, "error": "path not allowed"}
if os.path.lexists(new_path):
return {"ok": False, "error": "already exists"}
try:
os.mkdir(new_path)
except OSError as exc:
return {"ok": False, "error": str(exc)}
real = os.path.realpath(new_path)
if not self._is_under_sync_root(real):
with contextlib.suppress(OSError):
os.rmdir(new_path)
return {"ok": False, "error": "path not allowed"}
rel = self._relpath_under_sync(real) or safe_rel
return {"ok": True, "path": rel}
def manager_upload(
self,
*,
filename: str | None,
data: bytes,
subdir: str | None = None,
) -> dict[str, Any]:
"""Write an uploaded file under the sync root."""
with self._lock:
if not isinstance(data, (bytes, bytearray)):
return {"ok": False, "error": "invalid upload data"}
if len(data) > MANAGER_UPLOAD_MAX_BYTES:
return {"ok": False, "error": "upload too large"}
base = _sanitize_upload_basename(filename)
if base is None:
return {"ok": False, "error": "invalid filename"}
parent_abs, err = self._resolve_manager_path(
subdir,
allow_root=True,
must_exist=True,
)
if err or parent_abs is None:
return {"ok": False, "error": err or "path not allowed"}
if not os.path.isdir(parent_abs) or os.path.islink(parent_abs):
return {"ok": False, "error": "path not allowed"}
dest = os.path.join(parent_abs, base)
if os.path.islink(dest):
return {"ok": False, "error": "path not allowed"}
if os.path.lexists(dest):
real_existing = os.path.realpath(dest)
if not self._is_under_sync_root(real_existing):
return {"ok": False, "error": "path not allowed"}
tmp_path = None
try:
fd, tmp_path = tempfile.mkstemp(
prefix=".upload-",
suffix=".tmp",
dir=parent_abs,
)
try:
with os.fdopen(fd, "wb") as handle:
handle.write(data)
except Exception:
with contextlib.suppress(OSError):
os.close(fd)
raise
if not self._is_under_sync_root(tmp_path):
with contextlib.suppress(OSError):
os.unlink(tmp_path)
return {"ok": False, "error": "path not allowed"}
os.replace(tmp_path, dest)
tmp_path = None
except OSError as exc:
if tmp_path:
with contextlib.suppress(OSError):
os.unlink(tmp_path)
return {"ok": False, "error": str(exc)}
real = os.path.realpath(dest)
if not self._is_under_sync_root(real) or not os.path.isfile(real):
with contextlib.suppress(OSError):
os.unlink(dest)
return {"ok": False, "error": "path not allowed"}
rel = self._relpath_under_sync(real)
if rel is None:
with contextlib.suppress(OSError):
os.unlink(dest)
return {"ok": False, "error": "path not allowed"}
self._nudge_inventory(rel)
return {"ok": True, "path": rel, "size": len(data)}
def manager_delete(self, path: str) -> dict[str, Any]:
"""Delete a file or empty directory under the sync root."""
with self._lock:
root = self._sync_root()
try:
safe_rel = normalize_relpath(str(path or "").strip())
except PathJailError:
return {"ok": False, "error": "path not allowed"}
lex_path = os.path.join(root, safe_rel)
if os.path.islink(lex_path):
return {"ok": False, "error": "path not allowed"}
target, err = self._resolve_manager_path(
path,
allow_root=False,
must_exist=True,
)
if err or target is None:
return {"ok": False, "error": err or "path not allowed"}
if target == root:
return {"ok": False, "error": "path not allowed"}
rel = self._relpath_under_sync(target)
if rel is None:
return {"ok": False, "error": "path not allowed"}
try:
if os.path.isdir(target):
try:
os.rmdir(target)
except OSError:
return {"ok": False, "error": "directory is not empty"}
elif os.path.isfile(target):
os.unlink(target)
else:
return {"ok": False, "error": "path not found"}
except OSError as exc:
return {"ok": False, "error": str(exc)}
self._nudge_inventory(rel)
return {"ok": True, "path": rel}
def manager_content(self, path: str) -> dict[str, Any]:
"""Resolve a file under the sync root for download streaming."""
with self._lock:
root = self._sync_root()
try:
safe_rel = normalize_relpath(str(path or "").strip())
except PathJailError:
return {"ok": False, "error": "path not allowed"}
lex_path = os.path.join(root, safe_rel)
if os.path.islink(lex_path):
return {"ok": False, "error": "path not allowed"}
target, err = self._resolve_manager_path(
path,
allow_root=False,
must_exist=True,
)
if err or target is None:
return {"ok": False, "error": err or "path not allowed"}
if not os.path.isfile(target):
return {"ok": False, "error": "not a file"}
rel = self._relpath_under_sync(target)
if rel is None:
return {"ok": False, "error": "path not allowed"}
return {
"ok": True,
"abspath": target,
"path": rel,
"filename": os.path.basename(target),
"size": os.path.getsize(target),
}
def _load_settings(self) -> None:
os.makedirs(self._root, exist_ok=True)
if not os.path.isfile(self._settings_path):

View file

@ -148,6 +148,15 @@
:class="{ 'animate-spin': isSyncingPropagationNode }"
/>
</button>
<button
v-if="inboundDeliveryCount > 0"
type="button"
class="sm:hidden rounded-full p-2 min-h-[44px] min-w-[44px] inline-flex items-center justify-center text-amber-700 dark:text-amber-300 hover:bg-amber-50 dark:hover:bg-amber-900/20 transition-colors"
:title="$t('app.cancel_inbound_deliveries')"
@click="cancelInboundDeliveries"
>
<MaterialDesignIcon icon-name="close-circle-outline" class="w-5 h-5" />
</button>
<button type="button" class="hidden sm:flex rounded-full" @click="syncPropagationNode">
<span
class="flex text-gray-800 dark:text-zinc-100 bg-white dark:bg-zinc-800/80 border border-gray-200 dark:border-zinc-700 hover:border-blue-400 dark:hover:border-blue-400/60 px-3 py-1.5 rounded-full shadow-xs transition"
@ -162,6 +171,21 @@
}}</span>
</span>
</button>
<button
v-if="inboundDeliveryCount > 0"
type="button"
class="hidden sm:flex rounded-full"
@click="cancelInboundDeliveries"
>
<span
class="flex text-amber-800 dark:text-amber-200 bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800/60 hover:border-amber-400 dark:hover:border-amber-500/60 px-3 py-1.5 rounded-full shadow-xs transition"
>
<MaterialDesignIcon icon-name="close-circle-outline" class="size-6" />
<span class="hidden sm:inline-block my-auto mx-1 text-sm font-medium">{{
$t("app.cancel_inbound_deliveries_count", { count: inboundDeliveryCount })
}}</span>
</span>
</button>
<button type="button" class="hidden sm:flex rounded-full" @click="composeNewMessage">
<span
class="flex rounded-full border border-zinc-800 bg-zinc-900 px-3 py-1.5 text-white shadow-xs transition hover:bg-zinc-800 dark:border-zinc-400 dark:bg-zinc-200 dark:text-zinc-900 dark:hover:bg-white"
@ -646,6 +670,7 @@ import { postRequestPath } from "../js/reticulumPathfinding.js";
import ToneGenerator from "../js/ToneGenerator";
import { listNavItems } from "../js/registries/navRegistry.js";
import { onWsEvent, offWsEvent } from "../js/registries/wsEventRegistry.js";
import { shouldShowMultiSessionToast } from "../js/activeSessions.js";
import { handleLxmIngestUriResult } from "../js/ingestUriResultNavigation.js";
import { applyRelayShareLink, parseMeshchatRelayUri } from "../js/relayLinkUtils.js";
import logoUrl from "../assets/images/logo.png";
@ -740,6 +765,7 @@ export default {
identitySwitchDedupeHash: null,
identitySwitchDedupeAt: 0,
shellWsHandlerCleanups: [],
multiSessionWarningActive: false,
};
},
computed: {
@ -782,6 +808,10 @@ export default {
"response_received",
].includes(this.propagationNodeStatus?.state);
},
inboundDeliveryCount() {
const count = this.propagationNodeStatus?.inbound_delivery_count;
return Number.isFinite(Number(count)) ? Math.max(0, Number(count)) : 0;
},
activeCallTab() {
return GlobalState.activeCallTab;
},
@ -1487,6 +1517,18 @@ export default {
this.shellWsHandlerCleanups.push(() => offWsEvent(type, bound));
}
},
handleActiveSessionsUpdated(json) {
const count = Number(json?.count ?? 0);
const warningEnabled =
json?.warning_enabled !== undefined
? json.warning_enabled !== false
: this.config?.multi_session_warning_enabled !== false;
const decision = shouldShowMultiSessionToast(count, warningEnabled, this.multiSessionWarningActive);
this.multiSessionWarningActive = decision.warned;
if (decision.show) {
ToastUtils.warning(this.$t("app.multi_session_warning", { count }));
}
},
unregisterShellWsHandlers() {
for (const cleanup of this.shellWsHandlerCleanups) {
cleanup();
@ -1503,6 +1545,9 @@ export default {
this.displayName = next.display_name;
}
},
"app.sessions.updated": (json) => {
this.handleActiveSessionsUpdated(json);
},
keyboard_shortcuts: (json) => {
KeyboardShortcuts.setShortcuts(json.shortcuts);
},
@ -1994,6 +2039,31 @@ export default {
ToastUtils.dismiss(propagationSyncToastKey);
await this.updatePropagationNodeStatus();
},
async cancelInboundDeliveries() {
const count = this.inboundDeliveryCount;
if (count <= 0) {
return;
}
if (!(await DialogUtils.confirm(this.$t("app.cancel_inbound_confirm", { count })))) {
return;
}
try {
const response = await window.api.post("/api/v1/lxmf/propagation-node/cancel-inbound", {});
const cancelled = response?.data?.cancelled ?? 0;
ToastUtils.success(this.$t("app.cancel_inbound_done", { count: cancelled }));
if (response?.data?.inbound_deliveries) {
this.propagationNodeStatus = {
...(this.propagationNodeStatus || {}),
inbound_delivery_count: response.data.inbound_delivery_count ?? 0,
inbound_deliveries: response.data.inbound_deliveries,
};
} else {
await this.updatePropagationNodeStatus();
}
} catch (e) {
ToastUtils.error(e.response?.data?.message ?? this.$t("app.cancel_inbound_failed"));
}
},
async updatePropagationNodeStatus() {
try {
const response = await window.api.get("/api/v1/lxmf/propagation-node/status");

View file

@ -358,6 +358,69 @@
</div>
</div>
<!-- Active sessions -->
<div class="about-section">
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between mb-4">
<div
class="text-xs font-black text-blue-500 uppercase tracking-[0.2em] flex items-center gap-2"
>
<v-icon icon="mdi-monitor-multiple" size="14"></v-icon>
{{ $t("about.active_sessions") }}
</div>
<span
class="text-[11px] font-black uppercase tracking-wider text-gray-500 dark:text-zinc-400"
>
{{ $t("about.active_sessions_count", { count: activeSessionCount }) }}
</span>
</div>
<p class="text-[11px] leading-relaxed text-gray-500 dark:text-gray-400 mb-4">
{{ $t("about.active_sessions_description") }}
</p>
<div v-if="!activeSessions.length" class="text-sm text-gray-600 dark:text-zinc-300">
{{ $t("about.active_sessions_empty") }}
</div>
<ul v-else class="space-y-3 list-none">
<li
v-for="session in activeSessions"
:key="session.id"
class="rounded-xl border border-gray-200 dark:border-zinc-800 bg-gray-50/70 dark:bg-zinc-900/40 p-3 min-w-0"
>
<div class="grid gap-2 text-[11px] sm:grid-cols-2">
<div class="min-w-0">
<div
class="text-[10px] font-black uppercase tracking-wider text-gray-400 dark:text-zinc-500 mb-1"
>
{{ $t("about.active_session_ip") }}
</div>
<div class="font-mono text-gray-900 dark:text-white break-all">
{{ session.ip || "unknown" }}
</div>
</div>
<div class="min-w-0">
<div
class="text-[10px] font-black uppercase tracking-wider text-gray-400 dark:text-zinc-500 mb-1"
>
{{ $t("about.active_session_connected") }}
</div>
<div class="text-gray-900 dark:text-white">
{{ formatSessionConnectedAt(session.connected_at) }}
</div>
</div>
<div class="min-w-0 sm:col-span-2">
<div
class="text-[10px] font-black uppercase tracking-wider text-gray-400 dark:text-zinc-500 mb-1"
>
{{ $t("about.active_session_user_agent") }}
</div>
<div class="font-mono text-gray-700 dark:text-zinc-200 break-all">
{{ session.user_agent || "unknown" }}
</div>
</div>
</div>
</li>
</ul>
</div>
<!-- Advanced Tech Info -->
<div v-if="appInfo" class="about-section">
<div
@ -366,7 +429,7 @@
<v-icon icon="mdi-server" size="14"></v-icon>
{{ $t("about.environment_information") }}
</div>
<div class="grid gap-8 sm:grid-cols-2 lg:grid-cols-3 text-sm min-w-0">
<div class="grid gap-8 sm:grid-cols-2 text-sm min-w-0">
<div>
<div class="glass-label text-[10px]! mb-2 opacity-50">
{{ $t("about.reticulum_config") }}
@ -405,111 +468,6 @@
{{ $t("about.reveal_database_file") }}
</button>
</div>
<div
class="flex flex-col justify-center space-y-3 py-2 sm:py-3 border-t sm:border border-gray-200/60 dark:border-zinc-800/80 sm:rounded-xl sm:p-4 sm:bg-black/2 dark:sm:bg-white/2"
>
<div
v-if="config"
class="space-y-3 mb-2 pb-3 border-b border-zinc-100 dark:border-zinc-800"
>
<div class="flex flex-col">
<span class="text-[9px] font-black text-blue-500 uppercase tracking-wider">{{
$t("about.identity_hash")
}}</span>
<span class="font-mono text-[10px] break-all opacity-70">{{
config.identity_hash
}}</span>
</div>
<div class="flex flex-col">
<span class="text-[9px] font-black text-blue-500 uppercase tracking-wider">{{
$t("about.lxmf_address")
}}</span>
<span class="font-mono text-[10px] break-all opacity-70">{{
config.lxmf_address_hash
}}</span>
</div>
</div>
<div class="flex items-center justify-between">
<span class="text-[10px] font-black text-blue-500 uppercase tracking-wider">{{
$t("about.env_python")
}}</span>
<span class="font-mono text-xs font-bold"
>v{{ appInfo.python_version || $t("about.path_unknown") }}</span
>
</div>
<div class="flex items-center justify-between">
<span class="text-[10px] font-black text-purple-500 uppercase tracking-wider">{{
$t("about.env_lxmf")
}}</span>
<span class="font-mono text-xs font-bold"
>v{{ appInfo.lxmf_version || $t("about.path_unknown") }}</span
>
</div>
<div class="flex items-center justify-between">
<span class="text-[10px] font-black text-indigo-500 uppercase tracking-wider">{{
$t("about.env_rns")
}}</span>
<span class="font-mono text-xs font-bold"
>v{{ appInfo.rns_version || $t("about.path_unknown") }}</span
>
</div>
<div class="flex items-center justify-between">
<span class="text-[10px] font-black text-emerald-500 uppercase tracking-wider">{{
$t("about.env_platform")
}}</span>
<span class="font-mono text-xs font-bold">{{ environmentInfo.platform }}</span>
</div>
<div
v-if="isLinuxHost && appInfo.landlock_requested !== undefined"
class="flex flex-col gap-1"
>
<div class="flex items-center justify-between gap-3">
<span class="text-[10px] font-black text-teal-500 uppercase tracking-wider">{{
$t("app.landlock_status")
}}</span>
<span
class="font-mono text-xs font-bold shrink-0"
:class="
appInfo.landlock_active
? 'text-green-600 dark:text-green-400'
: 'text-amber-600 dark:text-amber-400'
"
>
{{
appInfo.landlock_active
? $t("app.landlock_active")
: $t("app.landlock_inactive")
}}
</span>
</div>
<p
v-if="!appInfo.landlock_active && landlockInactiveReason"
class="text-[10px] leading-snug opacity-70"
>
{{ landlockInactiveReason }}
</p>
</div>
<div class="flex items-center justify-between">
<span class="text-[10px] font-black text-amber-500 uppercase tracking-wider">{{
$t("about.env_language")
}}</span>
<span class="font-mono text-xs font-bold">{{ environmentInfo.language }}</span>
</div>
<div class="flex items-center justify-between">
<span class="text-[10px] font-black text-fuchsia-500 uppercase tracking-wider">{{
$t("about.env_backend_url")
}}</span>
<span class="font-mono text-xs font-bold">{{ environmentInfo.backendUrl }}</span>
</div>
<div class="flex flex-col gap-1 pt-2 border-t border-zinc-100 dark:border-zinc-800">
<span class="text-[10px] font-black text-slate-500 uppercase tracking-wider">{{
$t("about.env_user_agent")
}}</span>
<span class="font-mono text-[10px] break-all opacity-70">{{
environmentInfo.userAgent
}}</span>
</div>
</div>
</div>
</div>
@ -1241,6 +1199,7 @@ import DialogUtils from "../../js/DialogUtils";
import ToastUtils from "../../js/ToastUtils";
import DownloadUtils from "../../js/DownloadUtils";
import GlobalEmitter from "../../js/GlobalEmitter";
import { onWsEvent, offWsEvent } from "../../js/registries/wsEventRegistry.js";
import {
appBatteryUsageToneClass,
batteryStatusIconName,
@ -1266,7 +1225,6 @@ export default {
appInfo: {
version: "unknown",
},
config: null,
updateInterval: null,
healthInterval: null,
databaseHealth: null,
@ -1307,6 +1265,9 @@ export default {
developerLxmfAlternate: "43d3309adf27fc446556121b553b56a6",
moneroDonateAddress:
"83SUg6mmkkVGwCycckLEgRfdmXNm7H9XtVjbGXp5kko71N6pTefYURJeS7WdEGHrz2aagmt4nF3dWg6mHcYs6yu4EokwhTh",
activeSessions: [],
activeSessionCount: 0,
sessionsWsHandler: null,
};
},
computed: {
@ -1332,21 +1293,6 @@ export default {
return "";
}
},
isLinuxHost() {
return this.appInfo && this.appInfo.host_platform === "linux";
},
landlockInactiveReason() {
if (!this.appInfo || this.appInfo.landlock_active) {
return null;
}
if (this.appInfo.landlock_kernel_supported === false) {
return this.$t("app.landlock_kernel_unsupported");
}
if (this.appInfo.landlock_disabled_by_env) {
return this.$t("app.landlock_disabled_by_env");
}
return this.$t("app.landlock_inactive");
},
batteryStatusIcon() {
return batteryStatusIconName(this.batteryStatus);
},
@ -1440,43 +1386,10 @@ export default {
}
return "";
},
environmentInfo() {
const ua = typeof navigator !== "undefined" ? navigator.userAgent || "" : "";
let platform = typeof navigator !== "undefined" && navigator.platform ? navigator.platform : "";
if (
!platform &&
typeof navigator !== "undefined" &&
navigator.userAgentData &&
navigator.userAgentData.platform
) {
platform = navigator.userAgentData.platform;
}
if (!platform && /Android/i.test(ua)) {
platform = "Android";
}
if (!platform && this.appInfo && this.appInfo.host_platform) {
platform = this.appInfo.host_platform;
}
if (!platform) {
platform = "unknown";
}
const language =
typeof navigator !== "undefined" && navigator.language
? navigator.language
: typeof navigator !== "undefined" && navigator.languages && navigator.languages[0]
? navigator.languages[0]
: "unknown";
return {
platform,
language,
userAgent: ua || "unknown",
backendUrl: typeof window !== "undefined" && window.location ? window.location.origin : "unknown",
};
},
},
mounted() {
this.getAppInfo();
this.getConfig();
this.getActiveSessions();
this.getDatabaseHealth();
this.listSnapshots();
this.listAutoBackups();
@ -1485,6 +1398,10 @@ export default {
this.restartAboutPollIntervals();
};
GlobalEmitter.on(BATTERY_SAVER_CHANGED_EVENT, this._batterySaverPrefsHandler);
this.sessionsWsHandler = (payload) => {
this.applyActiveSessionsPayload(payload);
};
onWsEvent("app.sessions.updated", this.sessionsWsHandler);
this.restartAboutPollIntervals();
},
beforeUnmount() {
@ -1497,6 +1414,10 @@ export default {
if (this._batterySaverPrefsHandler) {
GlobalEmitter.off(BATTERY_SAVER_CHANGED_EVENT, this._batterySaverPrefsHandler);
}
if (this.sessionsWsHandler) {
offWsEvent("app.sessions.updated", this.sessionsWsHandler);
this.sessionsWsHandler = null;
}
},
methods: {
restartAboutPollIntervals() {
@ -1510,6 +1431,7 @@ export default {
this.updateInterval = setInterval(
() => {
this.getAppInfo();
this.getActiveSessions();
},
applyBackgroundPollInterval(5000, prefs)
);
@ -1668,6 +1590,31 @@ export default {
console.log(e);
}
},
applyActiveSessionsPayload(payload) {
const sessions = Array.isArray(payload?.sessions) ? payload.sessions : [];
this.activeSessions = sessions;
const count = Number(payload?.count);
this.activeSessionCount = Number.isFinite(count) ? count : sessions.length;
},
async getActiveSessions() {
try {
const response = await window.api.get("/api/v1/app/sessions");
this.applyActiveSessionsPayload(response?.data || {});
} catch (e) {
console.log(e);
}
},
formatSessionConnectedAt(value) {
const ts = Number(value);
if (!Number.isFinite(ts) || ts <= 0) {
return "unknown";
}
try {
return new Date(ts * 1000).toLocaleString();
} catch {
return "unknown";
}
},
async refreshBatteryStatus() {
try {
this.batteryStatus = await getDeviceBatteryStatus();
@ -1818,15 +1765,6 @@ export default {
this.databaseActionInProgress = false;
}
},
async getConfig() {
try {
const response = await window.api.get("/api/v1/config");
this.config = response.data.config;
} catch (e) {
// do nothing if failed to load config
console.log(e);
}
},
async copyValue(value, labelKey) {
if (!value) {
return;

View file

@ -0,0 +1,294 @@
<!-- SPDX-License-Identifier: 0BSD -->
<template>
<div class="space-y-4">
<p class="text-sm text-sem-fg-muted">{{ $t("rns_filesync.manager_help") }}</p>
<div class="flex flex-wrap items-center gap-2">
<button
type="button"
class="secondary-chip px-3 py-1.5 text-sm"
:disabled="busy || currentPath === ''"
:title="$t('rns_filesync.browser_up')"
@click="goUp"
>
<MaterialDesignIcon icon-name="arrow-up" class="w-4 h-4" />
{{ $t("rns_filesync.browser_up") }}
</button>
<button type="button" class="secondary-chip px-3 py-1.5 text-sm" :disabled="busy" @click="refresh">
{{ $t("rns_filesync.refresh") }}
</button>
<button type="button" class="secondary-chip px-3 py-1.5 text-sm" :disabled="busy" @click="triggerUpload">
<MaterialDesignIcon icon-name="upload" class="w-4 h-4" />
{{ $t("rns_filesync.upload") }}
</button>
<button
type="button"
class="secondary-chip px-3 py-1.5 text-sm"
:disabled="busy || !syncDirectory"
@click="$emit('open-folder')"
>
<MaterialDesignIcon icon-name="folder-open-outline" class="w-4 h-4" />
{{ $t("rns_filesync.open_folder") }}
</button>
<input ref="fileInput" type="file" class="hidden" @change="onUploadSelected" />
</div>
<div class="input-field !py-2 font-mono text-xs truncate" :title="breadcrumbLabel">
{{ breadcrumbLabel }}
</div>
<div class="flex flex-col sm:flex-row gap-2">
<input
v-model="newFolderName"
type="text"
class="input-field flex-1 min-w-0 text-sm"
:placeholder="$t('rns_filesync.browser_new_placeholder')"
:disabled="busy"
@keydown.enter.prevent="createFolder"
/>
<button
type="button"
class="secondary-chip px-3 py-2 text-sm shrink-0"
:disabled="busy || !newFolderName.trim()"
@click="createFolder"
>
<MaterialDesignIcon icon-name="folder-plus-outline" class="w-4 h-4" />
{{ $t("rns_filesync.browser_new") }}
</button>
</div>
<div v-if="busy && entries.length === 0" class="text-sm text-sem-fg-muted">
{{ $t("rns_filesync.manager_loading") }}
</div>
<div v-else-if="entries.length === 0" class="text-sm text-sem-fg-muted">
{{ $t("rns_filesync.manager_empty") }}
</div>
<ul v-else class="space-y-2">
<li
v-for="entry in entries"
:key="entry.path"
class="flex flex-col sm:flex-row sm:items-center justify-between gap-2 p-3 rounded-lg border border-sem-border"
>
<button
v-if="entry.type === 'dir'"
type="button"
class="min-w-0 flex items-center gap-2 text-left text-sm text-sem-fg hover:text-emerald-600 dark:hover:text-emerald-400"
@click="enterDir(entry.path)"
>
<MaterialDesignIcon
icon-name="folder"
class="w-5 h-5 shrink-0 text-emerald-600 dark:text-emerald-400"
/>
<span class="break-all">{{ entry.name }}</span>
</button>
<div v-else class="min-w-0 flex items-center gap-2 text-sm text-sem-fg">
<MaterialDesignIcon icon-name="file-outline" class="w-5 h-5 shrink-0 text-sem-fg-muted" />
<div class="min-w-0">
<div class="break-all">{{ entry.name }}</div>
<div class="text-xs text-sem-fg-muted mt-0.5">
{{ formatFileSize(entry.size) }}
</div>
</div>
</div>
<div class="flex flex-wrap gap-2 shrink-0">
<button
v-if="entry.type === 'file'"
type="button"
class="secondary-chip px-3 py-1.5 text-sm"
:disabled="busy"
@click="downloadEntry(entry)"
>
{{ $t("rns_filesync.download_local") }}
</button>
<button
type="button"
class="secondary-chip px-3 py-1.5 text-sm text-red-600 dark:text-red-300"
:disabled="busy"
@click="deleteEntry(entry)"
>
{{ $t("rns_filesync.delete") }}
</button>
</div>
</li>
</ul>
</div>
</template>
<script>
import MaterialDesignIcon from "../MaterialDesignIcon.vue";
import ToastUtils from "../../js/ToastUtils";
import DownloadUtils from "../../js/DownloadUtils";
import DialogUtils from "../../js/DialogUtils";
import Utils from "../../js/Utils";
export default {
name: "FilesyncFileManager",
components: {
MaterialDesignIcon,
},
props: {
syncDirectory: {
type: String,
default: "",
},
},
emits: ["open-folder"],
data() {
return {
busy: false,
currentPath: "",
parentPath: null,
entries: [],
newFolderName: "",
};
},
computed: {
breadcrumbLabel() {
if (!this.currentPath) {
return this.$t("rns_filesync.manager_root");
}
return this.currentPath;
},
},
watch: {
syncDirectory() {
this.currentPath = "";
this.refresh();
},
},
mounted() {
this.refresh();
},
methods: {
formatFileSize(bytes) {
return Utils.formatBytes(bytes || 0);
},
joinPath(base, name) {
const left = String(base || "").replace(/\/+$/, "");
const right = String(name || "").replace(/^\/+/, "");
if (!left) {
return right;
}
if (!right) {
return left;
}
return `${left}/${right}`;
},
async refresh() {
this.busy = true;
try {
const params = {};
if (this.currentPath) {
params.path = this.currentPath;
}
const response = await window.api.get("/api/v1/filesync/tree", { params });
const data = response?.data || {};
this.entries = Array.isArray(data.entries) ? data.entries : [];
this.currentPath = data.current != null ? String(data.current) : "";
this.parentPath = data.parent === undefined ? null : data.parent;
} catch (err) {
this.entries = [];
ToastUtils.error(err?.response?.data?.message || err?.message || this.$t("rns_filesync.error"));
} finally {
this.busy = false;
}
},
enterDir(path) {
this.currentPath = String(path || "");
this.refresh();
},
goUp() {
if (this.parentPath === null || this.parentPath === undefined) {
return;
}
this.currentPath = this.parentPath === "" ? "" : String(this.parentPath);
this.refresh();
},
triggerUpload() {
this.$refs.fileInput?.click();
},
async onUploadSelected(event) {
const file = event?.target?.files?.[0];
if (!file) {
return;
}
this.busy = true;
try {
const formData = new FormData();
formData.append("file", file);
if (this.currentPath) {
formData.append("path", this.currentPath);
}
await window.api.post("/api/v1/filesync/upload", formData);
ToastUtils.success(this.$t("rns_filesync.upload_done"));
await this.refresh();
} catch (err) {
ToastUtils.error(err?.response?.data?.message || err?.message || this.$t("rns_filesync.error"));
} finally {
this.busy = false;
if (event?.target) {
event.target.value = "";
}
}
},
async createFolder() {
const name = String(this.newFolderName || "").trim();
if (!name) {
return;
}
this.busy = true;
try {
const path = this.joinPath(this.currentPath, name);
await window.api.post("/api/v1/filesync/mkdir", { path });
ToastUtils.success(this.$t("rns_filesync.browser_created"));
this.newFolderName = "";
await this.refresh();
} catch (err) {
ToastUtils.error(err?.response?.data?.message || err?.message || this.$t("rns_filesync.error"));
} finally {
this.busy = false;
}
},
async downloadEntry(entry) {
const path = entry?.path;
if (!path) {
return;
}
this.busy = true;
try {
const response = await window.api.get("/api/v1/filesync/content", {
params: { path },
responseType: "blob",
});
await DownloadUtils.downloadFromApiResponse(response, entry.name || "download");
ToastUtils.success(this.$t("rns_filesync.download_local_done"));
} catch (err) {
ToastUtils.error(err?.response?.data?.message || err?.message || this.$t("rns_filesync.error"));
} finally {
this.busy = false;
}
},
async deleteEntry(entry) {
const path = entry?.path;
if (!path) {
return;
}
const label = entry.name || path;
if (!(await DialogUtils.confirm(this.$t("rns_filesync.delete_confirm", { name: label })))) {
return;
}
this.busy = true;
try {
await window.api.delete("/api/v1/filesync/entry", { data: { path } });
ToastUtils.success(this.$t("rns_filesync.delete_done"));
await this.refresh();
} catch (err) {
ToastUtils.error(err?.response?.data?.message || err?.message || this.$t("rns_filesync.error"));
} finally {
this.busy = false;
}
},
},
};
</script>

View file

@ -260,37 +260,11 @@
</div>
<div v-else-if="activeTab === 'files'" class="space-y-4">
<div class="flex flex-wrap items-center gap-2">
<button
type="button"
class="secondary-chip px-3 py-1.5 text-sm"
:disabled="busy"
@click="refreshFiles"
>
{{ $t("rns_filesync.refresh") }}
</button>
<button
type="button"
class="secondary-chip px-3 py-1.5 text-sm"
:disabled="busy || !syncDirectory"
@click="openSyncFolder"
>
<MaterialDesignIcon icon-name="folder-open-outline" class="w-4 h-4" />
{{ $t("rns_filesync.open_folder") }}
</button>
</div>
<div v-if="files.length === 0" class="text-sm text-sem-fg-muted">
{{ $t("rns_filesync.no_files") }}
</div>
<ul v-else class="space-y-2">
<li v-for="file in files" :key="file.path" class="p-3 rounded-lg border border-sem-border">
<div class="break-all text-sm text-sem-fg">{{ file.path }}</div>
<div class="text-xs text-sem-fg-muted mt-1">
{{ formatFileSize(file.size) }}
<span v-if="file.hash" class="font-mono"> · {{ shortHash(file.hash) }}</span>
</div>
</li>
</ul>
<FilesyncFileManager
ref="fileManager"
:sync-directory="syncDirectory"
@open-folder="openSyncFolder"
/>
</div>
<div v-else-if="activeTab === 'remote'" class="space-y-4">
@ -407,6 +381,7 @@ import MaterialDesignIcon from "../MaterialDesignIcon.vue";
import ToastUtils from "../../js/ToastUtils";
import ToolsPageHeader from "../tools/ToolsPageHeader.vue";
import FilesyncDirectoryBrowserModal from "./FilesyncDirectoryBrowserModal.vue";
import FilesyncFileManager from "./FilesyncFileManager.vue";
import ElectronUtils from "../../js/ElectronUtils";
import Utils from "../../js/Utils";
import { onWsEvent, offWsEvent } from "../../js/registries/wsEventRegistry.js";
@ -417,6 +392,7 @@ export default {
MaterialDesignIcon,
ToolsPageHeader,
FilesyncDirectoryBrowserModal,
FilesyncFileManager,
},
data() {
return {
@ -442,7 +418,6 @@ export default {
monitor: true,
connectHash: "",
peers: [],
files: [],
browsePeerId: "",
remoteFiles: [],
aclEnforce: false,
@ -546,11 +521,11 @@ export default {
await this.refreshStatus();
});
bind("filesync.file.updated", async () => {
await this.refreshFiles();
await this.refreshFileManager();
ToastUtils.info(this.$t("rns_filesync.file_updated"));
});
bind("filesync.file.deleted", async () => {
await this.refreshFiles();
await this.refreshFileManager();
ToastUtils.info(this.$t("rns_filesync.file_deleted"));
});
bind("filesync.error", (payload) => {
@ -561,13 +536,6 @@ export default {
formatFileSize(bytes) {
return Utils.formatBytes(bytes || 0);
},
shortHash(hash) {
const value = String(hash || "");
if (value.length <= 12) {
return value;
}
return `${value.slice(0, 8)}...`;
},
peerStatusLabel(peer) {
const raw = peer?.status;
if (raw === 1 || raw === "connected" || raw === true) {
@ -606,7 +574,14 @@ export default {
}
},
async refreshAll() {
await Promise.all([this.refreshStatus(), this.refreshPeers(), this.refreshFiles(), this.refreshAcl()]);
await Promise.all([this.refreshStatus(), this.refreshPeers(), this.refreshAcl()]);
await this.refreshFileManager();
},
async refreshFileManager() {
const manager = this.$refs.fileManager;
if (manager && typeof manager.refresh === "function") {
await manager.refresh();
}
},
async refreshStatus() {
try {
@ -635,15 +610,6 @@ export default {
ToastUtils.error(err?.message || this.$t("rns_filesync.error"));
}
},
async refreshFiles() {
try {
const response = await window.api.get("/api/v1/filesync/files");
const data = response?.data || {};
this.files = Array.isArray(data.files) ? data.files : [];
} catch (err) {
ToastUtils.error(err?.message || this.$t("rns_filesync.error"));
}
},
async refreshAcl() {
try {
const response = await window.api.get("/api/v1/filesync/acl");

View file

@ -2987,6 +2987,22 @@
</span>
</label>
<label class="setting-toggle">
<Toggle
id="multi-session-warning-enabled"
v-model="config.multi_session_warning_enabled"
@update:model-value="onMultiSessionWarningChange"
/>
<span class="setting-toggle__label">
<span class="setting-toggle__title">{{
$t("app.multi_session_warning_enabled")
}}</span>
<span class="setting-toggle__description">{{
$t("app.multi_session_warning_description")
}}</span>
</span>
</label>
<label class="setting-toggle">
<Toggle
id="obfuscate-hops"
@ -4020,6 +4036,7 @@ export default {
local_message_auto_delete_value: 30,
local_message_auto_delete_unit: "days",
privacy_mode_enabled: false,
multi_session_warning_enabled: true,
},
serverSecurity: {
listen_host: null,
@ -4885,6 +4902,9 @@ export default {
async onPrivacyModeChange(value) {
await this.updateConfig({ privacy_mode_enabled: value }, "privacy_mode_enabled");
},
async onMultiSessionWarningChange(value) {
await this.updateConfig({ multi_session_warning_enabled: value }, "multi_session_warning_enabled");
},
onWebUiAllowlistChange() {
if (this.saveTimeouts.webUiAllowlist) clearTimeout(this.saveTimeouts.webUiAllowlist);
this.saveTimeouts.webUiAllowlist = setTimeout(async () => {

View file

@ -0,0 +1,28 @@
// SPDX-License-Identifier: 0BSD
/**
* Predict whether the multi-session warning toast should fire.
* Mirrors meshchatx.src.backend.active_sessions.should_warn_multi_session.
*/
export function shouldWarnMultiSession(count, warningEnabled) {
const active = Number(count);
if (!Number.isFinite(active)) {
return false;
}
return warningEnabled !== false && active >= 2;
}
/**
* Fire at most once per multi-session episode (count rises to 2+).
* Resets when the count drops below 2 so a later episode can warn again.
*/
export function shouldShowMultiSessionToast(count, warningEnabled, alreadyWarned) {
const shouldWarn = shouldWarnMultiSession(count, warningEnabled);
if (!shouldWarn) {
return { show: false, warned: false };
}
if (alreadyWarned) {
return { show: false, warned: true };
}
return { show: true, warned: true };
}

View file

@ -391,6 +391,8 @@ export const CORE_SETTINGS_SECTION_KEYWORDS = {
"app.privacy_data_description",
"app.privacy_mode_enabled",
"app.privacy_mode_description",
"app.multi_session_warning_enabled",
"app.multi_session_warning_description",
"app.screen_security_enabled",
"app.screen_security_description",
"app.screen_security_drm_eyebrow",

View file

@ -138,6 +138,9 @@
"privacy_subsection_telemetry": "Mesh-Telemetrie",
"privacy_mode_enabled": "Datenschutzmodus (externes HTTP/HTTPS blockieren)",
"privacy_mode_description": "Blockiert Kartenkacheln, Geocoding, LibreTranslate, Firmware-Downloads und andere ausgehende HTTP/HTTPS-Verbindungen der App. Die Content Security Policy des Browsers wird auf Same-Origin beschränkt.",
"multi_session_warning_enabled": "Bei mehreren verbundenen Sitzungen warnen",
"multi_session_warning_description": "Zeigt eine Warnung, wenn zwei oder mehr Browser oder Geräte gleichzeitig mit dieser MeshChatX-Instanz verbunden sind.",
"multi_session_warning": "Mehrere aktive Sitzungen ({count}). Ein anderer Browser oder ein anderes Gerät ist mit dieser MeshChatX-Instanz verbunden.",
"screen_security_enabled": "Bildschirmsicherheit (Aufnahme blockieren)",
"screen_security_description": "Verwendet ein Windows-Display-Affinity-Flag, damit MeshChatX in Screenshots, Bildschirmaufnahmen und Windows Recall ausgelassen wird. Das Fenster sieht auf Ihrem Monitor weiterhin normal aus.",
"screen_security_description_short": "Dieses Fenster vor Screenshots, Recordern und Windows Recall verbergen.",
@ -507,7 +510,12 @@
"propagation_static_peers_bypass_sequential": "Static Peers Bypass Sequential Validation",
"propagation_static_peers_bypass_sequential_description": "Allow configured static peers to sync immediately even when another stamp batch is already validating.",
"propagation_max_inbound_syncs": "Max Concurrent Inbound Syncs",
"propagation_max_inbound_syncs_description": "How many concurrent inbound propagation sync transfers to accept. Extra offers are throttled. Range: 1-64. Default: 3."
"propagation_max_inbound_syncs_description": "How many concurrent inbound propagation sync transfers to accept. Extra offers are throttled. Range: 1-64. Default: 3.",
"cancel_inbound_deliveries": "Eingehende Übertragungen abbrechen",
"cancel_inbound_deliveries_count": "Eingehende abbrechen ({count})",
"cancel_inbound_confirm": "{count} eingehende LXMF-Übertragung(en) abbrechen? Große Nachrichten, die gerade heruntergeladen werden, werden gestoppt.",
"cancel_inbound_done": "{count} eingehende Übertragung(en) abgebrochen.",
"cancel_inbound_failed": "Eingehende Übertragungen konnten nicht abgebrochen werden"
},
"common": {
"open": "Öffnen",
@ -1085,6 +1093,13 @@
"config_path": "Konfigurationspfad",
"database_path": "Datenbankpfad",
"database_size": "Datenbankgröße",
"active_sessions": "Aktive Sitzungen",
"active_sessions_description": "Browser und Geräte, die derzeit über den UI-WebSocket mit dieser MeshChatX-Instanz verbunden sind.",
"active_sessions_empty": "Keine aktiven Sitzungen.",
"active_sessions_count": "{count} aktiv",
"active_session_ip": "IP-Adresse",
"active_session_user_agent": "User-Agent",
"active_session_connected": "Verbunden",
"database_health": "Datenbank-Zustand",
"database_health_description": "Schnellprüfung, WAL-Optimierung und Wiederherstellungswerkzeuge für die MeshChatX-Datenbank.",
"running_checks": "Prüfungen werden ausgeführt...",
@ -3828,7 +3843,7 @@
"description": "Keep a folder in sync with people on your mesh. Share your sync ID, connect devices, and files move both ways when allowed.",
"usage_steps": "Quick start",
"step_1": "1. Choose a folder, press Start, then share your sync ID with a friend.",
"step_2": "2. On Devices, paste their sync ID to connect. Files appear under Files as they sync.",
"step_2": "2. Unter Geräte die Sync-ID einfügen, um zu verbinden. Lokale Dateien unter Dateien verwalten oder von Remote abrufen.",
"step_3": "3. Under Sharing, decide who can read, write, or delete when access control is on.",
"tab_folder": "Folder",
"tab_devices": "Devices",
@ -3906,6 +3921,17 @@
"file_updated": "A file changed",
"file_deleted": "A file was removed",
"copied": "Copied to clipboard",
"error": "File Sync error"
"error": "File Sync error",
"manager_help": "Dateien im Sync-Ordner durchsuchen und verwalten. Uploads und Löschungen bleiben nur in diesem Ordner.",
"manager_root": "(Wurzel des Sync-Ordners)",
"manager_loading": "Dateien werden geladen...",
"manager_empty": "Dieser Ordner ist leer. Laden Sie eine Datei hoch oder erstellen Sie einen Unterordner.",
"upload": "Hochladen",
"upload_done": "Datei hochgeladen",
"download_local": "Herunterladen",
"download_local_done": "Download gestartet",
"delete": "Löschen",
"delete_confirm": "„{name}“ aus dem Sync-Ordner löschen?",
"delete_done": "Gelöscht"
}
}

View file

@ -194,6 +194,9 @@
"privacy_subsection_telemetry": "Mesh telemetry",
"privacy_mode_enabled": "Privacy mode (block external HTTP/HTTPS)",
"privacy_mode_description": "Blocks map tiles, geocoding, LibreTranslate, firmware downloads, and other outbound HTTP/HTTPS from the app. The browser Content Security Policy is tightened to same-origin only.",
"multi_session_warning_enabled": "Warn when multiple sessions are connected",
"multi_session_warning_description": "Show a warning toast when two or more browsers or devices are connected to this MeshChatX instance at the same time.",
"multi_session_warning": "Multiple active sessions ({count}). Another browser or device is connected to this MeshChatX instance.",
"screen_security_enabled": "Screen security (block capture)",
"screen_security_description": "Uses a Windows display-affinity flag so MeshChatX is omitted from screenshots, screen recording, and Windows Recall. The window still looks normal on your monitor.",
"screen_security_description_short": "Hide this window from screenshots, recorders, and Windows Recall.",
@ -384,6 +387,11 @@
"reloaded_rns": "Reticulum reloaded successfully",
"announce_interval": "Announce Interval",
"stop_sync_confirm": "Are you sure you want to stop syncing?",
"cancel_inbound_deliveries": "Cancel incoming transfers",
"cancel_inbound_deliveries_count": "Cancel incoming ({count})",
"cancel_inbound_confirm": "Cancel {count} incoming LXMF delivery transfer(s)? Large messages currently downloading will stop.",
"cancel_inbound_done": "Cancelled {count} incoming delivery transfer(s).",
"cancel_inbound_failed": "Failed to cancel incoming deliveries",
"sync_error_generic": "Something went wrong. Try again later.",
"sync_complete": "Sync complete. {count} messages received.",
"sync_error": "Sync error: {status}",
@ -1033,6 +1041,13 @@
"config_path": "Config path",
"database_path": "Database path",
"database_size": "Database size",
"active_sessions": "Active sessions",
"active_sessions_description": "Browsers and devices currently connected to this MeshChatX instance over the UI WebSocket.",
"active_sessions_empty": "No active sessions.",
"active_sessions_count": "{count} active",
"active_session_ip": "IP address",
"active_session_user_agent": "User agent",
"active_session_connected": "Connected",
"database_health": "Database Health",
"database_health_description": "Quick check, WAL tuning, and recovery tools for the MeshChatX database.",
"running_checks": "Running checks...",
@ -3101,7 +3116,7 @@
"description": "Keep a folder in sync with people on your mesh. Share your sync ID, connect devices, and files move both ways when allowed.",
"usage_steps": "Quick start",
"step_1": "1. Choose a folder, press Start, then share your sync ID with a friend.",
"step_2": "2. On Devices, paste their sync ID to connect. Files appear under Files as they sync.",
"step_2": "2. On Devices, paste their sync ID to connect. Manage local files under Files, or pull from Remote.",
"step_3": "3. Under Sharing, decide who can read, write, or delete when access control is on.",
"tab_folder": "Folder",
"tab_devices": "Devices",
@ -3138,6 +3153,17 @@
"disconnect": "Remove",
"no_peers": "No devices connected yet. Start syncing, then paste a friends sync ID.",
"no_files": "This folder is empty so far. Drop files in, or pull some from Remote.",
"manager_help": "Browse and manage files in your sync folder. Uploads and deletes stay inside this folder only.",
"manager_root": "(sync folder root)",
"manager_loading": "Loading files...",
"manager_empty": "This folder is empty. Upload a file or create a subfolder.",
"upload": "Upload",
"upload_done": "File uploaded",
"download_local": "Download",
"download_local_done": "Download started",
"delete": "Delete",
"delete_confirm": "Delete \"{name}\" from the sync folder?",
"delete_done": "Deleted",
"select_peer": "Choose a device",
"browse": "List files",
"no_remote_files": "No remote files yet. Connect a device, then list their files.",

View file

@ -164,6 +164,9 @@
"privacy_subsection_telemetry": "Telemetría mesh",
"privacy_mode_enabled": "Modo de privacidad (bloquear HTTP/HTTPS externo)",
"privacy_mode_description": "Bloquea teselas de mapa, geocodificación, LibreTranslate, descargas de firmware y otras conexiones HTTP/HTTPS salientes de la app. La política de seguridad de contenido del navegador se restringe solo al mismo origen.",
"multi_session_warning_enabled": "Avisar cuando hay varias sesiones conectadas",
"multi_session_warning_description": "Muestra un aviso cuando dos o más navegadores o dispositivos están conectados a esta instancia de MeshChatX al mismo tiempo.",
"multi_session_warning": "Varias sesiones activas ({count}). Otro navegador o dispositivo está conectado a esta instancia de MeshChatX.",
"screen_security_enabled": "Seguridad de pantalla (bloquear captura)",
"screen_security_description": "Usa una marca de afinidad de pantalla de Windows para que MeshChatX se omita en capturas, grabaciones y Windows Recall. La ventana sigue viéndose normal en su monitor.",
"screen_security_description_short": "Ocultar esta ventana de capturas, grabadoras y Windows Recall.",
@ -507,7 +510,12 @@
"propagation_static_peers_bypass_sequential": "Static Peers Bypass Sequential Validation",
"propagation_static_peers_bypass_sequential_description": "Allow configured static peers to sync immediately even when another stamp batch is already validating.",
"propagation_max_inbound_syncs": "Max Concurrent Inbound Syncs",
"propagation_max_inbound_syncs_description": "How many concurrent inbound propagation sync transfers to accept. Extra offers are throttled. Range: 1-64. Default: 3."
"propagation_max_inbound_syncs_description": "How many concurrent inbound propagation sync transfers to accept. Extra offers are throttled. Range: 1-64. Default: 3.",
"cancel_inbound_deliveries": "Cancelar transferencias entrantes",
"cancel_inbound_deliveries_count": "Cancelar entrantes ({count})",
"cancel_inbound_confirm": "¿Cancelar {count} transferencia(s) entrante(s) de LXMF? Se detendrán los mensajes grandes que se estén descargando.",
"cancel_inbound_done": "Se cancelaron {count} transferencia(s) entrante(s).",
"cancel_inbound_failed": "No se pudieron cancelar las entregas entrantes"
},
"common": {
"open": "Abierto",
@ -1033,6 +1041,13 @@
"config_path": "Ruta de confidencialidad",
"database_path": "Vía de base",
"database_size": "Tamaño de la base",
"active_sessions": "Sesiones activas",
"active_sessions_description": "Navegadores y dispositivos conectados actualmente a esta instancia de MeshChatX por el WebSocket de la interfaz.",
"active_sessions_empty": "No hay sesiones activas.",
"active_sessions_count": "{count} activas",
"active_session_ip": "Dirección IP",
"active_session_user_agent": "User agent",
"active_session_connected": "Conectada",
"database_health": "Base de datos",
"database_health_description": "Control rápido, ajuste WAL y herramientas de recuperación para la base de datos MeshChatX.",
"running_checks": "Corriendo cheques...",
@ -3828,7 +3843,7 @@
"description": "Keep a folder in sync with people on your mesh. Share your sync ID, connect devices, and files move both ways when allowed.",
"usage_steps": "Quick start",
"step_1": "1. Choose a folder, press Start, then share your sync ID with a friend.",
"step_2": "2. On Devices, paste their sync ID to connect. Files appear under Files as they sync.",
"step_2": "2. En Dispositivos, pega su ID de sincronización para conectar. Gestiona archivos locales en Archivos o tráelos desde Remoto.",
"step_3": "3. Under Sharing, decide who can read, write, or delete when access control is on.",
"tab_folder": "Folder",
"tab_devices": "Devices",
@ -3906,6 +3921,17 @@
"file_updated": "A file changed",
"file_deleted": "A file was removed",
"copied": "Copied to clipboard",
"error": "File Sync error"
"error": "File Sync error",
"manager_help": "Explora y gestiona archivos en tu carpeta de sincronización. Las subidas y eliminaciones se quedan solo en esta carpeta.",
"manager_root": "(raíz de la carpeta de sincronización)",
"manager_loading": "Cargando archivos...",
"manager_empty": "Esta carpeta está vacía. Sube un archivo o crea una subcarpeta.",
"upload": "Subir",
"upload_done": "Archivo subido",
"download_local": "Descargar",
"download_local_done": "Descarga iniciada",
"delete": "Eliminar",
"delete_confirm": "¿Eliminar \"{name}\" de la carpeta de sincronización?",
"delete_done": "Eliminado"
}
}

View file

@ -165,6 +165,9 @@
"privacy_subsection_telemetry": "Mesh-verkon käyttötiedot",
"privacy_mode_enabled": "Yksityisyystila (estä ulkoinen HTTP/HTTPS)",
"privacy_mode_description": "Estää karttalaatat, geokoodaukset, LibreTranslate, laiteohjelmistolataukset, ja muut lähtevät HTTP/HTTPS-yhteydet sovelluksesta. Selaimen sisällön turvallisuuskäytäntö (CSP) kiristetään sallimaan vain samasta lähteestä oleva liikenne.",
"multi_session_warning_enabled": "Varoita, kun useampi istunto on yhteydessä",
"multi_session_warning_description": "Näytä varoitus, kun kaksi tai useampi selain tai laite on yhdistetty tähän MeshChatX-instanssiin samaan aikaan.",
"multi_session_warning": "Useita aktiivisia istuntoja ({count}). Toinen selain tai laite on yhteydessä tähän MeshChatX-instanssiin.",
"screen_security_enabled": "Näytön suojaus (estä kaappaus)",
"screen_security_description": "Käyttää Windowsin display-affinity-lippua, jotta MeshChatX jätetään pois kuvakaappauksista, näytön tallennuksesta ja Windows Recallista. Ikkuna näyttää edelleen normaalilta näytölläsi.",
"screen_security_description_short": "Piilota tämä ikkuna kuvakaappauksilta, tallentimilta ja Windows Recallilta.",
@ -507,7 +510,12 @@
"propagation_static_peers_bypass_sequential": "Static Peers Bypass Sequential Validation",
"propagation_static_peers_bypass_sequential_description": "Allow configured static peers to sync immediately even when another stamp batch is already validating.",
"propagation_max_inbound_syncs": "Max Concurrent Inbound Syncs",
"propagation_max_inbound_syncs_description": "How many concurrent inbound propagation sync transfers to accept. Extra offers are throttled. Range: 1-64. Default: 3."
"propagation_max_inbound_syncs_description": "How many concurrent inbound propagation sync transfers to accept. Extra offers are throttled. Range: 1-64. Default: 3.",
"cancel_inbound_deliveries": "Peruuta saapuvat siirrot",
"cancel_inbound_deliveries_count": "Peruuta saapuvat ({count})",
"cancel_inbound_confirm": "Perutaanko {count} saapuvaa LXMF-siirtoa? Suurten, juuri ladattavien viestien lataus pysähtyy.",
"cancel_inbound_done": "Peruttiin {count} saapuvaa siirtoa.",
"cancel_inbound_failed": "Saapuvia siirtoja ei voitu perua"
},
"common": {
"open": "Avaa",
@ -1033,6 +1041,13 @@
"config_path": "Kokoonpanon polku",
"database_path": "Tietokannan polku",
"database_size": "Tietokannan koko",
"active_sessions": "Aktiiviset istunnot",
"active_sessions_description": "Selaimet ja laitteet, jotka ovat tällä hetkellä yhteydessä tähän MeshChatX-instanssiin UI-WebSocketin kautta.",
"active_sessions_empty": "Ei aktiivisia istuntoja.",
"active_sessions_count": "{count} aktiivista",
"active_session_ip": "IP-osoite",
"active_session_user_agent": "User agent",
"active_session_connected": "Yhdistetty",
"database_health": "Tietokannan eheys",
"database_health_description": "MeshChatX-tietokannan tarkistus, WAL-säätö ja palautustyökalut.",
"running_checks": "Tarkistetaan...",
@ -3828,7 +3843,7 @@
"description": "Keep a folder in sync with people on your mesh. Share your sync ID, connect devices, and files move both ways when allowed.",
"usage_steps": "Quick start",
"step_1": "1. Choose a folder, press Start, then share your sync ID with a friend.",
"step_2": "2. On Devices, paste their sync ID to connect. Files appear under Files as they sync.",
"step_2": "2. Liitä laitteissa heidän synkronointitunnuksensa yhteyden muodostamiseksi. Hallitse paikallisia tiedostoja Kohdassa Tiedostot tai hae etäyhteydestä.",
"step_3": "3. Under Sharing, decide who can read, write, or delete when access control is on.",
"tab_folder": "Folder",
"tab_devices": "Devices",
@ -3906,6 +3921,17 @@
"file_updated": "A file changed",
"file_deleted": "A file was removed",
"copied": "Copied to clipboard",
"error": "File Sync error"
"error": "File Sync error",
"manager_help": "Selaa ja hallitse tiedostoja synkronointikansiossa. Lähetykset ja poistot pysyvät vain tässä kansiossa.",
"manager_root": "(synkronointikansion juuri)",
"manager_loading": "Ladataan tiedostoja...",
"manager_empty": "Tämä kansio on tyhjä. Lähetä tiedosto tai luo alikansio.",
"upload": "Lähetä",
"upload_done": "Tiedosto lähetetty",
"download_local": "Lataa",
"download_local_done": "Lataus aloitettu",
"delete": "Poista",
"delete_confirm": "Poistetaanko \"{name}\" synkronointikansiosta?",
"delete_done": "Poistettu"
}
}

View file

@ -164,6 +164,9 @@
"privacy_subsection_telemetry": "Télémétrie mesh",
"privacy_mode_enabled": "Mode confidentialité (bloquer HTTP/HTTPS externe)",
"privacy_mode_description": "Bloque les tuiles de carte, le géocodage, LibreTranslate, les téléchargements de firmware et autres connexions HTTP/HTTPS sortantes de l'application. La politique de sécurité du contenu du navigateur est restreinte à same-origin uniquement.",
"multi_session_warning_enabled": "Avertir lorsque plusieurs sessions sont connectées",
"multi_session_warning_description": "Affiche un avertissement lorsque deux navigateurs ou appareils ou plus sont connectés à cette instance MeshChatX en même temps.",
"multi_session_warning": "Plusieurs sessions actives ({count}). Un autre navigateur ou appareil est connecté à cette instance MeshChatX.",
"screen_security_enabled": "Sécurité décran (bloquer la capture)",
"screen_security_description": "Utilise un indicateur daffinité daffichage Windows pour que MeshChatX soit omis des captures décran, de lenregistrement et de Windows Recall. La fenêtre reste normale sur votre moniteur.",
"screen_security_description_short": "Masquer cette fenêtre des captures, enregistreurs et de Windows Recall.",
@ -507,7 +510,12 @@
"propagation_static_peers_bypass_sequential": "Static Peers Bypass Sequential Validation",
"propagation_static_peers_bypass_sequential_description": "Allow configured static peers to sync immediately even when another stamp batch is already validating.",
"propagation_max_inbound_syncs": "Max Concurrent Inbound Syncs",
"propagation_max_inbound_syncs_description": "How many concurrent inbound propagation sync transfers to accept. Extra offers are throttled. Range: 1-64. Default: 3."
"propagation_max_inbound_syncs_description": "How many concurrent inbound propagation sync transfers to accept. Extra offers are throttled. Range: 1-64. Default: 3.",
"cancel_inbound_deliveries": "Annuler les transferts entrants",
"cancel_inbound_deliveries_count": "Annuler entrants ({count})",
"cancel_inbound_confirm": "Annuler {count} transfert(s) entrant(s) LXMF ? Les grands messages en cours de téléchargement seront arrêtés.",
"cancel_inbound_done": "{count} transfert(s) entrant(s) annulé(s).",
"cancel_inbound_failed": "Échec de l'annulation des livraisons entrantes"
},
"common": {
"open": "Ouvrir",
@ -1033,6 +1041,13 @@
"config_path": "Configurer le chemin",
"database_path": "Chemin de la base de données",
"database_size": "Taille de la base de données",
"active_sessions": "Sessions actives",
"active_sessions_description": "Navigateurs et appareils actuellement connectés à cette instance MeshChatX via le WebSocket de l'interface.",
"active_sessions_empty": "Aucune session active.",
"active_sessions_count": "{count} active(s)",
"active_session_ip": "Adresse IP",
"active_session_user_agent": "User-agent",
"active_session_connected": "Connectée",
"database_health": "Base de données Santé",
"database_health_description": "Vérification rapide, réglage WAL et outils de récupération pour la base de données MeshChatX.",
"running_checks": "Des contrôles...",
@ -3828,7 +3843,7 @@
"description": "Keep a folder in sync with people on your mesh. Share your sync ID, connect devices, and files move both ways when allowed.",
"usage_steps": "Quick start",
"step_1": "1. Choose a folder, press Start, then share your sync ID with a friend.",
"step_2": "2. On Devices, paste their sync ID to connect. Files appear under Files as they sync.",
"step_2": "2. Dans Appareils, collez leur ID de synchronisation pour vous connecter. Gérez les fichiers locaux sous Fichiers, ou récupérez-les depuis Distant.",
"step_3": "3. Under Sharing, decide who can read, write, or delete when access control is on.",
"tab_folder": "Folder",
"tab_devices": "Devices",
@ -3906,6 +3921,17 @@
"file_updated": "A file changed",
"file_deleted": "A file was removed",
"copied": "Copied to clipboard",
"error": "File Sync error"
"error": "File Sync error",
"manager_help": "Parcourez et gérez les fichiers de votre dossier de synchronisation. Les envois et suppressions restent uniquement dans ce dossier.",
"manager_root": "(racine du dossier de synchronisation)",
"manager_loading": "Chargement des fichiers...",
"manager_empty": "Ce dossier est vide. Envoyez un fichier ou créez un sous-dossier.",
"upload": "Envoyer",
"upload_done": "Fichier envoyé",
"download_local": "Télécharger",
"download_local_done": "Téléchargement démarré",
"delete": "Supprimer",
"delete_confirm": "Supprimer « {name} » du dossier de synchronisation ?",
"delete_done": "Supprimé"
}
}

View file

@ -164,6 +164,9 @@
"privacy_subsection_telemetry": "Telemetria mesh",
"privacy_mode_enabled": "Modalità privacy (blocca HTTP/HTTPS esterni)",
"privacy_mode_description": "Blocca tile mappe, geocodifica, LibreTranslate, download firmware e altre connessioni HTTP/HTTPS in uscita dall'app. La Content Security Policy del browser è ristretta al solo same-origin.",
"multi_session_warning_enabled": "Avvisa quando sono collegate più sessioni",
"multi_session_warning_description": "Mostra un avviso quando due o più browser o dispositivi sono collegati a questa istanza MeshChatX contemporaneamente.",
"multi_session_warning": "Più sessioni attive ({count}). Un altro browser o dispositivo è collegato a questa istanza MeshChatX.",
"screen_security_enabled": "Sicurezza schermo (blocca acquisizione)",
"screen_security_description": "Usa un flag di affinità display di Windows affinché MeshChatX sia escluso da screenshot, registrazioni e Windows Recall. La finestra resta normale sul monitor.",
"screen_security_description_short": "Nascondi questa finestra da screenshot, registratori e Windows Recall.",
@ -507,7 +510,12 @@
"propagation_static_peers_bypass_sequential": "Static Peers Bypass Sequential Validation",
"propagation_static_peers_bypass_sequential_description": "Allow configured static peers to sync immediately even when another stamp batch is already validating.",
"propagation_max_inbound_syncs": "Max Concurrent Inbound Syncs",
"propagation_max_inbound_syncs_description": "How many concurrent inbound propagation sync transfers to accept. Extra offers are throttled. Range: 1-64. Default: 3."
"propagation_max_inbound_syncs_description": "How many concurrent inbound propagation sync transfers to accept. Extra offers are throttled. Range: 1-64. Default: 3.",
"cancel_inbound_deliveries": "Annulla trasferimenti in arrivo",
"cancel_inbound_deliveries_count": "Annulla in arrivo ({count})",
"cancel_inbound_confirm": "Annullare {count} trasferimento/i LXMF in arrivo? I messaggi grandi in download verranno interrotti.",
"cancel_inbound_done": "Annullati {count} trasferimento/i in arrivo.",
"cancel_inbound_failed": "Impossibile annullare le consegne in arrivo"
},
"common": {
"open": "Apri",
@ -1085,6 +1093,13 @@
"config_path": "Percorso config",
"database_path": "Percorso database",
"database_size": "Dimensione database",
"active_sessions": "Sessioni attive",
"active_sessions_description": "Browser e dispositivi attualmente connessi a questa istanza MeshChatX tramite il WebSocket dell'interfaccia.",
"active_sessions_empty": "Nessuna sessione attiva.",
"active_sessions_count": "{count} attive",
"active_session_ip": "Indirizzo IP",
"active_session_user_agent": "User agent",
"active_session_connected": "Connessa",
"database_health": "Salute Database",
"database_health_description": "Controllo rapido, ottimizzazione WAL e strumenti di recupero per il database MeshChatX.",
"running_checks": "Esecuzione controlli...",
@ -3828,7 +3843,7 @@
"description": "Keep a folder in sync with people on your mesh. Share your sync ID, connect devices, and files move both ways when allowed.",
"usage_steps": "Quick start",
"step_1": "1. Choose a folder, press Start, then share your sync ID with a friend.",
"step_2": "2. On Devices, paste their sync ID to connect. Files appear under Files as they sync.",
"step_2": "2. In Dispositivi, incolla il loro ID di sincronizzazione per connetterti. Gestisci i file locali in File, oppure scaricali da Remoto.",
"step_3": "3. Under Sharing, decide who can read, write, or delete when access control is on.",
"tab_folder": "Folder",
"tab_devices": "Devices",
@ -3906,6 +3921,17 @@
"file_updated": "A file changed",
"file_deleted": "A file was removed",
"copied": "Copied to clipboard",
"error": "File Sync error"
"error": "File Sync error",
"manager_help": "Sfoglia e gestisci i file nella cartella di sincronizzazione. Caricamenti ed eliminazioni restano solo in questa cartella.",
"manager_root": "(radice della cartella di sincronizzazione)",
"manager_loading": "Caricamento file...",
"manager_empty": "Questa cartella è vuota. Carica un file o crea una sottocartella.",
"upload": "Carica",
"upload_done": "File caricato",
"download_local": "Scarica",
"download_local_done": "Download avviato",
"delete": "Elimina",
"delete_confirm": "Eliminare \"{name}\" dalla cartella di sincronizzazione?",
"delete_done": "Eliminato"
}
}

View file

@ -164,6 +164,9 @@
"privacy_subsection_telemetry": "Mesh-telemetrie",
"privacy_mode_enabled": "Privacymodus (blokkeer extern HTTP/HTTPS)",
"privacy_mode_description": "Blokkeert kaarttegels, geocodering, LibreTranslate, firmware-downloads en andere uitgaande HTTP/HTTPS-verbindingen van de app. Het Content Security Policy van de browser wordt beperkt tot same-origin.",
"multi_session_warning_enabled": "Waarschuwen bij meerdere verbonden sessies",
"multi_session_warning_description": "Toon een waarschuwing wanneer twee of meer browsers of apparaten tegelijk met deze MeshChatX-instantie zijn verbonden.",
"multi_session_warning": "Meerdere actieve sessies ({count}). Een andere browser of een ander apparaat is verbonden met deze MeshChatX-instantie.",
"screen_security_enabled": "Schermbeveiliging (opname blokkeren)",
"screen_security_description": "Gebruikt een Windows display-affinity-vlag zodat MeshChatX wordt weggelaten uit screenshots, schermopnames en Windows Recall. Het venster ziet er op je monitor nog steeds normaal uit.",
"screen_security_description_short": "Verberg dit venster voor screenshots, recorders en Windows Recall.",
@ -507,7 +510,12 @@
"propagation_static_peers_bypass_sequential": "Static Peers Bypass Sequential Validation",
"propagation_static_peers_bypass_sequential_description": "Allow configured static peers to sync immediately even when another stamp batch is already validating.",
"propagation_max_inbound_syncs": "Max Concurrent Inbound Syncs",
"propagation_max_inbound_syncs_description": "How many concurrent inbound propagation sync transfers to accept. Extra offers are throttled. Range: 1-64. Default: 3."
"propagation_max_inbound_syncs_description": "How many concurrent inbound propagation sync transfers to accept. Extra offers are throttled. Range: 1-64. Default: 3.",
"cancel_inbound_deliveries": "Inkomende overdrachten annuleren",
"cancel_inbound_deliveries_count": "Inkomend annuleren ({count})",
"cancel_inbound_confirm": "{count} inkomende LXMF-overdracht(en) annuleren? Grote berichten die nu worden gedownload stoppen.",
"cancel_inbound_done": "{count} inkomende overdracht(en) geannuleerd.",
"cancel_inbound_failed": "Inkomende leveringen konden niet worden geannuleerd"
},
"common": {
"open": "Openen",
@ -1033,6 +1041,13 @@
"config_path": "Pad instellen",
"database_path": "Databasepad",
"database_size": "Databasegrootte",
"active_sessions": "Actieve sessies",
"active_sessions_description": "Browsers en apparaten die momenteel via de UI-WebSocket met deze MeshChatX-instantie zijn verbonden.",
"active_sessions_empty": "Geen actieve sessies.",
"active_sessions_count": "{count} actief",
"active_session_ip": "IP-adres",
"active_session_user_agent": "User agent",
"active_session_connected": "Verbonden",
"database_health": "Databasegezondheid",
"database_health_description": "Snel controleren, WAL tuning, en herstel tools voor de MeshChatX database.",
"running_checks": "Controles uitvoeren...",
@ -3828,7 +3843,7 @@
"description": "Keep a folder in sync with people on your mesh. Share your sync ID, connect devices, and files move both ways when allowed.",
"usage_steps": "Quick start",
"step_1": "1. Choose a folder, press Start, then share your sync ID with a friend.",
"step_2": "2. On Devices, paste their sync ID to connect. Files appear under Files as they sync.",
"step_2": "2. Plak op Apparaten hun sync-ID om te verbinden. Beheer lokale bestanden onder Bestanden, of haal ze van Extern.",
"step_3": "3. Under Sharing, decide who can read, write, or delete when access control is on.",
"tab_folder": "Folder",
"tab_devices": "Devices",
@ -3906,6 +3921,17 @@
"file_updated": "A file changed",
"file_deleted": "A file was removed",
"copied": "Copied to clipboard",
"error": "File Sync error"
"error": "File Sync error",
"manager_help": "Blader door en beheer bestanden in je syncmap. Uploads en verwijderingen blijven alleen in deze map.",
"manager_root": "(hoofdmap van de syncmap)",
"manager_loading": "Bestanden laden...",
"manager_empty": "Deze map is leeg. Upload een bestand of maak een submap.",
"upload": "Uploaden",
"upload_done": "Bestand geüpload",
"download_local": "Downloaden",
"download_local_done": "Download gestart",
"delete": "Verwijderen",
"delete_confirm": "\"{name}\" uit de syncmap verwijderen?",
"delete_done": "Verwijderd"
}
}

View file

@ -138,6 +138,9 @@
"privacy_subsection_telemetry": "Телеметрия mesh",
"privacy_mode_enabled": "Режим конфиденциальности (блокировка внешнего HTTP/HTTPS)",
"privacy_mode_description": "Блокирует тайлы карт, геокодирование, LibreTranslate, загрузку прошивок и другие исходящие HTTP/HTTPS-соединения приложения. Политика безопасности контента браузера ограничивается только same-origin.",
"multi_session_warning_enabled": "Предупреждать при нескольких подключённых сеансах",
"multi_session_warning_description": "Показывать предупреждение, когда к этому экземпляру MeshChatX одновременно подключены два или более браузера или устройства.",
"multi_session_warning": "Несколько активных сеансов ({count}). К этому экземпляру MeshChatX подключён другой браузер или устройство.",
"screen_security_enabled": "Защита экрана (блокировать захват)",
"screen_security_description": "Использует флаг display affinity Windows, чтобы MeshChatX не попадал в снимки экрана, запись и Windows Recall. Окно по-прежнему выглядит обычно на мониторе.",
"screen_security_description_short": "Скрыть это окно от снимков экрана, записи и Windows Recall.",
@ -507,7 +510,12 @@
"propagation_static_peers_bypass_sequential": "Static Peers Bypass Sequential Validation",
"propagation_static_peers_bypass_sequential_description": "Allow configured static peers to sync immediately even when another stamp batch is already validating.",
"propagation_max_inbound_syncs": "Max Concurrent Inbound Syncs",
"propagation_max_inbound_syncs_description": "How many concurrent inbound propagation sync transfers to accept. Extra offers are throttled. Range: 1-64. Default: 3."
"propagation_max_inbound_syncs_description": "How many concurrent inbound propagation sync transfers to accept. Extra offers are throttled. Range: 1-64. Default: 3.",
"cancel_inbound_deliveries": "Отменить входящие передачи",
"cancel_inbound_deliveries_count": "Отменить входящие ({count})",
"cancel_inbound_confirm": "Отменить {count} входящую LXMF-передачу(и)? Крупные сообщения, которые сейчас загружаются, будут остановлены.",
"cancel_inbound_done": "Отменено входящих передач: {count}.",
"cancel_inbound_failed": "Не удалось отменить входящие доставки"
},
"common": {
"open": "Открыть",
@ -1085,6 +1093,13 @@
"config_path": "Путь к конфигу",
"database_path": "Путь к базе данных",
"database_size": "Размер базы данных",
"active_sessions": "Активные сеансы",
"active_sessions_description": "Браузеры и устройства, подключённые к этому экземпляру MeshChatX через UI WebSocket.",
"active_sessions_empty": "Нет активных сеансов.",
"active_sessions_count": "{count} активных",
"active_session_ip": "IP-адрес",
"active_session_user_agent": "User-Agent",
"active_session_connected": "Подключён",
"database_health": "Состояние базы данных",
"database_health_description": "Быстрая проверка, настройка WAL и инструменты восстановления базы данных MeshChatX.",
"running_checks": "Выполнение проверок...",
@ -3828,7 +3843,7 @@
"description": "Keep a folder in sync with people on your mesh. Share your sync ID, connect devices, and files move both ways when allowed.",
"usage_steps": "Quick start",
"step_1": "1. Choose a folder, press Start, then share your sync ID with a friend.",
"step_2": "2. On Devices, paste their sync ID to connect. Files appear under Files as they sync.",
"step_2": "2. На вкладке Устройства вставьте их ID синхронизации для подключения. Управляйте локальными файлами в разделе Файлы или загружайте с Удалённых.",
"step_3": "3. Under Sharing, decide who can read, write, or delete when access control is on.",
"tab_folder": "Folder",
"tab_devices": "Devices",
@ -3906,6 +3921,17 @@
"file_updated": "A file changed",
"file_deleted": "A file was removed",
"copied": "Copied to clipboard",
"error": "File Sync error"
"error": "File Sync error",
"manager_help": "Просматривайте и управляйте файлами в папке синхронизации. Загрузки и удаления остаются только в этой папке.",
"manager_root": "(корень папки синхронизации)",
"manager_loading": "Загрузка файлов...",
"manager_empty": "Эта папка пуста. Загрузите файл или создайте подпапку.",
"upload": "Загрузить",
"upload_done": "Файл загружен",
"download_local": "Скачать",
"download_local_done": "Скачивание начато",
"delete": "Удалить",
"delete_confirm": "Удалить «{name}» из папки синхронизации?",
"delete_done": "Удалено"
}
}

View file

@ -164,6 +164,9 @@
"privacy_subsection_telemetry": "Mesh 遥测",
"privacy_mode_enabled": "隐私模式(阻止外部 HTTP/HTTPS",
"privacy_mode_description": "阻止地图瓦片、地理编码、LibreTranslate、固件下载以及应用的其他出站 HTTP/HTTPS 连接。浏览器内容安全策略收紧为仅同源。",
"multi_session_warning_enabled": "多个会话连接时发出警告",
"multi_session_warning_description": "当两个或更多浏览器或设备同时连接到此 MeshChatX 实例时显示警告提示。",
"multi_session_warning": "多个活动会话({count})。另一个浏览器或设备已连接到此 MeshChatX 实例。",
"screen_security_enabled": "屏幕安全(阻止截录)",
"screen_security_description": "使用 Windows 显示亲和性标志,使 MeshChatX 不出现在截图、录屏和 Windows Recall 中。窗口在显示器上仍正常显示。",
"screen_security_description_short": "对此窗口隐藏截图、录屏和 Windows Recall。",
@ -507,7 +510,12 @@
"propagation_static_peers_bypass_sequential": "Static Peers Bypass Sequential Validation",
"propagation_static_peers_bypass_sequential_description": "Allow configured static peers to sync immediately even when another stamp batch is already validating.",
"propagation_max_inbound_syncs": "Max Concurrent Inbound Syncs",
"propagation_max_inbound_syncs_description": "How many concurrent inbound propagation sync transfers to accept. Extra offers are throttled. Range: 1-64. Default: 3."
"propagation_max_inbound_syncs_description": "How many concurrent inbound propagation sync transfers to accept. Extra offers are throttled. Range: 1-64. Default: 3.",
"cancel_inbound_deliveries": "取消传入传输",
"cancel_inbound_deliveries_count": "取消传入({count}",
"cancel_inbound_confirm": "取消 {count} 个传入 LXMF 传输?正在下载的大消息将停止。",
"cancel_inbound_done": "已取消 {count} 个传入传输。",
"cancel_inbound_failed": "无法取消传入投递"
},
"common": {
"open": "打开",
@ -1033,6 +1041,13 @@
"config_path": "配置路径",
"database_path": "数据库路径",
"database_size": "数据库大小",
"active_sessions": "活动会话",
"active_sessions_description": "当前通过 UI WebSocket 连接到此 MeshChatX 实例的浏览器和设备。",
"active_sessions_empty": "没有活动会话。",
"active_sessions_count": "{count} 个活动",
"active_session_ip": "IP 地址",
"active_session_user_agent": "用户代理",
"active_session_connected": "已连接",
"database_health": "数据库健康",
"database_health_description": "MeshChatX 数据库的缓存、WAL 调优和恢复工具。",
"running_checks": "正在检查...",
@ -3828,7 +3843,7 @@
"description": "Keep a folder in sync with people on your mesh. Share your sync ID, connect devices, and files move both ways when allowed.",
"usage_steps": "Quick start",
"step_1": "1. Choose a folder, press Start, then share your sync ID with a friend.",
"step_2": "2. On Devices, paste their sync ID to connect. Files appear under Files as they sync.",
"step_2": "2. 在“设备”中粘贴对方的同步 ID 以连接。在“文件”中管理本地文件,或从“远程”拉取。",
"step_3": "3. Under Sharing, decide who can read, write, or delete when access control is on.",
"tab_folder": "Folder",
"tab_devices": "Devices",
@ -3906,6 +3921,17 @@
"file_updated": "A file changed",
"file_deleted": "A file was removed",
"copied": "Copied to clipboard",
"error": "File Sync error"
"error": "File Sync error",
"manager_help": "浏览并管理同步文件夹中的文件。上传和删除仅限于此文件夹。",
"manager_root": "(同步文件夹根目录)",
"manager_loading": "正在加载文件…",
"manager_empty": "此文件夹为空。请上传文件或创建子文件夹。",
"upload": "上传",
"upload_done": "文件已上传",
"download_local": "下载",
"download_local_done": "下载已开始",
"delete": "删除",
"delete_confirm": "从同步文件夹删除“{name}”?",
"delete_done": "已删除"
}
}

View file

@ -105,6 +105,7 @@ WebSocket lxmf_message event on recipient UI
- Enable **auto-announce** so your `lxmf.delivery` aspect stays visible on the mesh.
- Check **Interfaces** if messages stall. No path to the peer means LXMF cannot deliver.
- Review stamp settings before joining busy public meshes.
- While a large message is downloading, the header shows **Cancel incoming**. That stops active LXMF delivery resource transfers (`cancel_all_inbound` / per-resource cancel). Outbound send cancel stays on each message menu.
## See also

View file

@ -69,37 +69,37 @@
"packageManager": "pnpm@11.1.2",
"devDependencies": {
"@electron/fuses": "^1.8.0",
"@eslint/js": "^9.39.4",
"@eslint/js": "^9.39.5",
"@playwright/test": "^1.61.1",
"@tailwindcss/vite": "^4.2.4",
"@vitejs/plugin-vue": "^6.0.7",
"@vitest/coverage-v8": "^4.1.5",
"@vitest/ui": "^4.1.9",
"@tailwindcss/vite": "^4.3.3",
"@vitejs/plugin-vue": "^6.0.8",
"@vitest/coverage-v8": "^4.1.10",
"@vitest/ui": "^4.1.10",
"@vue/test-utils": "^2.4.11",
"cross-env": "^10.1.0",
"dayjs": "^1.11.21",
"electron": "42.4.0",
"electron-builder": "^26.15.3",
"electron-builder-squirrel-windows": "^26.15.3",
"eslint": "^9.39.4",
"eslint": "^9.39.5",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-prettier": "^5.5.6",
"eslint-plugin-security": "^3.0.1",
"eslint-plugin-vue": "^10.9.2",
"eslint-plugin-vue": "^10.10.0",
"fake-indexeddb": "^6.2.5",
"globals": "^17.6.0",
"globals": "^17.7.0",
"jsdom": "^29.1.1",
"knip": "^6.24.0",
"knip": "^6.29.0",
"micron-parser": "github:RFnexus/micron-parser-js#33feb1054c8b2cb3f5f05abbb8903360d3f0c098",
"prettier": "^3.9.3",
"tailwindcss": "^4.3.0",
"terser": "^5.48.0",
"prettier": "^3.9.6",
"tailwindcss": "^4.3.3",
"terser": "^5.49.0",
"typescript": "^6.0.3",
"vite": "^8.0.16",
"vite": "^8.1.5",
"vite-plugin-vuetify": "^2.1.3",
"vitest": "^4.1.5",
"vitest": "^4.1.10",
"vue-eslint-parser": "^10.4.1",
"vue-tsc": "^3.3.6"
"vue-tsc": "^3.3.7"
},
"build": {
"appId": "com.quad4.meshchatx",
@ -285,27 +285,27 @@
}
},
"dependencies": {
"@fontsource/noto-sans": "^5.2.10",
"@fontsource/noto-sans": "^5.3.0",
"@mdi/font": "^7.4.47",
"@mdi/js": "^7.4.47",
"@tailwindcss/forms": "^0.5.11",
"@tanstack/vue-virtual": "^3.13.30",
"@tanstack/vue-virtual": "^3.13.34",
"compressorjs": "^1.3.0",
"dompurify": ">=3.4.11",
"dompurify": "^3.4.12",
"electron-prompt": "^1.7.0",
"emoji-picker-element": "^1.29.1",
"emoji-picker-element-data": "^1.8.0",
"jsqr": "^1.4.0",
"jszip": "^3.10.1",
"marked": "^18.0.5",
"marked": "^18.0.7",
"ol": "^10.9.0",
"ol-mapbox-style": "^13.4.1",
"qrcode": "^1.5.4",
"vis-data": "^7.1.10",
"vis-network": "^9.1.13",
"vue": "^3.5.39",
"vue-i18n": "^11.4.6",
"vue": "^3.5.40",
"vue-i18n": "^11.4.7",
"vue-router": "^4.6.4",
"vuetify": "^3.12.8"
"vuetify": "^3.12.10"
}
}

4042
pnpm-lock.yaml generated

File diff suppressed because it is too large Load diff

View file

@ -54,7 +54,7 @@ overrides:
flatted: ">=3.4.2"
tar: ">=7.5.16"
tmp: ">=0.2.7"
undici: ">=7.28.0"
undici: "7.28.0"
webpack: ">=5.104.0"
y18n: ">=5.0.5"
yargs-parser: ">=18.1.1"

View file

@ -33,6 +33,9 @@ _MUTATING_SAMPLES = (
("POST", "/api/v1/filesync/download", {"peer_id": "bb" * 16, "path": "a.txt"}),
("POST", "/api/v1/filesync/acl", {"enforce": False}),
("PATCH", "/api/v1/filesync/settings", {"monitor": True}),
("POST", "/api/v1/filesync/mkdir", {"path": "folder"}),
("DELETE", "/api/v1/filesync/entry", {"path": "a.txt"}),
("POST", "/api/v1/lxmf/propagation-node/cancel-inbound", {}),
)
@ -71,6 +74,19 @@ def _stub_filesync_handler(mock_app):
"announce_interval": 300,
"running": False,
}
handler.manager_mkdir.return_value = {"ok": True, "path": "folder"}
handler.manager_delete.return_value = {"ok": True, "path": "a.txt"}
handler.manager_upload.return_value = {"ok": True, "path": "a.txt", "size": 1}
handler.list_tree.return_value = {
"ok": True,
"current": "",
"parent": None,
"entries": [],
}
handler.manager_content.return_value = {
"ok": False,
"error": "path not allowed",
}
@pytest.mark.asyncio

View file

@ -28,6 +28,10 @@
"method": "GET",
"path": "/api/v1/app/info"
},
{
"method": "GET",
"path": "/api/v1/app/sessions"
},
{
"method": "POST",
"path": "/api/v1/app/integrity/acknowledge"
@ -320,6 +324,10 @@
"method": "POST",
"path": "/api/v1/filesync/connect"
},
{
"method": "GET",
"path": "/api/v1/filesync/content"
},
{
"method": "GET",
"path": "/api/v1/filesync/directories"
@ -336,10 +344,18 @@
"method": "POST",
"path": "/api/v1/filesync/download"
},
{
"method": "DELETE",
"path": "/api/v1/filesync/entry"
},
{
"method": "GET",
"path": "/api/v1/filesync/files"
},
{
"method": "POST",
"path": "/api/v1/filesync/mkdir"
},
{
"method": "GET",
"path": "/api/v1/filesync/peers"
@ -360,6 +376,14 @@
"method": "POST",
"path": "/api/v1/filesync/stop"
},
{
"method": "GET",
"path": "/api/v1/filesync/tree"
},
{
"method": "POST",
"path": "/api/v1/filesync/upload"
},
{
"method": "GET",
"path": "/api/v1/gifs"

View file

@ -89,6 +89,7 @@ from tests.backend.http_api_response_schemas import (
FILESYNC_FILES_SCHEMA,
FILESYNC_PEERS_SCHEMA,
FILESYNC_STATUS_SCHEMA,
FILESYNC_TREE_SCHEMA,
RNPATH_RATES_SCHEMA,
RNPATH_TABLE_SCHEMA,
RNPATH_TRACE_SCHEMA,
@ -491,6 +492,13 @@ HTTP_JSON_GET_CONTRACTS: tuple[HttpJsonContract, ...] = (
allow_statuses=(200, 400, 503),
alt_schemas=(MESSAGE_ENVELOPE_SCHEMA,),
),
HttpJsonContract(
"GET",
"/api/v1/filesync/tree",
FILESYNC_TREE_SCHEMA,
allow_statuses=(200, 400, 503),
alt_schemas=(MESSAGE_ENVELOPE_SCHEMA,),
),
HttpJsonContract(
"GET",
"/api/v1/filesync/acl",
@ -613,6 +621,7 @@ HTTP_JSON_GET_CONTRACT_EXCLUDED: tuple[str, ...] = (
"/api/v1/tools/rnode/download_firmware",
"/api/v1/tools/rnode/latest_release",
"/api/v1/tools/micron-parser-go-release",
"/api/v1/filesync/content",
"/api/v1/favourites/layout",
"/api/v1/map/overlays",
"/api/v1/map/overlays/jobs/{job_id}",

View file

@ -581,6 +581,19 @@ FILESYNC_DIRECTORIES_SCHEMA: dict = {
"additionalProperties": True,
}
FILESYNC_TREE_SCHEMA: dict = {
"type": "object",
"required": ["ok", "root", "current", "entries"],
"properties": {
"ok": _BOOLEAN,
"root": _STRING,
"current": _STRING,
"parent": {"type": ["string", "null"]},
"entries": _ARRAY,
},
"additionalProperties": True,
}
FILESYNC_ACL_SCHEMA: dict = {
"type": "object",
"required": ["enforce", "rules"],

View file

@ -0,0 +1,304 @@
# SPDX-License-Identifier: 0BSD
"""Active UI WebSocket session tracking and multi-session warning oracles."""
from __future__ import annotations
import json
import shutil
import tempfile
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
import RNS
from hypothesis import given, settings
from hypothesis import strategies as st
from meshchatx.src.backend.active_sessions import (
ActiveSessionTracker,
should_warn_multi_session,
)
@pytest.fixture
def temp_dir():
dir_path = tempfile.mkdtemp()
yield dir_path
shutil.rmtree(dir_path)
@pytest.fixture
def mock_rns_minimal():
with (
patch("RNS.Reticulum") as mock_rns,
patch("RNS.Transport"),
patch("LXMF.LXMRouter"),
patch("meshchatx.meshchat.get_file_path", return_value="/tmp/mock_path"),
):
mock_rns_instance = mock_rns.return_value
mock_rns_instance.configpath = "/tmp/mock_config"
mock_rns_instance.is_connected_to_shared_instance = False
mock_rns_instance.transport_enabled.return_value = True
mock_id = MagicMock(spec=RNS.Identity)
mock_id.hash = b"test_hash_32_bytes_long_01234567"
mock_id.hexhash = mock_id.hash.hex()
mock_id.get_private_key.return_value = b"test_private_key"
yield mock_id
def test_should_warn_multi_session_oracle_edge_cases():
assert should_warn_multi_session(0, True) is False
assert should_warn_multi_session(1, True) is False
assert should_warn_multi_session(2, True) is True
assert should_warn_multi_session(3, True) is True
assert should_warn_multi_session(2, False) is False
assert should_warn_multi_session(99, False) is False
assert should_warn_multi_session("2", True) is True
assert should_warn_multi_session("nope", True) is False
assert should_warn_multi_session(None, True) is False
@given(
count=st.integers(min_value=-5, max_value=50),
enabled=st.booleans(),
)
@settings(max_examples=80, deadline=None)
def test_should_warn_multi_session_matches_count_threshold(count, enabled):
expected = bool(enabled) and int(count) >= 2
assert should_warn_multi_session(count, enabled) is expected
def test_tracker_add_list_remove_round_trip():
tracker = ActiveSessionTracker()
assert tracker.count() == 0
assert tracker.list_sessions() == []
first = tracker.add(ip="127.0.0.1", user_agent="Browser/A")
second = tracker.add(ip="10.0.0.2", user_agent="Browser/B")
assert first["id"] != second["id"]
assert tracker.count() == 2
rows = tracker.list_sessions()
assert len(rows) == 2
assert rows[0]["ip"] == "127.0.0.1"
assert rows[0]["user_agent"] == "Browser/A"
assert rows[1]["ip"] == "10.0.0.2"
assert isinstance(rows[0]["connected_at"], float)
assert tracker.remove(first["id"]) is True
assert tracker.count() == 1
assert tracker.remove(first["id"]) is False
assert tracker.remove("") is False
assert tracker.remove(None) is False
snap = tracker.snapshot()
assert snap["count"] == 1
assert snap["sessions"][0]["id"] == second["id"]
def test_tracker_sanitizes_ip_and_user_agent():
tracker = ActiveSessionTracker()
entry = tracker.add(ip=None, user_agent="\x00\x01bad\x7f agent")
assert entry["ip"] == "unknown"
assert "\x00" not in entry["user_agent"]
assert "bad" in entry["user_agent"]
long_ua = "x" * 2000
long_ip = "y" * 200
entry2 = tracker.add(ip=long_ip, user_agent=long_ua)
assert len(entry2["ip"]) <= 128
assert len(entry2["user_agent"]) <= 512
def test_tracker_empty_user_agent_becomes_unknown():
tracker = ActiveSessionTracker()
entry = tracker.add(ip="192.168.1.1", user_agent=" ")
assert entry["user_agent"] == "unknown"
@pytest.mark.asyncio
async def test_app_sessions_endpoint_smoke(mock_rns_minimal, temp_dir):
from meshchatx.meshchat import ReticulumMeshChat
with patch("meshchatx.meshchat.generate_ssl_certificate"):
app = ReticulumMeshChat(
identity=mock_rns_minimal,
storage_dir=temp_dir,
reticulum_config_dir=temp_dir,
)
handler = None
for route in app.get_routes():
if route.path == "/api/v1/app/sessions" and route.method == "GET":
handler = route.handler
break
assert handler is not None
response = await handler(MagicMock())
data = json.loads(response.body)
assert data["count"] == 0
assert data["sessions"] == []
assert data["warning"] is False
assert data["warning_enabled"] is True
first = app.active_sessions.add(ip="127.0.0.1", user_agent="A/1")
app.active_sessions.add(ip="10.0.0.5", user_agent="B/2")
response = await handler(MagicMock())
data = json.loads(response.body)
assert data["count"] == 2
assert data["warning"] is True
assert {row["ip"] for row in data["sessions"]} == {"127.0.0.1", "10.0.0.5"}
assert {row["user_agent"] for row in data["sessions"]} == {"A/1", "B/2"}
assert any(row["id"] == first["id"] for row in data["sessions"])
@pytest.mark.asyncio
async def test_multi_session_warning_setting_in_config_and_payload(
mock_rns_minimal,
temp_dir,
):
from meshchatx.meshchat import ReticulumMeshChat
with patch("meshchatx.meshchat.generate_ssl_certificate"):
app = ReticulumMeshChat(
identity=mock_rns_minimal,
storage_dir=temp_dir,
reticulum_config_dir=temp_dir,
)
assert app.config.multi_session_warning_enabled.get() is True
assert app.get_config_dict()["multi_session_warning_enabled"] is True
app.active_sessions.add(ip="1.1.1.1", user_agent="one")
app.active_sessions.add(ip="2.2.2.2", user_agent="two")
payload = app.get_active_sessions_payload()
assert payload["warning"] is True
app.config.multi_session_warning_enabled.set(False)
payload = app.get_active_sessions_payload()
assert payload["warning_enabled"] is False
assert payload["warning"] is False
app.config.multi_session_warning_enabled.set(True)
with (
patch.object(app, "send_config_to_websocket_clients", new_callable=AsyncMock),
patch.object(
app,
"send_active_sessions_to_websocket_clients",
new_callable=AsyncMock,
) as sessions_broadcast,
):
await app.update_config({"multi_session_warning_enabled": False})
assert app.config.multi_session_warning_enabled.get() is False
sessions_broadcast.assert_awaited()
@pytest.mark.asyncio
async def test_detach_active_session_and_broadcast_payload(mock_rns_minimal, temp_dir):
from meshchatx.meshchat import ReticulumMeshChat
with patch("meshchatx.meshchat.generate_ssl_certificate"):
app = ReticulumMeshChat(
identity=mock_rns_minimal,
storage_dir=temp_dir,
reticulum_config_dir=temp_dir,
)
entry = app.active_sessions.add(ip="::1", user_agent="UA")
client = MagicMock()
client._meshchatx_session_id = entry["id"]
assert app._detach_active_session(client) is True
assert app.active_sessions.count() == 0
assert not hasattr(client, "_meshchatx_session_id")
assert app._detach_active_session(client) is False
sent = []
async def capture(data):
sent.append(json.loads(data))
app.websocket_broadcast = capture
app.active_sessions.add(ip="8.8.8.8", user_agent="Chrome")
app.active_sessions.add(ip="9.9.9.9", user_agent="Firefox")
await app.send_active_sessions_to_websocket_clients()
assert len(sent) == 1
assert sent[0]["type"] == "app.sessions.updated"
assert sent[0]["count"] == 2
assert sent[0]["warning"] is True
@pytest.mark.asyncio
async def test_websocket_broadcast_detaches_dead_session(mock_rns_minimal, temp_dir):
from meshchatx.meshchat import ReticulumMeshChat
with patch("meshchatx.meshchat.generate_ssl_certificate"):
app = ReticulumMeshChat(
identity=mock_rns_minimal,
storage_dir=temp_dir,
reticulum_config_dir=temp_dir,
)
live = MagicMock()
live.send_str = AsyncMock()
live.close = AsyncMock()
dead = MagicMock()
dead.send_str = AsyncMock(side_effect=RuntimeError("gone"))
dead.close = AsyncMock()
dead_entry = app.active_sessions.add(ip="10.0.0.9", user_agent="Dead/1")
dead._meshchatx_session_id = dead_entry["id"]
live_entry = app.active_sessions.add(ip="10.0.0.8", user_agent="Live/1")
live._meshchatx_session_id = live_entry["id"]
app.websocket_clients = [live, dead]
# Avoid recursive session broadcast while asserting detach bookkeeping.
app.send_active_sessions_to_websocket_clients = AsyncMock()
await app.websocket_broadcast('{"type":"ping"}')
assert dead not in app.websocket_clients
assert live in app.websocket_clients
assert app.active_sessions.count() == 1
assert app.active_sessions.list_sessions()[0]["id"] == live_entry["id"]
app.send_active_sessions_to_websocket_clients.assert_awaited()
@pytest.mark.asyncio
async def test_connect_disconnect_session_lifecycle_unit(mock_rns_minimal, temp_dir):
"""Simulate the connect and disconnect bookkeeping without a live aiohttp WS."""
from meshchatx.meshchat import ReticulumMeshChat
with patch("meshchatx.meshchat.generate_ssl_certificate"):
app = ReticulumMeshChat(
identity=mock_rns_minimal,
storage_dir=temp_dir,
reticulum_config_dir=temp_dir,
)
broadcasts = []
async def capture(data):
broadcasts.append(json.loads(data))
app.websocket_broadcast = capture
clients = []
for ip, ua in (("127.0.0.1", "One"), ("127.0.0.1", "Two")):
client = MagicMock()
session = app.active_sessions.add(ip=ip, user_agent=ua)
client._meshchatx_session_id = session["id"]
app.websocket_clients.append(client)
clients.append(client)
await app.send_active_sessions_to_websocket_clients()
assert app.active_sessions.count() == 2
assert broadcasts[-1]["warning"] is True
assert broadcasts[-1]["count"] == 2
client = clients.pop()
app.websocket_clients.remove(client)
app._detach_active_session(client)
await app.send_active_sessions_to_websocket_clients()
assert app.active_sessions.count() == 1
assert broadcasts[-1]["warning"] is False
assert broadcasts[-1]["count"] == 1

View file

@ -70,3 +70,41 @@ async def test_lxmf_cancel_endpoint_loads_updated_message_from_database(web_canc
web_cancel_app.database.messages.get_lxmf_message_by_hash.assert_called_with(
message_hash,
)
@pytest.mark.asyncio
async def test_lxmf_cancel_inbound_all_endpoint(web_cancel_app):
web_cancel_app.message_router.cancel_all_inbound.return_value = 3
web_cancel_app.message_router.inbound_resources.return_value = []
aio_app = _build_aio_app(web_cancel_app)
async with TestClient(TestServer(aio_app)) as client:
response = await client.post(
"/api/v1/lxmf/propagation-node/cancel-inbound", json={}
)
assert response.status == 200
body = await response.json()
assert body["cancelled"] == 3
assert body["inbound_delivery_count"] == 0
web_cancel_app.message_router.cancel_all_inbound.assert_called_once_with()
@pytest.mark.asyncio
async def test_lxmf_cancel_inbound_one_endpoint(web_cancel_app):
resource_hash = "ee" * 16
web_cancel_app.message_router.cancel_inbound.return_value = True
web_cancel_app.message_router.inbound_resources.return_value = []
aio_app = _build_aio_app(web_cancel_app)
async with TestClient(TestServer(aio_app)) as client:
response = await client.post(
"/api/v1/lxmf/propagation-node/cancel-inbound",
json={"resource_hash": resource_hash},
)
assert response.status == 200
body = await response.json()
assert body["cancelled"] == 1
assert body["resource_hash"] == resource_hash
web_cancel_app.message_router.cancel_inbound.assert_called_once_with(
bytes.fromhex(resource_hash),
)

View file

@ -0,0 +1,66 @@
# SPDX-License-Identifier: 0BSD
"""Inbound LXMF delivery cancel helpers (LXMF 1.1 / RNS 1.4)."""
from __future__ import annotations
from types import SimpleNamespace
from unittest.mock import MagicMock
from meshchatx.src.backend.meshchat_utils import (
cancel_inbound_deliveries,
list_inbound_deliveries,
)
def test_list_inbound_deliveries_empty_without_router():
assert list_inbound_deliveries(None) == []
def test_list_inbound_deliveries_serializes_active_resources():
resource = MagicMock()
resource.hash = bytes.fromhex("ab" * 16)
resource.get_data_size.return_value = 1024
resource.get_transfer_size.return_value = 1100
resource.get_progress.return_value = 0.5
router = MagicMock()
router.inbound_resources.return_value = [resource]
items = list_inbound_deliveries(router)
assert len(items) == 1
assert items[0]["hash"] == "ab" * 16
assert items[0]["size_bytes"] == 1024
assert items[0]["progress"] == 50.0
def test_cancel_all_inbound_deliveries():
router = MagicMock()
router.cancel_all_inbound.return_value = 2
result = cancel_inbound_deliveries(router)
assert result["ok"] is True
assert result["cancelled"] == 2
router.cancel_all_inbound.assert_called_once_with()
def test_cancel_one_inbound_delivery():
router = MagicMock()
router.cancel_inbound.return_value = True
result = cancel_inbound_deliveries(router, resource_hash="cd" * 16)
assert result["ok"] is True
assert result["cancelled"] == 1
router.cancel_inbound.assert_called_once_with(bytes.fromhex("cd" * 16))
def test_cancel_inbound_rejects_bad_hash():
router = MagicMock()
result = cancel_inbound_deliveries(router, resource_hash="not-hex")
assert result["ok"] is False
assert result["cancelled"] == 0
router.cancel_inbound.assert_not_called()
def test_cancel_inbound_unavailable_without_api():
router = SimpleNamespace()
result = cancel_inbound_deliveries(router)
assert result["ok"] is False
assert "unavailable" in result["error"]

View file

@ -63,6 +63,37 @@ def test_filesync_rejects_identity_root_and_reserved(tmp_path):
assert ok.endswith("filesync/custom") or ok.endswith("filesync\\custom")
def test_filesync_manager_resolve_never_leaves_sync_root(tmp_path):
storage = tmp_path / "id"
storage.mkdir()
(storage / "identity").mkdir()
outside = tmp_path / "OUTSIDE"
outside.mkdir()
(outside / "secret.txt").write_text("x", encoding="utf-8")
handler = RnsFilesyncHandler(
MagicMock(), SimpleNamespace(hash=b"\x22" * 16), str(storage)
)
sync_root = handler._sync_root()
payloads = [
"../identity",
"../../OUTSIDE/secret.txt",
str(outside / "secret.txt"),
str(storage / "identity"),
"/etc/passwd",
"ok/inside.txt",
]
for payload in payloads:
abspath, err = handler._resolve_manager_path(payload, allow_root=False)
if abspath is not None:
assert abspath == sync_root or abspath.startswith(sync_root + os.sep)
assert err is None
else:
assert err is not None
root_abs, root_err = handler._resolve_manager_path("", allow_root=True)
assert root_err is None
assert root_abs == sync_root
def test_restore_rejects_bad_backup_without_wiping_live_db(tmp_path):
db_path = tmp_path / "main.db"
db = Database(str(db_path))

View file

@ -171,3 +171,83 @@ def test_settings_reject_sync_dir_change_while_running(handler):
result = handler.update_settings(sync_directory="/tmp/other")
assert result["ok"] is False
assert "stop filesync" in result["error"]
def test_list_tree_while_stopped(handler):
sync = handler._sync_directory
nested = os.path.join(sync, "docs")
os.makedirs(nested, exist_ok=True)
with open(os.path.join(sync, "hello.txt"), "w", encoding="utf-8") as handle:
handle.write("hi")
with open(os.path.join(nested, "note.md"), "w", encoding="utf-8") as handle:
handle.write("note")
root = handler.list_tree()
assert root["ok"] is True
assert root["current"] == ""
names = {entry["name"] for entry in root["entries"]}
assert "hello.txt" in names
assert "docs" in names
assert handler.service is None
nested_list = handler.list_tree("docs")
assert nested_list["ok"] is True
assert nested_list["current"] == "docs"
assert any(e["name"] == "note.md" for e in nested_list["entries"])
def test_manager_upload_mkdir_delete_roundtrip(handler):
mk = handler.manager_mkdir("photos")
assert mk["ok"] is True
assert mk["path"] == "photos"
uploaded = handler.manager_upload(
filename="shot.jpg",
data=b"jpeg-bytes",
subdir="photos",
)
assert uploaded["ok"] is True
assert uploaded["path"] == "photos/shot.jpg"
assert uploaded["size"] == len(b"jpeg-bytes")
tree = handler.list_tree("photos")
assert any(e["name"] == "shot.jpg" for e in tree["entries"])
content = handler.manager_content("photos/shot.jpg")
assert content["ok"] is True
assert content["filename"] == "shot.jpg"
with open(content["abspath"], "rb") as handle:
assert handle.read() == b"jpeg-bytes"
deleted = handler.manager_delete("photos/shot.jpg")
assert deleted["ok"] is True
assert not os.path.exists(
os.path.join(handler._sync_directory, "photos", "shot.jpg")
)
empty = handler.manager_delete("photos")
assert empty["ok"] is True
def test_manager_delete_refuses_nonempty_dir(handler):
handler.manager_mkdir("keep")
handler.manager_upload(filename="a.txt", data=b"x", subdir="keep")
result = handler.manager_delete("keep")
assert result["ok"] is False
assert "not empty" in result["error"]
assert os.path.isdir(os.path.join(handler._sync_directory, "keep"))
def test_manager_skips_dotfiles_in_tree(handler):
sync = handler._sync_directory
with open(os.path.join(sync, ".secret"), "w", encoding="utf-8") as handle:
handle.write("nope")
with open(os.path.join(sync, ".rns-filesync.db"), "w", encoding="utf-8") as handle:
handle.write("{}")
with open(os.path.join(sync, "visible.txt"), "w", encoding="utf-8") as handle:
handle.write("yes")
tree = handler.list_tree()
names = {e["name"] for e in tree["entries"]}
assert "visible.txt" in names
assert ".secret" not in names
assert ".rns-filesync.db" not in names

View file

@ -13,7 +13,10 @@ import pytest
from hypothesis import HealthCheck, given, settings
from hypothesis import strategies as st
from meshchatx.src.backend.rns_filesync_handler import RnsFilesyncHandler
from meshchatx.src.backend.rns_filesync_handler import (
RnsFilesyncHandler,
_is_forbidden_entry_name,
)
from rns_filesync.paths import PathJailError, normalize_relpath
_TRAVERSAL_PAYLOADS = (
@ -378,3 +381,158 @@ def test_start_wires_callbacks_and_reuses_host_reticulum(mock_service_cls, handl
assert mock_service_cls.call_args.kwargs["own_reticulum"] is False
assert service.on_error is not None
assert service.on_sync_progress is not None
def test_manager_rejects_traversal_payloads(handler, tmp_path):
bait = tmp_path / "bait.txt"
bait.write_text("do-not-touch", encoding="utf-8")
identity_dir = os.path.join(handler.storage_dir, "identity")
os.makedirs(identity_dir, exist_ok=True)
secret = os.path.join(identity_dir, "secret.key")
with open(secret, "w", encoding="utf-8") as handle:
handle.write("private")
payloads = list(_TRAVERSAL_PAYLOADS) + [
str(bait),
secret,
os.path.join(handler.storage_dir, "identity"),
os.path.join(handler.storage_dir, "database.db"),
os.path.join(handler.storage_dir, "lxmf"),
handler.storage_dir,
]
for payload in payloads:
tree = handler.list_tree(payload if str(payload).strip() else None)
if not str(payload).strip():
assert tree["ok"] is True
continue
assert tree["ok"] is False, payload
assert handler.manager_content(str(payload))["ok"] is False
assert handler.manager_delete(str(payload))["ok"] is False
assert handler.manager_mkdir(str(payload))["ok"] is False
assert (
handler.manager_upload(
filename="x.txt",
data=b"x",
subdir=str(payload),
)["ok"]
is False
)
assert bait.read_text(encoding="utf-8") == "do-not-touch"
with open(secret, encoding="utf-8") as handle:
assert handle.read() == "private"
def test_manager_rejects_cross_identity_paths(tmp_path):
storage_a = tmp_path / "id_a"
storage_b = tmp_path / "id_b"
storage_a.mkdir()
storage_b.mkdir()
ha = RnsFilesyncHandler(
MagicMock(), SimpleNamespace(hash=b"\xaa" * 16), str(storage_a)
)
hb = RnsFilesyncHandler(
MagicMock(), SimpleNamespace(hash=b"\xbb" * 16), str(storage_b)
)
bait = os.path.join(hb._sync_directory, "peer_secret.txt")
with open(bait, "w", encoding="utf-8") as handle:
handle.write("b-only")
assert ha.list_tree(bait)["ok"] is False
assert ha.manager_content(bait)["ok"] is False
assert ha.manager_delete(bait)["ok"] is False
assert os.path.isfile(bait)
with open(bait, encoding="utf-8") as handle:
assert handle.read() == "b-only"
@pytest.mark.skipif(os.name == "nt", reason="symlink tests require POSIX")
def test_manager_rejects_symlink_escape(handler, tmp_path):
outside = tmp_path / "outside_secret.txt"
outside.write_text("escape-me", encoding="utf-8")
link_path = os.path.join(handler._sync_directory, "escape.txt")
os.symlink(str(outside), link_path)
assert handler.list_tree()["ok"] is True
names = {e["name"] for e in handler.list_tree()["entries"]}
assert "escape.txt" not in names
assert handler.manager_content("escape.txt")["ok"] is False
assert handler.manager_delete("escape.txt")["ok"] is False
assert (
handler.manager_upload(
filename="escape.txt",
data=b"overwrite",
subdir="",
)["ok"]
is False
)
assert outside.read_text(encoding="utf-8") == "escape-me"
def test_manager_upload_rejects_malicious_filenames(handler):
for name in (
".hidden",
".rns-filesync.db",
"",
".",
"..",
"x\x00y.txt",
):
result = handler.manager_upload(filename=name, data=b"x")
assert result["ok"] is False, name
# Path segments in the client filename are stripped to a basename under sync root.
escaped = handler.manager_upload(filename="../evil.txt", data=b"safe")
assert escaped["ok"] is True
assert escaped["path"] == "evil.txt"
assert os.path.isfile(os.path.join(handler._sync_directory, "evil.txt"))
assert not os.path.exists(os.path.join(handler.storage_dir, "evil.txt"))
def test_manager_refuses_delete_sync_root(handler):
assert handler.manager_delete("")["ok"] is False
assert handler.manager_delete(".")["ok"] is False
assert os.path.isdir(handler._sync_directory)
@settings(
max_examples=60,
deadline=None,
suppress_health_check=[HealthCheck.too_slow, HealthCheck.function_scoped_fixture],
)
@given(
path=st.one_of(
st.sampled_from(
list(_TRAVERSAL_PAYLOADS) + ["ok.txt", "dir/file.bin", "nested/a/b"]
),
st.text(min_size=0, max_size=40),
),
)
def test_manager_path_oracle(handler, path):
"""Oracle: manager resolve accepts only normalize_relpath-safe relative paths."""
cleaned = str(path or "").strip()
expect_ok = False
if cleaned and not os.path.isabs(cleaned) and not cleaned.startswith(("/", "\\")):
try:
safe = normalize_relpath(cleaned)
parts = safe.replace("\\", "/").split("/")
if not any(_is_forbidden_entry_name(part) for part in parts):
expect_ok = True
except PathJailError:
expect_ok = False
abspath, err = handler._resolve_manager_path(path, allow_root=False)
if expect_ok:
# Path may not exist yet. resolve without must_exist should succeed.
assert err is None, path
assert abspath is not None
assert abspath.startswith(handler._sync_root() + os.sep)
else:
if cleaned == "":
assert err == "path is required"
else:
assert abspath is None
assert err is not None

View file

@ -27,5 +27,8 @@ test.describe("Acceptance: Settings privacy", () => {
await expect(page.getByText("Privacy mode (block external HTTP/HTTPS)", { exact: true }).first()).toBeVisible({
timeout: 20000,
});
await expect(page.getByText("Warn when multiple sessions are connected", { exact: true }).first()).toBeVisible({
timeout: 20000,
});
});
});

View file

@ -13,4 +13,25 @@ test.describe("HTTP API (via Vite proxy)", () => {
expect(body).toHaveProperty("backups");
expect(Array.isArray(body.backups)).toBeTruthy();
});
test("active sessions list returns JSON with warning fields", async ({ request }) => {
const res = await request.get("/api/v1/app/sessions");
expect(res.ok()).toBeTruthy();
const body = await res.json();
expect(body).toHaveProperty("count");
expect(body).toHaveProperty("sessions");
expect(body).toHaveProperty("warning");
expect(body).toHaveProperty("warning_enabled");
expect(Array.isArray(body.sessions)).toBeTruthy();
expect(body.count).toBeGreaterThanOrEqual(0);
expect(body.warning).toBe(body.warning_enabled && body.count >= 2);
});
test("config exposes multi_session_warning_enabled", async ({ request }) => {
const res = await request.get("/api/v1/config");
expect(res.ok()).toBeTruthy();
const body = await res.json();
expect(body.config).toHaveProperty("multi_session_warning_enabled");
expect(typeof body.config.multi_session_warning_enabled).toBe("boolean");
});
});

View file

@ -71,6 +71,22 @@ test.describe("MeshChatX E2E (Vite + Python backend)", () => {
expect(String(body.app_info.version).length).toBeGreaterThan(0);
});
test("backend /api/v1/app/sessions returns active session list", async ({ request }) => {
const res = await request.get(`${E2E_BACKEND_ORIGIN}/api/v1/app/sessions`);
expect(res.ok()).toBeTruthy();
const body = await res.json();
expect(typeof body.count).toBe("number");
expect(Array.isArray(body.sessions)).toBeTruthy();
expect(typeof body.warning).toBe("boolean");
expect(typeof body.warning_enabled).toBe("boolean");
for (const session of body.sessions) {
expect(session).toHaveProperty("id");
expect(session).toHaveProperty("ip");
expect(session).toHaveProperty("user_agent");
expect(session).toHaveProperty("connected_at");
}
});
test("document title, shell, and app name in header", async ({ page }) => {
await page.goto("/");
await expect(page).toHaveTitle(/Reticulum MeshChatX/);
@ -87,6 +103,9 @@ test.describe("MeshChatX E2E (Vite + Python backend)", () => {
await expect(page).toHaveURL(/#\/about/);
await expect(page.getByText("MeshChatX", { exact: true }).first()).toBeVisible({ timeout: 30000 });
await expect(page.locator("#app")).toBeVisible();
await expect(page.getByText("Active sessions", { exact: true }).first()).toBeVisible({
timeout: 30000,
});
});
test("settings route loads profile section", async ({ page }) => {

View file

@ -4,6 +4,7 @@ import AboutPage from "@/components/about/AboutPage.vue";
import ElectronUtils from "@/js/ElectronUtils";
import DialogUtils from "@/js/DialogUtils";
import ToastUtils from "@/js/ToastUtils";
import { dispatchWsEvent } from "@/js/registries/wsEventRegistry.js";
vi.mock("@/js/ToastUtils", () => ({
default: {
@ -111,12 +112,13 @@ describe("AboutPage.vue", () => {
await wrapper.vm.$nextTick();
expect(axiosMock.get).toHaveBeenCalledWith("/api/v1/app/info");
expect(axiosMock.get).toHaveBeenCalledWith("/api/v1/config");
expect(axiosMock.get).toHaveBeenCalledWith("/api/v1/app/sessions");
expect(wrapper.text()).toContain("about.app_name");
expect(wrapper.text()).toContain("about.tagline_link");
expect(wrapper.text()).toContain("hash1");
expect(wrapper.text()).toContain("hash2");
expect(wrapper.text()).toContain("about.environment_information");
expect(wrapper.text()).toContain("/path/to/config");
expect(wrapper.text()).toContain("/path/to/db");
expect(wrapper.text()).toContain("about.dependency_chain");
expect(wrapper.text()).toContain("LXMFy");
@ -206,13 +208,13 @@ describe("AboutPage.vue", () => {
});
mountAboutPage();
expect(axiosMock.get).toHaveBeenCalledTimes(5); // info, config, health, snapshots, backups
expect(axiosMock.get).toHaveBeenCalledTimes(5); // info, sessions, health, snapshots, backups
vi.advanceTimersByTime(5000);
expect(axiosMock.get).toHaveBeenCalledTimes(6); // +1 from updateInterval
expect(axiosMock.get).toHaveBeenCalledTimes(7); // +info +sessions from updateInterval
vi.advanceTimersByTime(5000);
expect(axiosMock.get).toHaveBeenCalledTimes(7); // +2 from updateInterval
expect(axiosMock.get).toHaveBeenCalledTimes(9); // +info +sessions again
});
it("handles vacuum database action and shows success toast", async () => {
@ -426,99 +428,6 @@ describe("AboutPage.vue", () => {
expect(wrapper.text()).toContain("about.path_unknown");
});
it("shows landlock status on Linux when active", async () => {
axiosMock.get.mockImplementation((url) => {
if (url === "/api/v1/app/info")
return Promise.resolve({
data: {
app_info: {
version: "1.0.0",
host_platform: "linux",
landlock_requested: true,
landlock_active: true,
landlock_kernel_supported: true,
landlock_auto_enabled: true,
landlock_disabled_by_env: false,
},
},
});
if (url === "/api/v1/config") return Promise.resolve({ data: { config: {} } });
if (url === "/api/v1/database/health") return Promise.resolve({ data: { database: {} } });
if (url === "/api/v1/database/snapshots") return Promise.resolve({ data: [] });
return Promise.reject(new Error("Not found"));
});
const wrapper = mountAboutPage();
await vi.runOnlyPendingTimers();
await wrapper.vm.$nextTick();
await wrapper.vm.$nextTick();
expect(wrapper.text()).toContain("app.landlock_status");
expect(wrapper.text()).toContain("app.landlock_active");
expect(wrapper.text()).not.toContain("app.landlock_kernel_unsupported");
});
it("shows landlock inactive reason on Linux", async () => {
axiosMock.get.mockImplementation((url) => {
if (url === "/api/v1/app/info")
return Promise.resolve({
data: {
app_info: {
version: "1.0.0",
host_platform: "linux",
landlock_requested: false,
landlock_active: false,
landlock_kernel_supported: false,
landlock_auto_enabled: false,
landlock_disabled_by_env: false,
},
},
});
if (url === "/api/v1/config") return Promise.resolve({ data: { config: {} } });
if (url === "/api/v1/database/health") return Promise.resolve({ data: { database: {} } });
if (url === "/api/v1/database/snapshots") return Promise.resolve({ data: [] });
return Promise.reject(new Error("Not found"));
});
const wrapper = mountAboutPage();
await vi.runOnlyPendingTimers();
await wrapper.vm.$nextTick();
await wrapper.vm.$nextTick();
expect(wrapper.text()).toContain("app.landlock_inactive");
expect(wrapper.text()).toContain("app.landlock_kernel_unsupported");
});
it("hides landlock status on non-Linux platforms", async () => {
axiosMock.get.mockImplementation((url) => {
if (url === "/api/v1/app/info")
return Promise.resolve({
data: {
app_info: {
version: "1.0.0",
host_platform: "darwin",
landlock_requested: false,
landlock_active: false,
landlock_kernel_supported: false,
landlock_auto_enabled: false,
landlock_disabled_by_env: false,
},
},
});
if (url === "/api/v1/config") return Promise.resolve({ data: { config: {} } });
if (url === "/api/v1/database/health") return Promise.resolve({ data: { database: {} } });
if (url === "/api/v1/database/snapshots") return Promise.resolve({ data: [] });
return Promise.reject(new Error("Not found"));
});
const wrapper = mountAboutPage();
await vi.runOnlyPendingTimers();
await wrapper.vm.$nextTick();
await wrapper.vm.$nextTick();
expect(wrapper.text()).not.toContain("app.landlock_status");
});
it("shows MeshChatX usage insights from app info", async () => {
axiosMock.get.mockImplementation((url) => {
if (url === "/api/v1/app/info") {
@ -613,4 +522,81 @@ describe("AboutPage.vue", () => {
expect(wrapper.text()).toContain("about.battery_saver_measures");
expect(wrapper.vm.batterySaverActiveMeasures.length).toBeGreaterThan(0);
});
it("loads active sessions with IP and user agent and applies websocket updates", async () => {
axiosMock.get.mockImplementation((url) => {
if (url === "/api/v1/app/info") {
return Promise.resolve({ data: { app_info: { version: "1.0.0" } } });
}
if (url === "/api/v1/config") {
return Promise.resolve({ data: { config: {} } });
}
if (url === "/api/v1/app/sessions") {
return Promise.resolve({
data: {
count: 2,
sessions: [
{
id: "sess-a",
ip: "127.0.0.1",
user_agent: "Browser/A",
connected_at: 1700000000,
},
{
id: "sess-b",
ip: "10.0.0.2",
user_agent: "Browser/B",
connected_at: 1700000001,
},
],
warning: true,
warning_enabled: true,
},
});
}
if (url === "/api/v1/database/health") {
return Promise.resolve({ data: { database: {} } });
}
if (url === "/api/v1/database/snapshots") {
return Promise.resolve({ data: { snapshots: [], total: 0 } });
}
if (url === "/api/v1/database/backups") {
return Promise.resolve({ data: { backups: [], total: 0 } });
}
return Promise.reject(new Error("Not found"));
});
const wrapper = mountAboutPage();
await vi.runOnlyPendingTimers();
await wrapper.vm.$nextTick();
await wrapper.vm.$nextTick();
expect(axiosMock.get).toHaveBeenCalledWith("/api/v1/app/sessions");
expect(wrapper.text()).toContain("about.active_sessions");
expect(wrapper.text()).toContain("127.0.0.1");
expect(wrapper.text()).toContain("10.0.0.2");
expect(wrapper.text()).toContain("Browser/A");
expect(wrapper.text()).toContain("Browser/B");
expect(wrapper.vm.activeSessionCount).toBe(2);
await dispatchWsEvent("app.sessions.updated", {
count: 1,
sessions: [
{
id: "sess-a",
ip: "127.0.0.1",
user_agent: "Browser/A",
connected_at: 1700000000,
},
],
warning: false,
warning_enabled: true,
});
await wrapper.vm.$nextTick();
expect(wrapper.vm.activeSessionCount).toBe(1);
expect(wrapper.text()).toContain("Browser/A");
expect(wrapper.text()).not.toContain("Browser/B");
expect(wrapper.text()).not.toContain("10.0.0.2");
});
});

View file

@ -0,0 +1,76 @@
// SPDX-License-Identifier: 0BSD
import { describe, it, expect, vi, beforeEach } from "vitest";
import App from "../../meshchatx/src/frontend/components/App.vue";
import ToastUtils from "../../meshchatx/src/frontend/js/ToastUtils";
vi.mock("../../meshchatx/src/frontend/js/ToastUtils", () => ({
default: {
success: vi.fn(),
error: vi.fn(),
warning: vi.fn(),
loading: vi.fn(),
dismiss: vi.fn(),
},
}));
function makeContext(overrides = {}) {
return {
config: { multi_session_warning_enabled: true },
multiSessionWarningActive: false,
handleActiveSessionsUpdated: App.methods.handleActiveSessionsUpdated,
$t(key, params = {}) {
if (key === "app.multi_session_warning") {
return `multi ${params.count}`;
}
return key;
},
...overrides,
};
}
describe("App multi-session warning toast", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("toasts when two sessions connect and setting is enabled", () => {
const ctx = makeContext();
ctx.handleActiveSessionsUpdated({ count: 2, warning_enabled: true });
expect(ToastUtils.warning).toHaveBeenCalledWith("multi 2");
expect(ctx.multiSessionWarningActive).toBe(true);
});
it("does not toast again while still above the threshold", () => {
const ctx = makeContext({ multiSessionWarningActive: true });
ctx.handleActiveSessionsUpdated({ count: 3, warning_enabled: true });
expect(ToastUtils.warning).not.toHaveBeenCalled();
expect(ctx.multiSessionWarningActive).toBe(true);
});
it("does not toast when the setting is disabled", () => {
const ctx = makeContext({
config: { multi_session_warning_enabled: false },
});
ctx.handleActiveSessionsUpdated({ count: 2, warning_enabled: false });
expect(ToastUtils.warning).not.toHaveBeenCalled();
expect(ctx.multiSessionWarningActive).toBe(false);
});
it("resets and can toast again after dropping below two sessions", () => {
const ctx = makeContext({ multiSessionWarningActive: true });
ctx.handleActiveSessionsUpdated({ count: 1, warning_enabled: true });
expect(ctx.multiSessionWarningActive).toBe(false);
ctx.handleActiveSessionsUpdated({ count: 2, warning_enabled: true });
expect(ToastUtils.warning).toHaveBeenCalledTimes(1);
expect(ctx.multiSessionWarningActive).toBe(true);
});
it("uses config when warning_enabled is omitted from the payload", () => {
const ctx = makeContext({
config: { multi_session_warning_enabled: false },
});
ctx.handleActiveSessionsUpdated({ count: 2 });
expect(ToastUtils.warning).not.toHaveBeenCalled();
});
});

View file

@ -25,6 +25,18 @@ vi.mock("@/js/ElectronUtils", () => ({
},
}));
vi.mock("@/js/DialogUtils", () => ({
default: {
confirm: vi.fn().mockResolvedValue(true),
},
}));
vi.mock("@/js/DownloadUtils", () => ({
default: {
downloadFromApiResponse: vi.fn().mockResolvedValue(undefined),
},
}));
describe("RnsFilesyncPage.vue", () => {
let apiMock;
@ -33,6 +45,7 @@ describe("RnsFilesyncPage.vue", () => {
get: vi.fn(),
post: vi.fn(),
patch: vi.fn(),
delete: vi.fn(),
};
window.api = apiMock;
@ -53,6 +66,16 @@ describe("RnsFilesyncPage.vue", () => {
if (url === "/api/v1/filesync/peers") {
return Promise.resolve({ data: { peers: [] } });
}
if (url === "/api/v1/filesync/tree") {
return Promise.resolve({
data: {
ok: true,
current: "",
parent: null,
entries: [{ name: "hello.txt", path: "hello.txt", type: "file", size: 4 }],
},
});
}
if (url === "/api/v1/filesync/files") {
return Promise.resolve({ data: { files: [] } });
}
@ -74,6 +97,7 @@ describe("RnsFilesyncPage.vue", () => {
});
apiMock.post.mockResolvedValue({ data: { ok: true } });
apiMock.patch.mockResolvedValue({ data: { ok: true } });
apiMock.delete.mockResolvedValue({ data: { ok: true } });
});
afterEach(() => {
@ -101,6 +125,13 @@ describe("RnsFilesyncPage.vue", () => {
props: ["open", "initialPath"],
emits: ["close", "select"],
},
FilesyncFileManager: {
template: "<div class='file-manager-stub'></div>",
props: ["syncDirectory"],
methods: {
refresh: vi.fn().mockResolvedValue(undefined),
},
},
},
},
});
@ -112,6 +143,14 @@ describe("RnsFilesyncPage.vue", () => {
expect(wrapper.vm.syncDirectory).toBe("/tmp/sync");
});
it("files tab mounts file manager", async () => {
const wrapper = mountPage();
await vi.waitFor(() => expect(wrapper.vm.syncDirectory).toBe("/tmp/sync"));
wrapper.vm.activeTab = "files";
await wrapper.vm.$nextTick();
expect(wrapper.find(".file-manager-stub").exists()).toBe(true);
});
it("uses themed input-field classes", async () => {
const wrapper = mountPage();
await vi.waitFor(() => expect(wrapper.vm.syncDirectory).toBe("/tmp/sync"));
@ -259,3 +298,57 @@ describe("FilesyncDirectoryBrowserModal.vue", () => {
expect(wrapper.emitted("close")).toBeTruthy();
});
});
describe("FilesyncFileManager.vue", () => {
let apiMock;
beforeEach(() => {
apiMock = {
get: vi.fn().mockResolvedValue({
data: {
ok: true,
current: "",
parent: null,
entries: [
{ name: "docs", path: "docs", type: "dir" },
{ name: "a.txt", path: "a.txt", type: "file", size: 3 },
],
},
}),
post: vi.fn().mockResolvedValue({ data: { ok: true, path: "a.txt" } }),
delete: vi.fn().mockResolvedValue({ data: { ok: true } }),
};
window.api = apiMock;
});
afterEach(() => {
delete window.api;
vi.clearAllMocks();
});
it("loads tree and uploads via window.api", async () => {
const { default: FilesyncFileManager } = await import("@/components/filesync/FilesyncFileManager.vue");
const wrapper = mount(FilesyncFileManager, {
props: { syncDirectory: "/tmp/sync" },
global: {
mocks: {
$t: (key, params) => (params ? `${key}:${JSON.stringify(params)}` : key),
},
stubs: {
MaterialDesignIcon: {
template: "<div></div>",
props: ["iconName"],
},
},
},
});
await vi.waitFor(() => expect(apiMock.get).toHaveBeenCalledWith("/api/v1/filesync/tree", expect.any(Object)));
expect(wrapper.vm.entries).toHaveLength(2);
await wrapper.vm.onUploadSelected({
target: { files: [new File(["hi"], "hi.txt")], value: "x" },
});
expect(apiMock.post).toHaveBeenCalledWith("/api/v1/filesync/upload", expect.any(FormData));
expect(ToastUtils.success).toHaveBeenCalledWith("rns_filesync.upload_done");
});
});

View file

@ -0,0 +1,39 @@
// SPDX-License-Identifier: 0BSD
import { describe, it, expect } from "vitest";
import { shouldWarnMultiSession, shouldShowMultiSessionToast } from "../../meshchatx/src/frontend/js/activeSessions.js";
describe("activeSessions oracles", () => {
it("warns only when count is at least two and setting is on", () => {
expect(shouldWarnMultiSession(0, true)).toBe(false);
expect(shouldWarnMultiSession(1, true)).toBe(false);
expect(shouldWarnMultiSession(2, true)).toBe(true);
expect(shouldWarnMultiSession(5, true)).toBe(true);
expect(shouldWarnMultiSession(2, false)).toBe(false);
expect(shouldWarnMultiSession(2, undefined)).toBe(true);
expect(shouldWarnMultiSession(Number.NaN, true)).toBe(false);
});
it("toasts once per multi-session episode", () => {
expect(shouldShowMultiSessionToast(1, true, false)).toEqual({
show: false,
warned: false,
});
expect(shouldShowMultiSessionToast(2, true, false)).toEqual({
show: true,
warned: true,
});
expect(shouldShowMultiSessionToast(3, true, true)).toEqual({
show: false,
warned: true,
});
expect(shouldShowMultiSessionToast(1, true, true)).toEqual({
show: false,
warned: false,
});
expect(shouldShowMultiSessionToast(2, false, false)).toEqual({
show: false,
warned: false,
});
});
});

4
vendor/README.txt vendored
View file

@ -2,10 +2,12 @@ Vendored third-party trees shipped inside the reticulum-meshchatx distribution.
lxmfy/
Upstream: https://git.quad4.io/LXMFy/LXMFy
Bundled revision: d92cfe0e1ad07fbf6928cf2e02438fe9f0d14384
Bundled revision: 483b6928ce2e3cdacd415be92d0f38ae13dca651
Declared version (pyproject): see vendor/lxmfy/pyproject.toml
Update: clone default branch, replace vendor/lxmfy (omit .git), align vendor/README
commit above, run poetry lock / uv lock, regenerate THIRD_PARTY_NOTICES if needed.
Note: MeshChatX keeps its Landlock ABI hardening in vendor/lxmfy/lxmfy/landlock_sandbox.py
(and matching tests) when refreshing from upstream.
rns_filesync/
Upstream: https://github.com/Quad4-Software/RNS-Filesync

View file

@ -1,5 +1,44 @@
# Changelog
## [2.0.1] - 2026-07-21
### Fixes
- **RNS shared-instance digest rejection**: Bots no longer default the Reticulum config directory to the bot `config_path` when a user/system Reticulum config already exists. LXMFy now discovers `/etc/reticulum`, `~/.config/reticulum`, then `~/.reticulum` (same order as RNS). When a bot must use an isolated config directory, it forces `share_instance = No` so it cannot collide on the default shared-instance RPC socket/ports with NomadNet, Columba, or `rnsd` (`AuthenticationError: digest sent was rejected`).
- **Opportunistic delivery**: `opportunistic_sending=True` (the default) now actually selects LXMF `OPPORTUNISTIC` packet delivery instead of only toggling propagation-on-fail. This is required for reliable messaging through public TCP/backbone entrypoints where link-based `DIRECT` delivery often fails.
### Tests
- Added unit/integration coverage for Reticulum config discovery and isolated `share_instance` handling.
- Added opt-in live LXMF ping/pong test (`LXMFY_LIVE_LXMF=1`) that selects random online TCP/backbone nodes from `directory.rns.recipes`.
### Updates
- **Dependencies**: RNS `>=1.3.9`.
## [2.0.0] - 2026-07-10
Final feature release of LXMFy!
### Features
- **Reticulum Relay Chat (RRC)**: CBOR-encoded RRC client support so bots can join hubs as first-class chat participants ([RRC spec](https://rrc.kc1awv.net/), compatible with NomadNet / rrcd).
- New `lxmfy.rrc` package: constants, envelope encode/decode/validation (`cbor2`), `RRCClient`, and `RRCManager`.
- `BotConfig` options: `rrc_enabled`, `rrc_hubs`, `rrc_rooms`, `rrc_nick`, `rrc_dest_name`, `rrc_auto_reconnect`, `rrc_persist_sessions`.
- Bot API: `connect_rrc()`, `disconnect_rrc()`, `@bot.on_rrc`, `bot.rrc` manager, and `rrc_*` event dispatch.
- Session behavior: HELLO/WELCOME, JOIN/PART, MSG/NOTICE/ACTION, PING/PONG, ERROR, RESOURCE_ENVELOPE, auto-reconnect with room re-join, client-side hub limit and rate-limit enforcement, pre-WELCOME send guard.
- Optional RRC session persistence across restarts (`rrc_persist_sessions`, default on).
- New `RRCBot` template and `lxmfy create --template rrc` / `lxmfy run rrc`.
### Fixes
- **LXMF crash recovery**: Outgoing `persisted_queue` now updates after dequeue, preserves delivery method on restore, keeps failed and deferred restores, requeues on outbound send failure, and flushes on `cleanup()`.
- **Invalid persisted destinations**: Corrupt or non-hash destinations (for example test-mode leftovers) are dropped on restore instead of looping forever.
### Updates
- **Dependencies**: RNS `>=1.3.8`, LXMF `>=1.0.1`, CBOR via `cbor2>=5.4.0`.
- **Defaults**: `message_persistence_enabled` defaults to `True` for crash-safe outgoing queues.
- **Memory guards**: Bounded outbound queue (`message_queue_size`, default 50) with drop-oldest on overflow, capped persisted queue/content, and RRC caps for tracked nicks, room members, resource expectations, and pending pings.
### Tests
- Added RRC/CBOR unit, property, persistence, reconnect, limit, resource-envelope, and bot-integration tests.
- Opt-in live rrcd smoke test via `LXMFY_LIVE_RRC=1`.
## [1.6.5] - 2026-07-04
### Features

View file

@ -1,42 +0,0 @@
# Contributing to LXMFy
Patches are the preferred way to contribute. Create your changes locally,
export a `.patch` file, and send it over Reticulum.
## Generating a Patch
1. Clone or fork the repository and make your changes on a branch.
2. Stage and commit your work:
```bash
git add -A
git commit -m "Short description of the change"
```
3. Export the commit(s) as a `.patch` file:
```bash
# Single most recent commit
git format-patch -1
# Last N commits
git format-patch -N
# All commits since a branch point
git format-patch main..HEAD
```
This produces one `.patch` file per commit (e.g. `0001-my-change.patch`).
## Sending the Patch
Send the `.patch` file as an LXMF message over Reticulum to:
```
7cc8d66b4f6a0e0e49d34af7f6077b5a
```
You can attach the file using Sideband, Meshchat, MeshchatX, or any LXMF-capable client with attachments support.
Include a brief description of what the patch does in the message body.
## Patch Guidelines
- Keep patches focused on a single change or fix.
- Test your changes before exporting.

View file

@ -4,12 +4,12 @@ Easily create LXMF bots for the Reticulum Network with this extensible framework
[Docs](https://lxmfy.quad4.io)
## Features
## Feature
| Category | Key Capabilities |
| :--- | :--- |
| **Core** | Interactive CLI, Command Prefixes, Cron-style Task Scheduler, Middleware & Event Systems |
| **Connectivity** | Direct Delivery & Propagation Fallback, Auto-Peering, RNS Link Support, Opportunistic Sending |
| **Connectivity** | Direct Delivery & Propagation Fallback, Auto-Peering, RNS Link Support, Opportunistic Sending, **RRC (Reticulum Relay Chat) hub client** |
| **Security** | Spam Protection, Role-based Permissions, Identity Pinning, Message Signing/Verification, Landlock LSM Filesystem Sandbox (Linux) |
| **NLP** | Local NLP Intent Classification (Offline/Private), Type-hinted Argument Parsing |
| **Extensions** | Python Cogs, External Script Cogs (Bash, Go, C, etc.), Linux Sandboxing (Landlock LSM, `bwrap`/`firejail`) |
@ -19,7 +19,7 @@ Easily create LXMF bots for the Reticulum Network with this extensible framework
## Installation
**Requirements:** Python 3.11+, [RNS](https://pypi.org/project/rns/) 1.4.0+, [LXMF](https://pypi.org/project/lxmf/) 1.1.0+ (installed automatically with LXMFy).
**Requirements:** Python 3.11+, [RNS](https://pypi.org/project/rns/) 1.3.8+, [LXMF](https://pypi.org/project/lxmf/) 1.0.1+, [cbor2](https://pypi.org/project/cbor2/) 5.4.0+ (installed automatically with LXMFy).
There are many ways to install LXMFy, you pick:
@ -154,6 +154,33 @@ def echo(ctx, message: str):
bot.run()
```
## RRC (Reticulum Relay Chat)
Bots can join [RRC](https://rrc.kc1awv.net/) hubs as ordinary clients over RNS Links with CBOR envelopes:
```python
from lxmfy import LXMFBot, RRCMessage
bot = LXMFBot(
name="RoomBot",
rrc_enabled=True,
rrc_hubs=["your_rrc_hub_destination_hash"],
rrc_rooms=["lobby"],
rrc_nick="RoomBot",
)
@bot.on_rrc
def on_rrc(event, client, payload):
if event == "msg" and isinstance(payload, RRCMessage) and payload.mention:
client.send_message(payload.room, f"Heard you, {payload.nick}")
bot.run()
```
Or connect at runtime with `bot.connect_rrc(hub_hash, rooms=["lobby"])`.
Hub sessions persist across restarts by default (`rrc_persist_sessions=True`). Outgoing LXMF messages are also persisted by default (`message_persistence_enabled=True`) so a crash mid-queue does not drop them. The outbound queue is bounded (`message_queue_size`, default 50) and drops the oldest message when full.
## Propagation Node Configuration
LXMFy supports three modes for propagation node usage:

View file

@ -1,4 +0,0 @@
- LXST Support
- More template bots
- Improve NLP
- Knowledge Graph

View file

@ -34,7 +34,7 @@ The main bot class that handles message routing, command processing, and bot lif
signature_verification_enabled=False,
require_message_signatures=False,
identity_pinning_enabled=False,
message_persistence_enabled=False,
message_persistence_enabled=True,
dynamic_cogs_enabled=True,
external_cogs_enabled=True,
external_cogs_sandbox_enabled=True,
@ -44,7 +44,16 @@ The main bot class that handles message routing, command processing, and bot lif
nlp_enabled=False,
nlp_threshold=0.5,
link_support_enabled=False,
lxmf_commands_enabled=True
lxmf_commands_enabled=True,
message_queue_size=50,
reticulum_config_dir=None, # or LXMFY_RETICULUM_CONFIG_DIR / "~/.reticulum"
rrc_enabled=False,
rrc_hubs=[],
rrc_rooms=[],
rrc_nick=None,
rrc_dest_name="rrc.hub",
rrc_auto_reconnect=True,
rrc_persist_sessions=True,
)
Key Methods
@ -67,6 +76,10 @@ Key Methods
- :code:`on_first_message()`: Decorator for handling first messages from users
- :code:`on_message()`: Decorator for handling all messages (called before command processing)
- :code:`validate()`: Run validation checks on the bot configuration
- :code:`connect_rrc(hub_hash, rooms=None, nick=None, dest_name=None, auto_reconnect=None)`: Connect to an RRC hub as a client
- :code:`disconnect_rrc(hub_hash=None)`: Disconnect one or all RRC hub sessions
- :code:`on_rrc(callback=None)`: Decorator or register handler for RRC events (:code:`handler(event, client, payload)`)
- :code:`rrc`: :code:`RRCManager` instance for multi-hub sessions
Structured Commands via LXMF Fields
-----------------------------------
@ -422,12 +435,13 @@ The retry system tracks delivery attempts per destination and automatically retr
Message Persistence
^^^^^^^^^^^^^^^^^^^
Outgoing messages can be persisted to disk to ensure they are delivered even after a bot restart.
Outgoing messages can be persisted to disk to ensure they are delivered even after a bot restart. Persistence is enabled by default. The in-memory outbound queue is bounded (:code:`message_queue_size`, default 50) and drops the oldest message when full. Invalid destination hashes are not restored.
.. code-block:: python
bot = LXMFBot(
message_persistence_enabled=True
message_persistence_enabled=True,
message_queue_size=50,
)
Message Handlers
@ -471,6 +485,64 @@ Message handlers are called in this order:
2. General message handlers (registered with :code:`on_message()`)
3. Command processing (if message starts with command prefix)
Reticulum Relay Chat (RRC)
--------------------------
Bots can join `RRC <https://rrc.kc1awv.net/>`_ hubs over RNS Links with CBOR envelopes. Package: :code:`lxmfy.rrc`.
BotConfig options
^^^^^^^^^^^^^^^^^
* :code:`rrc_enabled` (bool, default :code:`False`): Connect configured hubs on startup
* :code:`rrc_hubs` (list of hex hashes): Hub destination hashes
* :code:`rrc_rooms` (list of str): Rooms to auto-join after WELCOME
* :code:`rrc_nick` (str or None): Nickname on HELLO and room messages
* :code:`rrc_dest_name` (str, default :code:`"rrc.hub"`): Destination name used to build the hub destination
* :code:`rrc_auto_reconnect` (bool, default :code:`True`): Reconnect after link loss
* :code:`rrc_persist_sessions` (bool, default :code:`True`): Persist hubs and rooms across restarts
* :code:`reticulum_config_dir` (str or None): Reticulum config directory. Also set via :code:`LXMFY_RETICULUM_CONFIG_DIR`. Use the same config as MeshChatX (often :code:`~/.reticulum`) so hub announces are visible.
Example
^^^^^^^
.. code-block:: python
from lxmfy import LXMFBot, RRCMessage
bot = LXMFBot(
name="RoomBot",
reticulum_config_dir="~/.reticulum",
rrc_enabled=True,
rrc_hubs=["664fc0e8d2e448658e37bb3f34e6c88f"],
rrc_rooms=["general"],
rrc_nick="RoomBot",
)
@bot.on_rrc
def on_rrc(event, client, payload):
if event == "msg" and isinstance(payload, RRCMessage) and payload.mention:
client.send_message(payload.room, f"Hi {payload.nick}")
# Runtime API
# bot.connect_rrc(hub_hash, rooms=["general"])
# bot.rrc.send_message("general", "hello")
# bot.rrc.send_notice("general", "notice")
# bot.rrc.send_action("general", "waves")
# bot.rrc.join("ops")
# bot.rrc.part("ops")
# bot.rrc.status()
# bot.disconnect_rrc()
Exported types
^^^^^^^^^^^^^^
* :code:`RRCClient`: Single-hub session
* :code:`RRCManager`: Multi-hub manager (:code:`bot.rrc`)
* :code:`RRCMessage`: Room event payload (:code:`kind`, :code:`room`, :code:`text`, :code:`nick`, :code:`src`, :code:`mention`, ...)
* :code:`RRC_VERSION`: Wire protocol version constant
Common events passed to :code:`@bot.on_rrc` handlers include :code:`status`, :code:`welcome`, :code:`joined`, :code:`parted`, :code:`msg`, :code:`notice`, :code:`action`, :code:`motd`, :code:`error`, and :code:`rtt`.
Templates
=========
@ -512,6 +584,23 @@ Reminder bot with SQLite storage:
bot = ReminderBot()
bot.run()
RRCBot
------
RRC room bot that joins configured hubs and replies to :code:`@mentions`. Defaults to hub :code:`664fc0e8d2e448658e37bb3f34e6c88f`, room :code:`#general`, and :code:`~/.reticulum` when available.
.. code-block:: python
from lxmfy.templates import RRCBot
bot = RRCBot(
hubs=["664fc0e8d2e448658e37bb3f34e6c88f"],
rooms=["general"],
nick="RRCBot",
reticulum_config_dir="~/.reticulum",
)
bot.run()
CLI Tools
=========
@ -524,9 +613,11 @@ The framework provides command-line tools for bot management:
# Create a bot from template
lxmfy create --template echo mybot
lxmfy create --template rrc my_rrc_bot
# Run a template bot
lxmfy run echo
lxmfy run rrc
# Test signature verification with a message
lxmfy signatures test

View file

@ -75,6 +75,12 @@ LXMFy provides several templates for common bot types. You can use the CLI to ge
# Create a cog test bot (tests cog loading features)
lxmfy create --template cogtest my_cog_test_bot
# Create an RRC room bot (joins hubs and replies to @mentions)
lxmfy create --template rrc my_rrc_bot
# Or run the template directly
lxmfy run rrc
Running these commands creates a Python file (e.g., :code:`my_echo_bot.py`) that imports and runs the chosen template. You can then modify the generated file or the template code itself (:code:`lxmfy/templates/...`).
**Example generated file (:code:`my_cog_test_bot.py`):**
@ -651,3 +657,87 @@ The retry system:
- Retries failed direct deliveries up to :code:`direct_delivery_retries`
- Resets the retry counter on successful delivery
- Logs retry attempts and failures for debugging
Reticulum Relay Chat (RRC)
--------------------------
LXMFy bots can join `RRC <https://rrc.kc1awv.net/>`_ hubs as ordinary clients over RNS Links using CBOR envelopes. This is compatible with NomadNet and rrcd style hubs (including MeshChatX when it hosts or joins the same hub).
Reticulum config matters
^^^^^^^^^^^^^^^^^^^^^^^^
The bot must use the **same** Reticulum network as the hub. MeshChatX typically uses :code:`~/.reticulum` with backbone or TCP interfaces. The project-local :code:`config/` directory often uses an isolated instance name and AutoInterface only, so hub announces never arrive and you see :code:`Hub identity unknown`.
Prefer one of:
* Set :code:`reticulum_config_dir` to your user config (usually :code:`~/.reticulum`)
* Or export :code:`LXMFY_RETICULUM_CONFIG_DIR=~/.reticulum`
* Keep MeshChatX or :code:`rnsd` running so the shared instance is up before the bot starts
The :code:`rrc` template defaults to :code:`~/.reticulum` when that directory exists.
Quick start with the template
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
.. code-block:: bash
lxmfy run rrc
Defaults:
* Hub: :code:`664fc0e8d2e448658e37bb3f34e6c88f`
* Room: :code:`#general`
* Reticulum config: :code:`~/.reticulum` (or :code:`LXMFY_RETICULUM_CONFIG_DIR`)
You should see logs for hub connect, welcome, auto-join, and :code:`RRC joined #general`.
Programmatic RRC bot
^^^^^^^^^^^^^^^^^^^^
.. code-block:: python
from lxmfy import LXMFBot, RRCMessage
bot = LXMFBot(
name="RoomBot",
reticulum_config_dir="~/.reticulum",
rrc_enabled=True,
rrc_hubs=["your_rrc_hub_destination_hash"],
rrc_rooms=["general"],
rrc_nick="RoomBot",
rrc_auto_reconnect=True,
rrc_persist_sessions=True,
)
@bot.on_rrc
def on_rrc(event, client, payload):
if event == "welcome":
bot.logger.info("Welcomed by hub")
return
if event != "msg" or not isinstance(payload, RRCMessage):
return
if payload.mention and payload.room:
client.send_message(
payload.room,
f"Heard you, {payload.nick}",
)
bot.run()
Or connect at runtime:
.. code-block:: python
bot.connect_rrc("hub_destination_hash", rooms=["general"])
bot.rrc.send_message("general", "hello room")
bot.rrc.send_action("general", "waves")
bot.disconnect_rrc()
Session behavior
^^^^^^^^^^^^^^^^
* HELLO / WELCOME, JOIN / PART, MSG / NOTICE / ACTION, PING / PONG, ERROR, RESOURCE_ENVELOPE
* Auto-reconnect with room re-join after WELCOME
* Client-side hub limit and rate-limit enforcement
* Session persistence across restarts (:code:`rrc_persist_sessions`, default on)
* Outgoing LXMF queue persistence is separate (:code:`message_persistence_enabled`)

View file

@ -5,8 +5,9 @@ Prerequisites
-------------
* Python 3.11+
* Reticulum Network Stack (:code:`pip install rns`, version 1.3.5+)
* Reticulum Network Stack (:code:`pip install rns`, version 1.3.8+)
* LXMF (:code:`pip install lxmf`, version 1.0.1+; installed automatically with LXMFy)
* CBOR (:code:`cbor2`, installed automatically, required for RRC)
* LXMFy (:code:`pip install lxmfy` or install from source)
Creating Your First Bot (Using the CLI)
@ -112,6 +113,13 @@ Once you're comfortable with the basics, explore these advanced features:
* Configure :code:`direct_delivery_retries` in :code:`LXMFBot(...)` for automatic retry before propagation fallback
* Configure :code:`propagation_node` in bot config (or use :code:`bot.set_propagation_node(...)`) to route through a specific LXMF propagation node
* Outgoing queue persistence is on by default (:code:`message_persistence_enabled=True`) with a bounded queue (:code:`message_queue_size`)
**Reticulum Relay Chat (RRC):**
* Join RRC hubs as a normal client with :code:`rrc_enabled=True` or the :code:`rrc` template
* Use the same Reticulum config as MeshChatX or your hub (:code:`reticulum_config_dir` or :code:`LXMFY_RETICULUM_CONFIG_DIR`, typically :code:`~/.reticulum`)
* See the `Creating Bots <creating-bots.html#reticulum-relay-chat-rrc>`_ guide for room bots and hub discovery
**Security:**

View file

@ -20,6 +20,18 @@ from .help import HelpFormatter, HelpSystem
from .lxmf_fields import FIELD_COMMANDS, FIELD_RESULTS, pack_result, unpack_commands
from .middleware import MiddlewareContext, MiddlewareManager, MiddlewareType
from .permissions import DefaultPerms, PermissionManager, Role
from .rrc import (
DEFAULT_DEST_NAME,
RRCClient,
RRCManager,
RRCMessage,
RRC_VERSION,
decode_envelope,
encode_envelope,
make_envelope,
normalize_room,
validate_envelope,
)
from .scheduler import ScheduledTask, TaskScheduler
from .storage import JSONStorage, SQLiteStorage, Storage
from .validation import format_validation_results, validate_bot
@ -29,6 +41,7 @@ __all__ = [
"AttachmentType",
"BotConfig",
"Command",
"DEFAULT_DEST_NAME",
"DefaultPerms",
"Event",
"EventManager",
@ -46,19 +59,28 @@ __all__ = [
"MiddlewareType",
"PermissionManager",
"Role",
"RRCClient",
"RRCManager",
"RRCMessage",
"RRC_VERSION",
"SQLiteStorage",
"ScheduledTask",
"Storage",
"TaskScheduler",
"__version__",
"command",
"decode_envelope",
"encode_envelope",
"format_validation_results",
"load_cogs_from_directory",
"make_envelope",
"normalize_room",
"pack_attachment",
"pack_icon_appearance_field",
"pack_result",
"unpack_commands",
"validate_bot",
"validate_envelope",
]
from .__version__ import __version__

View file

@ -12,6 +12,6 @@ except PackageNotFoundError:
pyproject = tomllib.load(f)
__version__ = pyproject["project"]["version"]
else:
__version__ = "1.6.2"
__version__ = "2.0.1"
except Exception:
__version__ = "1.6.2"
__version__ = "2.0.1"

View file

@ -20,7 +20,7 @@ from .colors import (
print_success,
print_warning,
)
from .templates import CogTestBot, EchoBot, NoteBot, ReminderBot
from .templates import CogTestBot, EchoBot, NoteBot, ReminderBot, RRCBot
def get_user_choice() -> str:
@ -50,7 +50,7 @@ def get_bot_name() -> str:
def get_template_choice() -> str:
"""Get template choice from user input."""
templates = ["basic", "echo", "reminder", "note", "cogtest"]
templates = ["basic", "echo", "reminder", "note", "cogtest", "rrc"]
if Colors.is_colors_supported():
print(f"\n{Colors.CYAN}Available templates:{Colors.ENDC}")
for i, template in enumerate(templates, 1):
@ -62,12 +62,12 @@ def get_template_choice() -> str:
while True:
if Colors.is_colors_supported():
choice = input(f"\n{Colors.CYAN}Select template (1-5): {Colors.ENDC}")
choice = input(f"\n{Colors.CYAN}Select template (1-6): {Colors.ENDC}")
else:
choice = input("\nSelect template (1-5): ")
if choice in ["1", "2", "3", "4", "5"]:
choice = input("\nSelect template (1-6): ")
if choice in ["1", "2", "3", "4", "5", "6"]:
return templates[int(choice) - 1]
print_error("Invalid choice. Please enter a number between 1 and 5.")
print_error("Invalid choice. Please enter a number between 1 and 6.")
def interactive_create() -> None:
@ -142,6 +142,7 @@ def interactive_run() -> None:
"reminder": ReminderBot,
"note": NoteBot,
"cogtest": CogTestBot,
"rrc": RRCBot,
}
BotClass = template_map[template]
@ -369,6 +370,7 @@ def create_from_template(template_name: str, output_path: str, bot_name: str) ->
"reminder": ReminderBot,
"note": NoteBot,
"cogtest": CogTestBot,
"rrc": RRCBot,
}
if template_name not in template_map:
@ -466,7 +468,7 @@ Examples:
)
parser.add_argument(
"--template",
choices=["basic", "echo", "reminder", "note", "cogtest"],
choices=["basic", "echo", "reminder", "note", "cogtest", "rrc"],
default="basic",
help="Bot template to use for 'create' command (default: basic)",
)
@ -550,7 +552,7 @@ To add admin rights, edit {bot_path} and add your LXMF hash to the admins list.
template_name = args.name
if not template_name:
print_error(
"Please specify a template name to run (echo, reminder, note, cogtest)",
"Please specify a template name to run (echo, reminder, note, cogtest, rrc)",
)
sys.exit(1)
@ -559,6 +561,7 @@ To add admin rights, edit {bot_path} and add your LXMF hash to the admins list.
"reminder": ReminderBot,
"note": NoteBot,
"cogtest": CogTestBot,
"rrc": RRCBot,
}
if template_name not in template_map:
@ -572,6 +575,18 @@ To add admin rights, edit {bot_path} and add your LXMF hash to the admins list.
print_header(f"Starting {template_name} Bot")
bot_instance = BotClass()
if template_name == "rrc" and hasattr(bot_instance, "bot"):
hubs = bot_instance.bot.config.rrc_hubs or []
rooms = bot_instance.bot.config.rrc_rooms or []
rns_dir = getattr(bot_instance.bot, "reticulum_config_dir", None)
print_info(
f"RRC hubs: {', '.join(hubs) if hubs else '(none)'}",
)
print_info(
f"RRC rooms: {', '.join('#' + r for r in rooms) if rooms else '(none)'}",
)
print_info(f"Reticulum config: {rns_dir or '(default)'}")
custom_name = args.name_opt
if custom_name:
try:

View file

@ -42,7 +42,7 @@ class BotConfig:
enable_propagation_node (bool): Whether to run this bot as a propagation node. Defaults to False.
message_storage_limit_mb (float): Maximum storage for propagation node messages in megabytes. Only applies when enable_propagation_node is True. Defaults to 500 MB.
config_path (str): The path to the bot configuration directory. If None, defaults to "config" in the current working directory. Defaults to None.
reticulum_config_dir (str): The Reticulum config directory used for RNS shared instance/auth state. If None, falls back to config_path. Can also be set via LXMFY_RETICULUM_CONFIG_DIR.
reticulum_config_dir (str): The Reticulum config directory used for RNS shared instance/auth state. If None, uses LXMFY_RETICULUM_CONFIG_DIR when set, otherwise discovers the user/system Reticulum config (/etc/reticulum, ~/.config/reticulum, ~/.reticulum), and only then falls back to config_path. Isolated bot configs force share_instance=No to avoid RPC digest rejection with NomadNet/Columba.
test_mode (bool): Whether to run in test mode (skips RNS initialization). Defaults to False.
announce_display_name_file (str): Optional filename under config_path whose UTF-8 contents override the bot display name for LXMF delivery announces. If unset, ``bot_display_name.txt`` is read when present. Otherwise ``name`` is used.
@ -85,7 +85,8 @@ class BotConfig:
announce_display_name_file: str | None = None
test_mode: bool = False
identity_pinning_enabled: bool = False
message_persistence_enabled: bool = False
message_persistence_enabled: bool = True
message_queue_size: int = 50
dynamic_cogs_enabled: bool = True
external_cogs_enabled: bool = True
external_cogs_sandbox_enabled: bool = True
@ -99,6 +100,13 @@ class BotConfig:
link_support_enabled: bool = False
opportunistic_sending: bool = True
lxmf_commands_enabled: bool = True
rrc_enabled: bool = False
rrc_hubs: list[str] | None = None
rrc_rooms: list[str] | None = None
rrc_nick: str | None = None
rrc_dest_name: str = "rrc.hub"
rrc_auto_reconnect: bool = True
rrc_persist_sessions: bool = True
def __post_init__(self):
"""Post-initialization to ensure admins is a set."""
@ -106,6 +114,10 @@ class BotConfig:
self.admins = set()
if self.reticulum_config_dir is None:
self.reticulum_config_dir = os.environ.get("LXMFY_RETICULUM_CONFIG_DIR")
if self.rrc_hubs is None:
self.rrc_hubs = []
if self.rrc_rooms is None:
self.rrc_rooms = []
def __str__(self):
"""Return a string representation of the BotConfig object."""

View file

@ -13,7 +13,7 @@ import re
import sys
import time
from concurrent.futures import ThreadPoolExecutor
from queue import Queue
from queue import Full, Queue
from typing import Any, Callable, cast
from types import SimpleNamespace
@ -32,9 +32,15 @@ from .middleware import MiddlewareContext, MiddlewareManager, MiddlewareType
from .moderation import SpamProtection
from .nlp import IntentClassifier
from .permissions import DefaultPerms, PermissionManager
from .reticulum_config import (
ensure_isolated_share_instance_disabled,
is_isolated_reticulum_dir,
resolve_reticulum_config_dir,
)
from .scheduler import TaskScheduler
from .signatures import SignatureManager, sign_outgoing_message, verify_incoming_message
from .storage import JSONStorage, MemoryStorage, SQLiteStorage, Storage
from .rrc import DEFAULT_DEST_NAME, RRCManager, RRCMessage
from .transport import Transport
from .validation import format_validation_results, validate_bot
@ -66,7 +72,8 @@ class LXMFBot:
self.message_handlers = []
self.delivery_callbacks = []
self.receipts = []
self.queue = Queue(maxsize=50)
queue_size = max(1, int(getattr(self.config, "message_queue_size", 50) or 50))
self.queue = Queue(maxsize=queue_size)
self.announce_time = 600
self.router = None
self.local = None
@ -82,13 +89,18 @@ class LXMFBot:
self.config_path = os.path.join(os.getcwd(), "config")
os.makedirs(self.config_path, exist_ok=True)
if self.config.reticulum_config_dir:
self.reticulum_config_dir = os.path.abspath(
os.path.expanduser(self.config.reticulum_config_dir),
)
if self.config.test_mode and not self.config.reticulum_config_dir:
self.reticulum_config_dir = os.path.abspath(self.config_path)
else:
self.reticulum_config_dir = self.config_path
self.reticulum_config_dir = resolve_reticulum_config_dir(
self.config.reticulum_config_dir,
self.config_path,
)
os.makedirs(self.reticulum_config_dir, exist_ok=True)
if not self.config.test_mode and is_isolated_reticulum_dir(
self.reticulum_config_dir, self.config_path
):
ensure_isolated_share_instance_disabled(self.reticulum_config_dir)
if self.config.storage_type == "json":
self.storage = Storage(JSONStorage(self.config.storage_path))
@ -274,6 +286,63 @@ class LXMFBot:
self.link_handlers = []
self.links = {} # {dest_hash: Link}
self.rrc_handlers = []
self.rrc = RRCManager(
identity=self.identity,
nick=self.config.rrc_nick or self.config.name,
dest_name=self.config.rrc_dest_name or DEFAULT_DEST_NAME,
auto_reconnect=self.config.rrc_auto_reconnect,
storage=self.storage,
persist_sessions=self.config.rrc_persist_sessions,
)
self.rrc.on_event(self._rrc_event)
if self.config.rrc_enabled and not self.config.test_mode:
config_rooms = list(self.config.rrc_rooms or [])
restored = 0
if self.config.rrc_persist_sessions:
try:
restored = self.rrc.restore_sessions()
except Exception as e:
self.logger.error("Failed to restore RRC sessions: %s", e)
for hub in self.config.rrc_hubs or []:
try:
RNS.log(
f"RRC connecting to hub {hub} rooms={config_rooms or ['(none)']}",
RNS.LOG_INFO,
)
self.connect_rrc(hub, rooms=list(config_rooms))
except Exception as e:
self.logger.error("Failed to connect RRC hub %s: %s", hub, e)
RNS.log(f"RRC hub connect failed for {hub}: {e}", RNS.LOG_ERROR)
if restored:
RNS.log(f"Restored {restored} RRC hub session(s)", RNS.LOG_INFO)
# Ensure persisted hubs not listed in config still get config rooms
# when their saved room list was empty.
if config_rooms:
for client in list(self.rrc.clients.values()):
with client._lock:
planned = (
set(client.rooms)
| set(client._rejoin_rooms)
| set(
client._auto_join_rooms,
)
)
if planned:
continue
try:
self.connect_rrc(
client.hub_hash.hex(),
rooms=list(config_rooms),
)
except Exception as e:
self.logger.error(
"Failed to apply RRC rooms to hub %s: %s",
client.hub_hash.hex(),
e,
)
self.signature_manager = SignatureManager(
self,
verification_enabled=self.config.signature_verification_enabled,
@ -794,6 +863,7 @@ class LXMFBot:
lxmf_fields: dict | None = None,
stamp_cost: int | None = None,
opportunistic: bool | None = None,
method=None,
):
"""Send a message to a destination, optionally with custom LXMF fields.
@ -805,26 +875,31 @@ class LXMFBot:
stamp_cost: Optional stamp cost override. If None, uses config.stamp_cost.
opportunistic: Whether to use opportunistic sending (try direct, then prop).
If None, uses config.opportunistic_sending.
method: Optional explicit LXMF delivery method override for crash recovery.
"""
if self.config.test_mode:
# In test mode, just queue a mock message
mock_message = SimpleNamespace()
try:
mock_message.destination_hash = bytes.fromhex(destination)
except ValueError:
mock_message.destination_hash = destination.encode("utf-8")
mock_message.content = message.encode("utf-8")
mock_message.title = title.encode("utf-8") if title else None
mock_message.fields = lxmf_fields
self.queue.put(mock_message)
return
mock_message.desired_method = method
return self._enqueue_outbound(mock_message)
try:
dest_hash_bytes = bytes.fromhex(destination)
except ValueError:
RNS.log(f"Invalid destination hash format: {destination}", RNS.LOG_ERROR)
return
return False
if len(dest_hash_bytes) != RNS.Reticulum.TRUNCATED_HASHLENGTH // 8:
RNS.log(f"Invalid destination hash length for {destination}", RNS.LOG_ERROR)
return
return False
identity_instance = RNS.Identity.recall(dest_hash_bytes)
if identity_instance is None:
@ -837,8 +912,7 @@ class LXMFBot:
"Path requested. If the network knows a path, you will receive an announce shortly.",
RNS.LOG_INFO,
)
return
return False
lxmf_destination_obj = RNS.Destination(
identity_instance,
RNS.Destination.OUT,
@ -886,9 +960,16 @@ class LXMFBot:
f"Using propagation for {destination} after {attempts} failed direct attempts",
RNS.LOG_INFO,
)
elif is_opportunistic:
# Packet delivery without requiring a Link first. Works much more
# reliably through public TCP/backbone entrypoints than DIRECT.
desired_method = LXMessage.OPPORTUNISTIC
else:
desired_method = LXMessage.DIRECT
if method is not None:
desired_method = method
# Use provided stamp_cost or fall back to config
final_stamp_cost = (
stamp_cost if stamp_cost is not None else self.config.stamp_cost
@ -936,36 +1017,95 @@ class LXMFBot:
# Sign the message (pass-through for LXMF's built-in signing)
lxm = sign_outgoing_message(self, lxm)
# Set propagation fallback if enabled
# Set propagation fallback if enabled. Applies when starting with
# opportunistic or direct delivery and a propagation node is known.
if (
desired_method == LXMessage.DIRECT
desired_method in (LXMessage.DIRECT, LXMessage.OPPORTUNISTIC)
and (self.config.propagation_fallback_enabled or is_opportunistic)
and has_prop_node
):
setattr(lxm, "try_propagation_on_fail", True)
self.queue.put(lxm)
self._persist_queue()
if not self._enqueue_outbound(lxm):
RNS.log(
f"Failed to queue message for {destination}: outbound queue full",
RNS.LOG_ERROR,
)
return False
RNS.log(
f"Message queued for {destination} (method: {desired_method}, opportunistic: {is_opportunistic})",
RNS.LOG_DEBUG,
)
return True
@staticmethod
def _destination_hash_len() -> int:
return RNS.Reticulum.TRUNCATED_HASHLENGTH // 8
def _is_valid_destination_hex(self, destination: str) -> bool:
if not isinstance(destination, str) or not destination:
return False
try:
raw = bytes.fromhex(destination)
except ValueError:
return False
return len(raw) == self._destination_hash_len()
def _enqueue_outbound(self, lxm) -> bool:
"""Enqueue an outbound message without blocking. Drops oldest if full."""
try:
self.queue.put_nowait(lxm)
except Full:
try:
dropped = self.queue.get_nowait()
self.logger.warning(
"Outbound queue full (max=%s), dropped oldest message",
self.queue.maxsize,
)
del dropped
self.queue.put_nowait(lxm)
except Exception as e:
self.logger.error("Failed to enqueue outbound message: %s", e)
return False
self._persist_queue()
return True
def _persist_queue(self):
"""Persist the outgoing message queue to storage."""
if getattr(self.config, "message_persistence_enabled", False) is not True:
return
# Persist destination/content/title/fields/method; LXMessage is not trivially serializable.
max_items = max(1, int(self.queue.maxsize or 50))
max_content = 65536
queued_messages = []
for lxm in list(self.queue.queue):
if len(queued_messages) >= max_items:
break
try:
msg_data = {
"destination": RNS.hexrep(lxm.destination_hash, delimit=False),
"content": lxm.content.decode("utf-8")
destination = RNS.hexrep(lxm.destination_hash, delimit=False)
if not self._is_valid_destination_hex(destination):
self.logger.warning(
"Skipping persist for invalid destination hash length: %s",
destination,
)
continue
content = (
lxm.content.decode("utf-8")
if isinstance(lxm.content, bytes)
else lxm.content,
else lxm.content
)
if (
isinstance(content, str)
and len(content.encode("utf-8")) > max_content
):
self.logger.warning(
"Skipping persist for oversized message to %s",
destination,
)
continue
msg_data = {
"destination": destination,
"content": content,
"title": lxm.title.decode("utf-8")
if isinstance(lxm.title, bytes)
else lxm.title,
@ -986,21 +1126,58 @@ class LXMFBot:
persisted = self.storage.get("persisted_queue", [])
if not persisted:
return
if not isinstance(persisted, list):
self.storage.set("persisted_queue", [])
return
max_items = max(1, int(self.queue.maxsize or 50))
if len(persisted) > max_items:
self.logger.warning(
"Truncating persisted queue from %s to %s messages",
len(persisted),
max_items,
)
persisted = persisted[-max_items:]
RNS.log(f"Restoring {len(persisted)} messages from persistence", RNS.LOG_INFO)
deferred = []
for msg_data in persisted:
try:
self.send(
msg_data["destination"],
msg_data["content"],
title=msg_data.get("title"),
lxmf_fields=msg_data.get("fields"),
if not isinstance(msg_data, dict):
continue
destination = msg_data.get("destination")
if not isinstance(destination, str) or not self._is_valid_destination_hex(
destination,
):
self.logger.warning(
"Dropping persisted message with invalid destination: %s",
destination,
)
continue
title = msg_data.get("title")
try:
queued = self.send(
destination,
msg_data["content"],
title=title if isinstance(title, str) else "Reply",
lxmf_fields=msg_data.get("fields"),
method=msg_data.get("method"),
)
if not queued:
deferred.append(msg_data)
except Exception as e:
self.logger.error("Failed to restore message from persistence: %s", e)
deferred.append(msg_data)
# Clear after loading to avoid duplicates if send() fails again
self.storage.set("persisted_queue", [])
# Keep successfully requeued messages on disk until outbound drain.
self._persist_queue()
if deferred:
current = self.storage.get("persisted_queue", [])
if not isinstance(current, list):
current = []
merged = list(current) + deferred
if len(merged) > max_items:
merged = merged[-max_items:]
self.storage.set("persisted_queue", merged)
def send_with_attachment(
self,
@ -1047,11 +1224,19 @@ class LXMFBot:
# Process outgoing queue with a timeout to prevent hanging
while not self.queue.empty():
try:
# Non-blocking get with a small timeout for safety
lxm = self.queue.get(block=False)
except Exception:
break
try:
if self.router:
self.router.handle_outbound(lxm)
except Exception:
self._persist_queue()
except Exception as e:
self.logger.error("Outbound send failed, requeueing: %s", e)
if not self._enqueue_outbound(lxm):
self.logger.error(
"Failed to requeue after outbound error",
)
break
time.sleep(delay)
@ -1096,6 +1281,15 @@ class LXMFBot:
def cleanup(self):
"""Clean up resources."""
RNS.log("Cleaning up LXMFBot...", RNS.LOG_DEBUG)
try:
self._persist_queue()
except Exception as e:
self.logger.error("Failed to persist queue during cleanup: %s", e)
if hasattr(self, "rrc") and self.rrc:
try:
self.rrc.shutdown()
except Exception as e:
self.logger.error("RRC shutdown failed: %s", e)
self.transport.cleanup()
self.thread_pool.shutdown(wait=False)
self.scheduler.stop()
@ -1349,6 +1543,85 @@ class LXMFBot:
except Exception as e:
self.logger.error("Error in link handler: %s", e)
def connect_rrc(
self,
hub_hash: str,
rooms: list[str] | None = None,
nick: str | None = None,
dest_name: str | None = None,
auto_reconnect: bool | None = None,
):
"""Connect to an RRC hub as a client.
Args:
hub_hash: Hub destination hash as hex.
rooms: Optional rooms to join after WELCOME.
nick: Optional nickname override for this hub.
dest_name: Destination name (default rrc.hub).
auto_reconnect: Override auto-reconnect for this session.
Returns:
The RRCClient session.
"""
if self.config.test_mode:
raise RuntimeError("RRC connections are unavailable in test_mode")
return self.rrc.connect(
hub_hash,
rooms=rooms,
nick=nick,
dest_name=dest_name,
auto_reconnect=auto_reconnect,
)
def disconnect_rrc(self, hub_hash: str | None = None) -> None:
"""Disconnect one or all RRC hub sessions."""
self.rrc.disconnect(hub_hash)
def on_rrc(self, callback: Callable | None = None):
"""Register a handler for RRC events.
Handler signature: ``handler(event, client, payload)``.
Payload is an RRCMessage for room events, or a dict for status/welcome.
"""
def decorator(func):
self.rrc_handlers.append(func)
return func
if callback is not None:
return decorator(callback)
return decorator
def _rrc_event(self, event: str, client, payload) -> None:
"""Fan RRC events to bot handlers and the event manager."""
event_data = {
"event": event,
"hub_hash": client.hub_hash.hex() if client else None,
"payload": payload,
}
if isinstance(payload, RRCMessage):
event_data.update(
{
"kind": payload.kind,
"room": payload.room,
"text": payload.text,
"nick": payload.nick,
"src": payload.src.hex() if payload.src else None,
"mention": payload.mention,
},
)
try:
self.events.dispatch(Event(f"rrc_{event}", event_data))
except Exception as e:
self.logger.error("Error dispatching RRC event: %s", e)
for handler in self.rrc_handlers:
try:
handler(event, client, payload)
except Exception as e:
self.logger.error("Error in RRC handler: %s", e)
def on_first_message(self):
"""Decorator for registering first message handlers"""

141
vendor/lxmfy/lxmfy/reticulum_config.py vendored Normal file
View file

@ -0,0 +1,141 @@
"""Reticulum config discovery and shared-instance collision avoidance."""
from __future__ import annotations
import logging
import os
import re
from pathlib import Path
logger = logging.getLogger(__name__)
_SHARE_INSTANCE_RE = re.compile(
r"(?im)^([ \t]*share_instance[ \t]*=[ \t]*)(\S+)([ \t]*)$",
)
def discover_user_reticulum_config_dir(
home: str | None = None,
*,
system_dir: str = "/etc/reticulum",
) -> str | None:
"""Return an existing user/system Reticulum config dir, matching RNS order.
Order:
1. ``system_dir`` (default ``/etc/reticulum``) when ``config`` exists
2. ``~/.config/reticulum`` when ``config`` exists
3. ``~/.reticulum`` when ``config`` exists
"""
user_home = home if home is not None else os.path.expanduser("~")
candidates = [
system_dir,
os.path.join(user_home, ".config", "reticulum"),
os.path.join(user_home, ".reticulum"),
]
for path in candidates:
if os.path.isdir(path) and os.path.isfile(os.path.join(path, "config")):
return os.path.abspath(path)
return None
def resolve_reticulum_config_dir(
explicit: str | None,
bot_config_path: str,
*,
environ: dict[str, str] | None = None,
home: str | None = None,
system_dir: str = "/etc/reticulum",
) -> str:
"""Resolve the Reticulum config directory for an LXMFy bot.
Prefer an explicit path or ``LXMFY_RETICULUM_CONFIG_DIR``, then an existing
user/system Reticulum config. Fall back to the bot config directory only
when nothing else is available.
"""
env = environ if environ is not None else os.environ
if explicit:
return os.path.abspath(os.path.expanduser(explicit))
env_dir = env.get("LXMFY_RETICULUM_CONFIG_DIR")
if env_dir:
return os.path.abspath(os.path.expanduser(env_dir))
discovered = discover_user_reticulum_config_dir(
home=home,
system_dir=system_dir,
)
if discovered:
return discovered
return os.path.abspath(bot_config_path)
def is_isolated_reticulum_dir(reticulum_config_dir: str, bot_config_path: str) -> bool:
"""True when the bot uses its own config path as the Reticulum config dir."""
return os.path.abspath(reticulum_config_dir) == os.path.abspath(bot_config_path)
def ensure_isolated_share_instance_disabled(reticulum_config_dir: str) -> bool:
"""Ensure an isolated Reticulum config does not join the default shared instance.
Different config directories derive different RPC authkeys. Sharing the default
instance ports/sockets then fails with ``AuthenticationError: digest sent was
rejected`` (the failure NomadNet/Columba show when an LXMFy bot collides with
them). Isolated bot configs must set ``share_instance = No``.
Returns:
True if the config file was created or modified.
"""
config_path = Path(reticulum_config_dir) / "config"
os.makedirs(reticulum_config_dir, exist_ok=True)
if not config_path.is_file():
config_path.write_text(
"[reticulum]\n"
"enable_transport = Yes\n"
"share_instance = No\n"
"\n"
"[logging]\n"
"loglevel = 4\n"
"\n"
"[interfaces]\n",
encoding="utf-8",
)
logger.info(
"Created isolated Reticulum config with share_instance=No at %s",
config_path,
)
return True
text = config_path.read_text(encoding="utf-8")
match = _SHARE_INSTANCE_RE.search(text)
if match:
current = match.group(2).strip().lower()
if current in {"no", "false", "0"}:
return False
updated = _SHARE_INSTANCE_RE.sub(
lambda m: f"{m.group(1)}No{m.group(3)}",
text,
count=1,
)
config_path.write_text(updated, encoding="utf-8")
logger.warning(
"Forced share_instance=No in %s to avoid RNS shared-instance "
"RPC digest rejection with other apps (NomadNet, Columba, rnsd)",
config_path,
)
return True
if re.search(r"(?im)^\[reticulum\]\s*$", text):
updated = re.sub(
r"(?im)^(\[reticulum\]\s*)$",
r"\1\nshare_instance = No",
text,
count=1,
)
else:
updated = "[reticulum]\nshare_instance = No\n\n" + text
config_path.write_text(updated, encoding="utf-8")
logger.warning(
"Added share_instance=No to %s to avoid RNS shared-instance "
"RPC digest rejection with other apps (NomadNet, Columba, rnsd)",
config_path,
)
return True

70
vendor/lxmfy/lxmfy/rrc/__init__.py vendored Normal file
View file

@ -0,0 +1,70 @@
"""Reticulum Relay Chat (RRC) support for LXMFy bots.
Bots are ordinary RRC clients: they open an RNS Link to a hub, send HELLO,
join rooms, and exchange CBOR-encoded envelopes. See https://rrc.kc1awv.net/
"""
from .client import RRCClient, RRCMessage
from .constants import (
CLIENT_NAME,
CLIENT_VERSION,
DEFAULT_DEST_NAME,
RRC_VERSION,
STATUS_CONNECTED,
STATUS_CONNECTING,
STATUS_DISCONNECTED,
STATUS_FAILED,
T_ACTION,
T_ERROR,
T_HELLO,
T_JOIN,
T_JOINED,
T_MSG,
T_NOTICE,
T_PART,
T_PARTED,
T_PING,
T_PONG,
T_RESOURCE_ENVELOPE,
T_WELCOME,
)
from .envelope import (
decode_envelope,
encode_envelope,
make_envelope,
normalize_room,
validate_envelope,
)
from .manager import RRCManager
__all__ = [
"CLIENT_NAME",
"CLIENT_VERSION",
"DEFAULT_DEST_NAME",
"RRCClient",
"RRCManager",
"RRCMessage",
"RRC_VERSION",
"STATUS_CONNECTED",
"STATUS_CONNECTING",
"STATUS_DISCONNECTED",
"STATUS_FAILED",
"T_ACTION",
"T_ERROR",
"T_HELLO",
"T_JOIN",
"T_JOINED",
"T_MSG",
"T_NOTICE",
"T_PART",
"T_PARTED",
"T_PING",
"T_PONG",
"T_RESOURCE_ENVELOPE",
"T_WELCOME",
"decode_envelope",
"encode_envelope",
"make_envelope",
"normalize_room",
"validate_envelope",
]

1110
vendor/lxmfy/lxmfy/rrc/client.py vendored Normal file

File diff suppressed because it is too large Load diff

92
vendor/lxmfy/lxmfy/rrc/constants.py vendored Normal file
View file

@ -0,0 +1,92 @@
"""RRC protocol constants aligned with the RRC wire encoding and rrcd.
See https://rrc.kc1awv.net/ and https://github.com/kc1awv/rrcd
"""
RRC_VERSION = 1
DEFAULT_DEST_NAME = "rrc.hub"
DEFAULT_MAX_NICK_BYTES = 32
DEFAULT_MAX_ROOM_BYTES = 64
DEFAULT_MAX_MSG_BYTES = 350
DEFAULT_MAX_ROOMS = 32
DEFAULT_RATE_PER_MINUTE = 240
# Client-side memory guards (not wire protocol limits)
MAX_TRACKED_NICKS = 2048
MAX_MEMBERS_PER_ROOM = 1024
MAX_RESOURCE_EXPECTATIONS = 32
MAX_PENDING_PINGS = 64
CLIENT_NAME = "lxmfy"
CLIENT_VERSION = "2.0.0"
K_V = 0
K_T = 1
K_ID = 2
K_TS = 3
K_SRC = 4
K_ROOM = 5
K_BODY = 6
K_NICK = 7
K_DST = 8
T_HELLO = 1
T_WELCOME = 2
T_JOIN = 10
T_JOINED = 11
T_PART = 12
T_PARTED = 13
T_MSG = 20
T_NOTICE = 21
T_ACTION = 22
T_PING = 30
T_PONG = 31
T_ERROR = 40
T_RESOURCE_ENVELOPE = 50
B_HELLO_NAME = 0
B_HELLO_VER = 1
B_HELLO_CAPS = 2
B_WELCOME_HUB = 0
B_WELCOME_VER = 1
B_WELCOME_CAPS = 2
B_WELCOME_LIMITS = 3
L_MAX_NICK_BYTES = 0
L_MAX_ROOM_NAME_BYTES = 1
L_MAX_MSG_BODY_BYTES = 2
L_MAX_ROOMS_PER_SESSION = 3
L_RATE_LIMIT_MSGS_PER_MINUTE = 4
CAP_RESOURCE_ENVELOPE = 0
CAP_ACTION = 1
CAP_DIRECT_NOTICE = 2
B_RES_ID = 0
B_RES_KIND = 1
B_RES_SIZE = 2
B_RES_SHA256 = 3
B_RES_ENCODING = 4
RES_KIND_NOTICE = "notice"
RES_KIND_MOTD = "motd"
RES_KIND_BLOB = "blob"
STATUS_DISCONNECTED = 0
STATUS_CONNECTING = 1
STATUS_CONNECTED = 2
STATUS_FAILED = 3
STATUS_NAMES = {
STATUS_DISCONNECTED: "disconnected",
STATUS_CONNECTING: "connecting",
STATUS_CONNECTED: "connected",
STATUS_FAILED: "failed",
}

149
vendor/lxmfy/lxmfy/rrc/envelope.py vendored Normal file
View file

@ -0,0 +1,149 @@
"""CBOR envelope helpers for Reticulum Relay Chat."""
from __future__ import annotations
import os
import time
from typing import Any
import cbor2
from .constants import (
K_BODY,
K_ID,
K_NICK,
K_ROOM,
K_SRC,
K_T,
K_TS,
K_V,
RRC_VERSION,
)
def now_ms() -> int:
"""Return milliseconds since the Unix epoch."""
return int(time.time() * 1000)
def msg_id() -> bytes:
"""Return an 8-byte cryptographically random message id."""
return os.urandom(8)
def normalize_room(room: str | None) -> str:
"""Normalize a room name for case-insensitive matching."""
if not isinstance(room, str):
raise ValueError("room must be a non-empty string")
normalized = room.strip().lower()
if not normalized:
raise ValueError("room must not be empty")
return normalized
def make_envelope(
msg_type: int,
src: bytes,
room: str | None = None,
body: Any = None,
nick: str | None = None,
mid: bytes | None = None,
ts: int | None = None,
) -> dict[int, Any]:
"""Build a canonical RRC envelope map with integer keys."""
if not isinstance(src, (bytes, bytearray)) or len(src) != 16:
raise ValueError("src must be a 16-byte identity hash")
env: dict[int, Any] = {
K_V: RRC_VERSION,
K_T: int(msg_type),
K_ID: mid or msg_id(),
K_TS: ts if ts is not None else now_ms(),
K_SRC: bytes(src),
}
if room is not None:
env[K_ROOM] = room
if body is not None:
env[K_BODY] = body
if nick is not None and nick != "":
env[K_NICK] = nick
return env
def encode_envelope(env: dict[int, Any]) -> bytes:
"""Encode an RRC envelope to CBOR bytes."""
return cbor2.dumps(env)
def decode_envelope(data: bytes | bytearray) -> dict[int, Any] | None:
"""Decode CBOR bytes into an RRC envelope map.
Returns None for malformed payloads. Unknown keys are preserved so
callers can ignore them per forward-compatibility rules.
"""
try:
env = cbor2.loads(bytes(data))
except Exception:
return None
if not isinstance(env, dict):
return None
return env
def envelope_type(env: dict[int, Any]) -> int | None:
"""Return the message type from an envelope, or None if missing."""
value = env.get(K_T)
return int(value) if isinstance(value, int) else None
def envelope_room(env: dict[int, Any]) -> str | None:
"""Return a normalized room name from an envelope, if present."""
room = env.get(K_ROOM)
if not isinstance(room, str) or not room.strip():
return None
return room.strip().lower()
def envelope_src(env: dict[int, Any]) -> bytes | None:
"""Return the sender identity hash from an envelope, if present."""
src = env.get(K_SRC)
if isinstance(src, (bytes, bytearray)) and len(src) == 16:
return bytes(src)
return None
def envelope_nick(env: dict[int, Any]) -> str | None:
"""Return the advisory nickname from an envelope, if present."""
nick = env.get(K_NICK)
return nick if isinstance(nick, str) and nick else None
def envelope_body(env: dict[int, Any]) -> Any:
"""Return the body field from an envelope."""
return env.get(K_BODY)
def validate_envelope(env: dict[int, Any]) -> bool:
"""Return True when required envelope fields are present and well-formed."""
if not isinstance(env, dict):
return False
version = env.get(K_V)
if version is not None and version != RRC_VERSION:
return False
if not isinstance(env.get(K_T), int):
return False
mid = env.get(K_ID)
if not isinstance(mid, (bytes, bytearray)) or len(mid) != 8:
return False
ts = env.get(K_TS)
if ts is not None and not isinstance(ts, int):
return False
src = env.get(K_SRC)
if not isinstance(src, (bytes, bytearray)) or len(src) != 16:
return False
room = env.get(K_ROOM)
if room is not None and not isinstance(room, str):
return False
nick = env.get(K_NICK)
if nick is not None and not isinstance(nick, str):
return False
return True

275
vendor/lxmfy/lxmfy/rrc/manager.py vendored Normal file
View file

@ -0,0 +1,275 @@
"""Manage multiple RRC hub sessions for an LXMFy bot."""
from __future__ import annotations
import logging
from collections.abc import Callable
from typing import Any
import RNS
from .client import RRCClient
from .constants import DEFAULT_DEST_NAME
from .envelope import normalize_room
logger = logging.getLogger(__name__)
EventCallback = Callable[[str, RRCClient, Any], None]
class RRCManager:
"""Owns RRCClient sessions and fans events out to bot handlers."""
STORAGE_KEY = "rrc_sessions"
def __init__(
self,
identity: RNS.Identity | None = None,
nick: str | None = None,
dest_name: str = DEFAULT_DEST_NAME,
auto_reconnect: bool = True,
storage=None,
persist_sessions: bool = False,
):
self.identity = identity
self.nick = nick
self.dest_name = dest_name or DEFAULT_DEST_NAME
self.auto_reconnect = auto_reconnect
self.storage = storage
self.persist_sessions = bool(persist_sessions)
self.clients: dict[bytes, RRCClient] = {}
self._handlers: list[EventCallback] = []
def on_event(self, callback: EventCallback) -> EventCallback:
"""Register an event callback. Returns the callback for decorator use."""
self._handlers.append(callback)
return callback
def _dispatch(self, event: str, client: RRCClient, payload: Any) -> None:
for handler in list(self._handlers):
try:
handler(event, client, payload)
except Exception:
logger.exception("RRC manager handler failed for %s", event)
if event in ("joined", "parted"):
self.save_sessions()
def connect(
self,
hub_hash: bytes | str,
rooms: list[str] | None = None,
nick: str | None = None,
dest_name: str | None = None,
auto_reconnect: bool | None = None,
) -> RRCClient:
"""Connect to an RRC hub. Returns the client session."""
if self.identity is None:
raise RuntimeError("RRC manager has no identity")
if isinstance(hub_hash, str):
hub_bytes = bytes.fromhex(hub_hash)
else:
hub_bytes = bytes(hub_hash)
existing = self.clients.get(hub_bytes)
if existing is not None:
if rooms:
existing.set_auto_join(rooms)
with existing._lock:
for room in rooms:
if isinstance(room, str):
existing._rejoin_rooms.add(normalize_room(room))
if nick is not None:
existing.set_nick(nick)
if not existing.connected:
existing.connect()
elif rooms:
for room in rooms:
try:
existing.join(room)
except Exception:
logger.exception("Failed joining room %s", room)
self.save_sessions()
return existing
client = RRCClient(
identity=self.identity,
hub_hash=hub_bytes,
dest_name=dest_name or self.dest_name,
nick=nick if nick is not None else self.nick,
auto_reconnect=(
self.auto_reconnect if auto_reconnect is None else auto_reconnect
),
on_event=self._dispatch,
)
if rooms:
client.set_auto_join(rooms)
with client._lock:
for room in rooms:
if isinstance(room, str):
client._rejoin_rooms.add(normalize_room(room))
self.clients[hub_bytes] = client
client.connect()
self.save_sessions()
return client
def disconnect(self, hub_hash: bytes | str | None = None) -> None:
"""Disconnect one hub, or all hubs when hub_hash is None."""
if hub_hash is None:
for client in list(self.clients.values()):
client.disconnect()
self.clients.clear()
self.save_sessions()
return
hub_bytes = (
bytes.fromhex(hub_hash) if isinstance(hub_hash, str) else bytes(hub_hash)
)
client = self.clients.pop(hub_bytes, None)
if client is not None:
client.disconnect()
self.save_sessions()
def get(self, hub_hash: bytes | str) -> RRCClient | None:
"""Return a connected client for a hub hash, if present."""
hub_bytes = (
bytes.fromhex(hub_hash) if isinstance(hub_hash, str) else bytes(hub_hash)
)
return self.clients.get(hub_bytes)
def send_message(
self,
room: str,
text: str,
hub_hash: bytes | str | None = None,
) -> bytes:
"""Send a MSG on the first connected hub, or a specific hub."""
client = self._require_client(hub_hash)
return client.send_message(room, text)
def send_notice(
self,
room: str,
text: str,
hub_hash: bytes | str | None = None,
) -> bytes:
"""Send a NOTICE on the first connected hub, or a specific hub."""
client = self._require_client(hub_hash)
return client.send_notice(room, text)
def send_action(
self,
room: str,
text: str,
hub_hash: bytes | str | None = None,
) -> bytes:
"""Send an ACTION on the first connected hub, or a specific hub."""
client = self._require_client(hub_hash)
return client.send_action(room, text)
def join(
self,
room: str,
hub_hash: bytes | str | None = None,
) -> str:
"""Join a room on the first connected hub, or a specific hub."""
client = self._require_client(hub_hash)
room_n = client.join(room)
self.save_sessions()
return room_n
def part(
self,
room: str,
hub_hash: bytes | str | None = None,
) -> str:
"""Part a room on the first connected hub, or a specific hub."""
client = self._require_client(hub_hash)
room_n = client.part(room)
self.save_sessions()
return room_n
def _require_client(self, hub_hash: bytes | str | None) -> RRCClient:
if hub_hash is not None:
client = self.get(hub_hash)
if client is None:
raise RuntimeError("RRC hub not connected")
return client
for client in self.clients.values():
if client.connected:
return client
raise RuntimeError("No connected RRC hub")
def status(self) -> list[dict[str, Any]]:
"""Return status snapshots for all hub sessions."""
return [client.status_dict() for client in self.clients.values()]
def save_sessions(self) -> None:
"""Persist hub hashes, rooms, and nick for crash recovery."""
if not self.persist_sessions or self.storage is None:
return
entries = []
for client in self.clients.values():
with client._lock:
rooms = sorted(
set(client.rooms)
| set(client._rejoin_rooms)
| set(client._auto_join_rooms),
)
entries.append(
{
"hub_hash": client.hub_hash.hex(),
"dest_name": client.dest_name,
"nick": client.nick,
"rooms": rooms,
"auto_reconnect": client.auto_reconnect,
},
)
try:
self.storage.set(self.STORAGE_KEY, entries)
except Exception:
logger.exception("Failed to persist RRC sessions")
def restore_sessions(self) -> int:
"""Restore persisted hub sessions. Returns number of hubs reconnected."""
if not self.persist_sessions or self.storage is None:
return 0
entries = self.storage.get(self.STORAGE_KEY, [])
if not isinstance(entries, list):
return 0
restored = 0
for entry in entries:
if not isinstance(entry, dict):
continue
hub_hash = entry.get("hub_hash")
if not isinstance(hub_hash, str):
continue
try:
self.connect(
hub_hash,
rooms=entry.get("rooms")
if isinstance(entry.get("rooms"), list)
else None,
nick=entry.get("nick")
if isinstance(entry.get("nick"), str)
else None,
dest_name=entry.get("dest_name")
if isinstance(entry.get("dest_name"), str)
else None,
auto_reconnect=entry.get("auto_reconnect")
if isinstance(entry.get("auto_reconnect"), bool)
else None,
)
restored += 1
except Exception:
logger.exception("Failed to restore RRC hub %s", hub_hash)
return restored
def shutdown(self) -> None:
"""Persist session state and tear down links without forgetting hubs."""
self.save_sessions()
for client in list(self.clients.values()):
try:
client.disconnect()
except Exception:
logger.exception("RRC client disconnect failed during shutdown")

View file

@ -7,5 +7,6 @@ from .cog_test_bot import CogTestBot
from .echo_bot import EchoBot
from .note_bot import NoteBot
from .reminder_bot import ReminderBot
from .rrc_bot import RRCBot
__all__ = ["CogTestBot", "EchoBot", "NoteBot", "ReminderBot"]
__all__ = ["CogTestBot", "EchoBot", "NoteBot", "ReminderBot", "RRCBot"]

79
vendor/lxmfy/lxmfy/templates/rrc_bot.py vendored Normal file
View file

@ -0,0 +1,79 @@
"""RRC room bot template that joins hubs and echoes mentions."""
from __future__ import annotations
from lxmfy import LXMFBot, RRCMessage
DEFAULT_RRC_HUB = "664fc0e8d2e448658e37bb3f34e6c88f"
DEFAULT_RRC_ROOMS = ["general"]
class RRCBot:
"""Bot that participates in Reticulum Relay Chat rooms."""
def __init__(
self,
hubs: list[str] | None = None,
rooms: list[str] | None = None,
nick: str | None = None,
test_mode: bool = False,
reticulum_config_dir: str | None = None,
):
"""Initialize an RRC-capable bot.
Args:
hubs: Hub destination hashes (hex) to join on startup.
rooms: Rooms to auto-join after WELCOME.
nick: Nickname advertised on HELLO and room messages.
test_mode: Skip RNS initialization when True.
reticulum_config_dir: Optional Reticulum config directory override.
"""
resolved_hubs = list(hubs) if hubs is not None else [DEFAULT_RRC_HUB]
resolved_rooms = list(rooms) if rooms is not None else list(DEFAULT_RRC_ROOMS)
self.bot = LXMFBot(
name=nick or "RRC Bot",
announce=600,
announce_enabled=True,
first_message_enabled=True,
test_mode=test_mode,
reticulum_config_dir=reticulum_config_dir,
rrc_enabled=bool(resolved_hubs) and not test_mode,
rrc_hubs=resolved_hubs,
rrc_rooms=resolved_rooms,
rrc_nick=nick or "RRCBot",
rrc_auto_reconnect=True,
)
self.setup_handlers()
def setup_handlers(self) -> None:
"""Register LXMF and RRC handlers."""
@self.bot.on_first_message()
def welcome(sender, message):
self.bot.send(
sender,
"RRC bot online. I join configured hubs and reply to @mentions in rooms.",
)
return True
@self.bot.on_rrc
def on_rrc(event, client, payload):
if event == "welcome":
self.bot.logger.info(
"RRC welcomed by %s",
payload.get("hub_name") if isinstance(payload, dict) else "hub",
)
return
if event != "msg" or not isinstance(payload, RRCMessage):
return
if payload.mention and payload.room:
reply = f"{payload.nick or 'someone'} mentioned me: {payload.text}"
try:
client.send_message(payload.room, reply)
except Exception as exc:
self.bot.logger.error("RRC reply failed: %s", exc)
def run(self) -> None:
"""Run the bot event loop."""
self.bot.run()

67
vendor/lxmfy/poetry.lock generated vendored
View file

@ -17,6 +17,65 @@ files = [
docs = ["furo", "jaraco.packaging (>=9.3)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"]
testing = ["jaraco.test", "pytest (!=8.0.*)", "pytest (>=6,!=8.1.*)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=2.2)"]
[[package]]
name = "cbor2"
version = "6.1.3"
description = "CBOR (de)serializer with extensive tag support"
optional = false
python-versions = ">=3.10"
groups = ["main"]
files = [
{file = "cbor2-6.1.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:20205c698f9ea4918d1714c459f58f46464c9f0d1e467679da8bca9e4eb17d1c"},
{file = "cbor2-6.1.3-cp310-cp310-manylinux_2_28_aarch64.whl", hash = "sha256:4e2e1c8ba6651e12fcf35188f24cbd2344ce0acb087cb7739e5bfb8cec6e12ea"},
{file = "cbor2-6.1.3-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:a7bcc957049da506d6e59b4edb5594c68d81a1fa43b56f826839cb15e9a82572"},
{file = "cbor2-6.1.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:d9971a2052802422efbbe11fc918c0f784de7280d2e0f45a6e9a8a0dc44e8f53"},
{file = "cbor2-6.1.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:165ce348632a4b502d0eb5ff187138d3b34986224273faf2233f842a9331c3ff"},
{file = "cbor2-6.1.3-cp310-cp310-win32.whl", hash = "sha256:9084077c4cd7e905ebe47677934c9c6580942c9d1294f99524c0369b10829e78"},
{file = "cbor2-6.1.3-cp310-cp310-win_amd64.whl", hash = "sha256:72b23df796d083ff53d561fd86e397aa5c19010192e85ea3838d8ef21c09db6d"},
{file = "cbor2-6.1.3-cp310-cp310-win_arm64.whl", hash = "sha256:fcec149218bacf1f98caf44225b67b4908e54e0440ca446ea488aac1ddc85a3b"},
{file = "cbor2-6.1.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:84edab2df31c981d258d652a82a3e30eb7368d86d9d7284216282f65403a1e00"},
{file = "cbor2-6.1.3-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:932e0894476fad36b186c0da6e9b1433358bea564a60ae4799e51182568ff29f"},
{file = "cbor2-6.1.3-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:5f14159423a984c387982901f67313d6582251b6733c23e8bd925d73173691bf"},
{file = "cbor2-6.1.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:622ec874664b4db54bc6df40f82832ad30fa5c875ad85cde84392ac62bb33d15"},
{file = "cbor2-6.1.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:c9144756fa7c9298d5882d1f7ad379c4a0059803a4c70329965a000bc79bf02d"},
{file = "cbor2-6.1.3-cp311-cp311-win32.whl", hash = "sha256:144f8cfd2e9149389c34026243aeb646184cd78a2c657822be9bc9e7a2c5f3f5"},
{file = "cbor2-6.1.3-cp311-cp311-win_amd64.whl", hash = "sha256:187fd06befc59e6cafafc2709e5f1f3df8afe8bca5646f9cb5b70fc7e6ab1783"},
{file = "cbor2-6.1.3-cp311-cp311-win_arm64.whl", hash = "sha256:43f0f694f47958de50fc84e6268a3015cc2a7fce88b231456c053bc5a1c6c828"},
{file = "cbor2-6.1.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3d939f55097c21e032f5a2d67592fcc57298986281f219356e2f519e4466f4ea"},
{file = "cbor2-6.1.3-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:b025009478d644dab407164fd60e3ef4381af284f5af6966df94c663756d949e"},
{file = "cbor2-6.1.3-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:2226d32e102e375737656ad5d141ad8c6ae3e705e04e263f24756f0eb379c6c1"},
{file = "cbor2-6.1.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e61d465244d66ffed36492eef3b44d43795d76a2bba0663a2f15c186af7f7513"},
{file = "cbor2-6.1.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:87fe7be8fab6ec4796aa127c1a52e09e79dbafd2aa31caf809cf04b8080a5975"},
{file = "cbor2-6.1.3-cp312-cp312-win32.whl", hash = "sha256:da25d345f01e6a40b2e5c57ef96b4dcff7be69394fb62f0f70e07f437f2376a9"},
{file = "cbor2-6.1.3-cp312-cp312-win_amd64.whl", hash = "sha256:d5514f693db6fa6f433b4096e9b604e6a7bf151c9ef1d2db86d0858e4c5e768f"},
{file = "cbor2-6.1.3-cp312-cp312-win_arm64.whl", hash = "sha256:3d43183d7beb3d3cd198d69b31bd2ee487ed704a1150c75cb0a66d6ad63d8c1a"},
{file = "cbor2-6.1.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:21c74b8ab67977c8b87b727247eeb730145b0068ad6d47f71e9f80f6b48c65f8"},
{file = "cbor2-6.1.3-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:f291a0ae4c1ed96eadb0afa9752568c7424f7d6fa818676d5e33005fcd22ddd9"},
{file = "cbor2-6.1.3-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:dc8e44c7bf172687195dcd428157885bc00ea06efc0ea30fb371163b92bef733"},
{file = "cbor2-6.1.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:8ccf4d263983d830dd429d2b01f27be58ac02ba7c790c45d861f767eb63963e5"},
{file = "cbor2-6.1.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8719a7a2a2a82168844533389957b8f617a139f5f40e4d0ad7ed905fd3abebd1"},
{file = "cbor2-6.1.3-cp313-cp313-win32.whl", hash = "sha256:c73b54ce09dd8d522f3c1540426e36172ba0f34abf3d89eb93909a5e14590003"},
{file = "cbor2-6.1.3-cp313-cp313-win_amd64.whl", hash = "sha256:b77df56c462c10eb3444db8ef78d8c3e71d9ef8d021ea92e97c1f9e3aa918690"},
{file = "cbor2-6.1.3-cp313-cp313-win_arm64.whl", hash = "sha256:b144be2ab3e9584ee7b6359d2a92fee0a5bec1d00dbd34c215ccc2040ac0b2ab"},
{file = "cbor2-6.1.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:3f3167ca9920b90db4ff72652db109dfd93b56ee0d583aca12f3a5d9a7019477"},
{file = "cbor2-6.1.3-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:48c677971e4b71e685491e1a267d9924dc205e7ddbe3f34fd2562f16c0f6bfca"},
{file = "cbor2-6.1.3-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:ad4f3c6dfc6b83331eb04c6975efb2839ab65a3aa81502bc2b3f7945d4c4aa44"},
{file = "cbor2-6.1.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:8bcd609eab4a39745123bfb28e73311abba3d14975a87f5906e0e8b910d918ac"},
{file = "cbor2-6.1.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:672727ecb27d7fb3ca0bf8a58fc489d5374ab1fee680ed3d0348a11d9d3ca78f"},
{file = "cbor2-6.1.3-cp314-cp314-win32.whl", hash = "sha256:1413cef2aa7f478a38298cd3492a055e8e8e45d17fb53bbe103e79ca15c33f3c"},
{file = "cbor2-6.1.3-cp314-cp314-win_amd64.whl", hash = "sha256:59df264d4a508ba61daaa0bf3c2f92d63275509549a0875c1fa38176f651e4f8"},
{file = "cbor2-6.1.3-cp314-cp314-win_arm64.whl", hash = "sha256:b62b5d80a0eb4305cd5f0217faa4d7747bd64fe0dff9b88415e7be3782f8249b"},
{file = "cbor2-6.1.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:856ab525bfc599588b8d2a45babb7c3400c693ea0bb574d818467d997102fe24"},
{file = "cbor2-6.1.3-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:a076f35abf1dd0a4de6e2f7f4d4932abafc951a26275b6aa4a3b370c2fd3bbf4"},
{file = "cbor2-6.1.3-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:37ccab1d0bd3f57ff536a41e44165d0c99cb166ac6e5ffb8b93c42304b56e48d"},
{file = "cbor2-6.1.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:76d257ce797e651fa430b0269e8e8c43549c54ce8b0d12860569b3709bf1326f"},
{file = "cbor2-6.1.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:4af35baadb66f7c9cbb3998eab469767c04641552416c1c69dd4d3d183797119"},
{file = "cbor2-6.1.3-cp314-cp314t-win32.whl", hash = "sha256:61c92661665bccfed4ffa69d1fe10097f2c820d262f56a8cf909a5ebf9f6d8c6"},
{file = "cbor2-6.1.3-cp314-cp314t-win_amd64.whl", hash = "sha256:dc6bcd030bf5043662b84b0ca0f0ca942491bf509105db30cedca6e2ce82d158"},
{file = "cbor2-6.1.3-cp314-cp314t-win_arm64.whl", hash = "sha256:da98a5e0ae9487bed497ac74e2c850b49975a3b7b5314b76c3843e2c83a6c8c4"},
{file = "cbor2-6.1.3.tar.gz", hash = "sha256:8d70680acb55c04ea5b5ad86da094f9612b53d5a8a65d0f5b3aafc3ce917ecbb"},
]
[[package]]
name = "certifi"
version = "2026.5.20"
@ -1105,14 +1164,14 @@ jupyter = ["ipywidgets (>=7.5.1,<9)"]
[[package]]
name = "rns"
version = "1.3.7"
version = "1.3.8"
description = "Self-configuring, encrypted and resilient mesh networking stack for LoRa, packet radio, WiFi and everything in between"
optional = false
python-versions = ">=3.7"
groups = ["main"]
files = [
{file = "rns-1.3.7-py3-none-any.whl", hash = "sha256:95d3bab98513e7f4318ea2dc0b743a21ce9e3c94caee2eb587203ca4b61fd9ed"},
{file = "rns-1.3.7.tar.gz", hash = "sha256:6757db65c0af212b386b7e44b95eda4e36d6b11cae834258bc6d2d11dac6e125"},
{file = "rns-1.3.8-py3-none-any.whl", hash = "sha256:c7f60abc7a2e42869df5be22335cf48a467c988ad67320e55301a183542e4a78"},
{file = "rns-1.3.8.tar.gz", hash = "sha256:d5c1f39493aa9b75ab67b83997d4ad5bd357e67e9d629ffa294e31a2ffde347d"},
]
[package.dependencies]
@ -1256,4 +1315,4 @@ type = ["pytest-mypy (>=1.0.1) ; platform_python_implementation != \"PyPy\""]
[metadata]
lock-version = "2.1"
python-versions = ">=3.11"
content-hash = "54c949917349ad5896898e82965b442240f489adc3dfce0f36be022979f4ad08"
content-hash = "701cabb3131f8438f4badabe8f2bf9dc97cc748af3011fda3aeda637bc572706"

View file

@ -1,12 +1,12 @@
[project]
name = "lxmfy"
version = "1.6.5"
version = "2.0.1"
description = "LXMF bot framework for creating bots for the Reticulum Network"
authors = [{name = "Quad4", email = "team@quad4.io"}]
readme = "README.md"
license = "BSD-0-Clause"
requires-python = ">=3.11"
keywords = ["lxmf", "reticulum", "bot", "framework", "rns"]
keywords = ["lxmf", "reticulum", "bot", "framework", "rns", "rrc", "cbor"]
classifiers = [
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.11",
@ -15,8 +15,9 @@ classifiers = [
"Operating System :: OS Independent",
]
dependencies = [
"lxmf>=1.1.0",
"rns>=1.4.0"
"lxmf>=1.0.1",
"rns>=1.3.9",
"cbor2>=5.4.0",
]
[project.urls]

View file

@ -1,4 +1,4 @@
[tool:pytest]
[pytest]
testpaths = tests
python_files = test_*.py
python_classes = Test*
@ -6,10 +6,6 @@ python_functions = test_*
addopts =
--verbose
--tb=short
--cov=lxmfy
--cov-report=term-missing
--cov-report=html:htmlcov
--cov-fail-under=80
markers =
slow: marks tests as slow (deselect with '-m "not slow"')
integration: marks tests as integration tests

View file

@ -155,7 +155,7 @@ class TestInputFunctions:
@patch("lxmfy.cli.print_error")
def test_get_template_choice_invalid_then_valid(self, mock_print_error, mock_input):
"""Test get_template_choice with invalid then valid input."""
mock_input.side_effect = ["6", "3"] # Invalid then reminder
mock_input.side_effect = ["7", "3"] # Invalid then reminder
result = get_template_choice()
assert result == "reminder"
mock_print_error.assert_called_once()

View file

@ -15,9 +15,9 @@ class TestClientBotCommunication:
original_queue_put = test_bot.queue.put
queued_messages = []
def capture_queue_put(message):
def capture_queue_put(message, *args, **kwargs):
queued_messages.append(message)
return original_queue_put(message)
return original_queue_put(message, *args, **kwargs)
test_bot.queue.put = capture_queue_put

416
vendor/lxmfy/tests/test_live_lxmf.py vendored Normal file
View file

@ -0,0 +1,416 @@
"""Live LXMF roundtrip over random online public TCP/backbone nodes.
Fetches online nodes from https://directory.rns.recipes, picks reachable
TCP/backbone entrypoints at random, connects two LXMFy bots through them,
and requires a ping/pong roundtrip.
Requires network access and LXMFY_LIVE_LXMF=1. Skips otherwise.
"""
from __future__ import annotations
import json
import multiprocessing
import os
import random
import socket
import sys
import time
import urllib.error
import urllib.request
from pathlib import Path
import pytest
DIRECTORY_URL = (
"https://directory.rns.recipes/api/directory/submitted?search=&type=&status=online"
)
LIVE_ENABLED = os.environ.get("LXMFY_LIVE_LXMF", "").strip().lower() in {
"1",
"true",
"yes",
}
NODE_TYPES = {"tcp", "backbone"}
CONNECT_TRIES = int(os.environ.get("LXMFY_LIVE_LXMF_TRIES", "4"))
PATH_TIMEOUT_S = int(os.environ.get("LXMFY_LIVE_LXMF_PATH_TIMEOUT", "60"))
ROUNDTRIP_TIMEOUT_S = int(os.environ.get("LXMFY_LIVE_LXMF_ROUNDTRIP_TIMEOUT", "90"))
def _log(msg: str) -> None:
print(msg, flush=True)
def _fetch_online_nodes(url: str = DIRECTORY_URL) -> list[dict]:
with urllib.request.urlopen(url, timeout=30) as resp:
payload = json.loads(resp.read().decode("utf-8"))
rows = payload.get("data", payload) if isinstance(payload, dict) else payload
if not isinstance(rows, list):
return []
nodes = []
for row in rows:
if not isinstance(row, dict):
continue
if str(row.get("status", "")).lower() != "online":
continue
if str(row.get("type", "")).lower() not in NODE_TYPES:
continue
if not row.get("host") or not row.get("port"):
continue
# Prefer clearnet TCP host:port targets for live CI hosts.
host = str(row["host"])
if ":" in host and not host.replace(":", "").isdigit():
# Skip Yggdrasil / IPv6-literal-only style hosts unless bracketed.
if host.count(":") > 1 and not host.startswith("["):
continue
nodes.append(row)
return nodes
def _tcp_reachable(host: str, port: int, timeout: float = 3.0) -> bool:
try:
with socket.create_connection((host, int(port)), timeout=timeout):
return True
except OSError:
return False
def _pick_reachable_nodes(nodes: list[dict], limit: int = CONNECT_TRIES) -> list[dict]:
tcp_first = [n for n in nodes if str(n.get("type", "")).lower() == "tcp"]
backbone = [n for n in nodes if str(n.get("type", "")).lower() == "backbone"]
random.shuffle(tcp_first)
random.shuffle(backbone)
chosen: list[dict] = []
for node in tcp_first + backbone:
host = str(node["host"])
port = int(node["port"])
ok = _tcp_reachable(host, port)
_log(f"probe {node.get('name')} {host}:{port} -> {ok}")
if ok:
chosen.append(node)
if len(chosen) >= limit:
break
return chosen
def _interface_block(node: dict) -> str:
raw = node.get("config")
if isinstance(raw, str) and "type =" in raw:
return raw.strip() + "\n"
name = str(node.get("name") or "LiveNode").replace("]", "")
host = node["host"]
port = int(node["port"])
ntype = str(node.get("type", "tcp")).lower()
if ntype == "backbone":
return (
f"[[{name}]]\n"
f" type = BackboneInterface\n"
f" enabled = Yes\n"
f" remote = {host}\n"
f" target_port = {port}\n"
)
return (
f"[[{name}]]\n"
f" type = TCPClientInterface\n"
f" enabled = Yes\n"
f" target_host = {host}\n"
f" target_port = {port}\n"
)
def _write_rns_config(path: Path, node: dict) -> None:
path.mkdir(parents=True, exist_ok=True)
(path / "config").write_text(
"[reticulum]\n"
"enable_transport = No\n"
"share_instance = No\n"
"\n"
"[logging]\n"
"loglevel = 3\n"
"\n"
"[interfaces]\n"
f"{_interface_block(node)}",
encoding="utf-8",
)
def _drain_outbound(bot) -> None:
while not bot.queue.empty():
try:
lxm = bot.queue.get(block=False)
except Exception:
break
try:
if bot.router:
bot.router.handle_outbound(lxm)
except Exception as e:
_log(f"outbound error: {e}")
def _bot_worker(
role: str,
config_dir: str,
peer_hash_hex: str | None,
peer_pub_hex: str | None,
ready_q: multiprocessing.Queue,
result_q: multiprocessing.Queue,
stop_event: multiprocessing.Event,
) -> None:
import RNS
from LXMF import LXMessage
from lxmfy import BotConfig, LXMFBot
os.environ.pop("LXMFY_RETICULUM_CONFIG_DIR", None)
try:
_log(f"{role}: starting in {config_dir}")
bot = LXMFBot(
**BotConfig(
name=f"Live{role}",
config_path=config_dir,
reticulum_config_dir=config_dir,
storage_path=str(Path(config_dir) / "storage"),
announce_enabled=True,
announce_immediately=True,
first_message_enabled=False,
landlock_enabled=False,
cogs_enabled=False,
message_persistence_enabled=False,
propagation_fallback_enabled=False,
test_mode=False,
).__dict__,
)
received: list[str] = []
delivery_events: list[str] = []
@bot.on_message()
def on_msg(sender, message):
raw = message.content
content = raw.decode("utf-8") if isinstance(raw, bytes) else (raw or "")
received.append(content)
_log(f"{role}: recv {content!r} from {sender}")
if role == "pong" and content:
bot.send(
sender,
f"pong:{content}",
title="",
method=LXMessage.OPPORTUNISTIC,
)
_drain_outbound(bot)
return True
assert bot.local is not None
local_hash = RNS.hexrep(bot.local.hash, delimit=False)
local_pub = bot.identity.get_public_key().hex()
ready_q.put((role, local_hash, local_pub))
bot.announce_now(force=True)
_drain_outbound(bot)
if peer_hash_hex and peer_pub_hex:
peer = bytes.fromhex(peer_hash_hex)
RNS.Identity.remember(
RNS.Identity.full_hash(peer),
peer,
bytes.fromhex(peer_pub_hex),
)
if role == "ping":
peer = bytes.fromhex(peer_hash_hex or "")
deadline = time.time() + PATH_TIMEOUT_S
while time.time() < deadline and not stop_event.is_set():
if RNS.Transport.has_path(peer) and RNS.Identity.recall(peer):
break
RNS.Transport.request_path(peer)
if int(time.time()) % 5 == 0:
bot.announce_now(force=True)
_drain_outbound(bot)
time.sleep(0.5)
if not (RNS.Transport.has_path(peer) and RNS.Identity.recall(peer)):
result_q.put(
(
role,
"no_identity_or_path",
{
"has_path": RNS.Transport.has_path(peer),
"identity": RNS.Identity.recall(peer) is not None,
},
),
)
return
token = f"live-lxmf-{os.getpid()}-{int(time.time())}"
_log(f"{role}: path+identity ok, sending {token!r}")
# Patch delivery callbacks onto the next queued LXMessage via send path.
original_enqueue = bot._enqueue_outbound
def enqueue_with_hooks(lxm):
def ok(_m):
delivery_events.append("ok")
def bad(_m):
delivery_events.append("fail")
try:
lxm.register_delivery_callback(ok)
lxm.register_failed_callback(bad)
except Exception:
pass
return original_enqueue(lxm)
bot._enqueue_outbound = enqueue_with_hooks
if not bot.send(
peer_hash_hex,
token,
title="",
method=LXMessage.OPPORTUNISTIC,
):
result_q.put((role, "send_failed", token))
return
# Keep pumping while LXMF retries opportunistic delivery.
_drain_outbound(bot)
wait_deadline = time.time() + ROUNDTRIP_TIMEOUT_S
while time.time() < wait_deadline and not stop_event.is_set():
_drain_outbound(bot)
if any(m == f"pong:{token}" for m in received):
result_q.put((role, "ok", token))
return
time.sleep(0.2)
result_q.put(
(
role,
"timeout",
{"received": received[-5:], "delivery": delivery_events},
),
)
else:
while not stop_event.is_set():
if int(time.time()) % 8 == 0:
bot.announce_now(force=True)
_drain_outbound(bot)
time.sleep(0.5)
result_q.put((role, "stopped", received[-5:]))
except Exception as e:
_log(f"{role}: error {e}")
result_q.put((role, "error", str(e)))
finally:
try:
import RNS
RNS.Reticulum.exit_handler()
except Exception:
pass
os._exit(0)
@pytest.mark.integration
@pytest.mark.e2e
@pytest.mark.slow
@pytest.mark.skipif(
not LIVE_ENABLED,
reason="Set LXMFY_LIVE_LXMF=1 to run live LXMF mesh roundtrip",
)
def test_live_lxmf_ping_pong_random_directory_node(tmp_path):
"""Ping/pong two LXMFy bots through a random online public TCP/backbone node."""
try:
nodes = _fetch_online_nodes()
except (urllib.error.URLError, TimeoutError, json.JSONDecodeError) as e:
pytest.skip(f"directory.rns.recipes unavailable: {e}")
assert nodes, "no online tcp/backbone nodes in directory"
_log(f"directory returned {len(nodes)} online tcp/backbone nodes")
candidates = _pick_reachable_nodes(nodes, limit=CONNECT_TRIES)
if not candidates:
pytest.skip("no reachable online tcp/backbone nodes")
last_error = None
for node in candidates:
_log(
f"trying node {node.get('name')} "
f"({node.get('type')} {node.get('host')}:{node.get('port')})",
)
ping_dir = tmp_path / f"ping_{node['id']}"
pong_dir = tmp_path / f"pong_{node['id']}"
_write_rns_config(ping_dir, node)
_write_rns_config(pong_dir, node)
ready_q: multiprocessing.Queue = multiprocessing.Queue()
result_q: multiprocessing.Queue = multiprocessing.Queue()
stop_event = multiprocessing.Event()
pong = multiprocessing.Process(
target=_bot_worker,
args=("pong", str(pong_dir), None, None, ready_q, result_q, stop_event),
)
pong.start()
pong_hash = pong_pub = None
ready_deadline = time.time() + 60
while time.time() < ready_deadline:
if not ready_q.empty():
role, value, pub = ready_q.get()
if role == "pong":
pong_hash, pong_pub = value, pub
break
if not pong.is_alive():
break
time.sleep(0.2)
if not pong_hash or not pong_pub:
stop_event.set()
pong.terminate()
pong.join(timeout=5)
last_error = f"pong failed on {node.get('name')}"
continue
# Restart pong is already running without peer info; tell ping the peer.
# Pong does not need ping identity to receive.
ping = multiprocessing.Process(
target=_bot_worker,
args=(
"ping",
str(ping_dir),
pong_hash,
pong_pub,
ready_q,
result_q,
stop_event,
),
)
ping.start()
ping.join(timeout=PATH_TIMEOUT_S + ROUNDTRIP_TIMEOUT_S + 30)
stop_event.set()
pong.join(timeout=10)
for proc in (ping, pong):
if proc.is_alive():
proc.terminate()
proc.join(timeout=5)
results = []
while not result_q.empty():
results.append(result_q.get())
_log(f"results for {node.get('name')}: {results}")
if any(r[0] == "ping" and r[1] == "ok" for r in results):
_log(
f"LIVE_LXMF_PROVED {node.get('name')} "
f"{node.get('host')}:{node.get('port')} {results}",
)
return
last_error = f"node={node.get('name')} results={results}"
pytest.fail(
f"live LXMF roundtrip failed after trying {len(candidates)} nodes: {last_error}",
)
if __name__ == "__main__":
if not LIVE_ENABLED:
print("Set LXMFY_LIVE_LXMF=1", file=sys.stderr)
raise SystemExit(2)
import tempfile
with tempfile.TemporaryDirectory() as td:
test_live_lxmf_ping_pong_random_directory_node(Path(td))

129
vendor/lxmfy/tests/test_memory_guards.py vendored Normal file
View file

@ -0,0 +1,129 @@
"""Tests for outbound queue and RRC memory guards."""
from __future__ import annotations
from unittest.mock import MagicMock
from lxmfy import BotConfig, LXMFBot
from lxmfy.rrc import RRCClient
from lxmfy.rrc.constants import (
MAX_MEMBERS_PER_ROOM,
MAX_RESOURCE_EXPECTATIONS,
MAX_TRACKED_NICKS,
)
from lxmfy.templates.rrc_bot import DEFAULT_RRC_HUB, DEFAULT_RRC_ROOMS, RRCBot
def _bot(tmp_path, name: str, **kwargs) -> LXMFBot:
config = BotConfig(
test_mode=True,
config_path=str(tmp_path / f"cfg_{name}"),
storage_path=str(tmp_path / f"data_{name}"),
announce_enabled=False,
cogs_enabled=False,
landlock_enabled=False,
message_persistence_enabled=True,
**kwargs,
)
return LXMFBot(**config.__dict__)
def test_invalid_persisted_destination_is_dropped(tmp_path):
bot = _bot(tmp_path, "invalid_dest")
bot.storage.set(
"persisted_queue",
[
{
"destination": "746573745f73656e646572",
"content": "poison",
"title": "t",
"fields": None,
"method": None,
},
],
)
bot._load_persisted_queue()
assert bot.storage.get("persisted_queue") == []
assert bot.queue.empty()
def test_queue_full_drops_oldest(tmp_path):
bot = _bot(tmp_path, "full_queue", message_queue_size=2)
assert bot.queue.maxsize == 2
assert bot.send("aabbccddeeff00112233445566778899", "one")
assert bot.send("aabbccddeeff00112233445566778899", "two")
assert bot.send("aabbccddeeff00112233445566778899", "three")
assert bot.queue.qsize() == 2
contents = [
item.content.decode("utf-8")
if isinstance(item.content, bytes)
else item.content
for item in list(bot.queue.queue)
]
assert contents == ["two", "three"]
persisted = bot.storage.get("persisted_queue")
assert [item["content"] for item in persisted] == ["two", "three"]
def test_persisted_queue_truncated_to_queue_size(tmp_path):
bot = _bot(tmp_path, "truncate", message_queue_size=2)
dest = "aabbccddeeff00112233445566778899"
bot.storage.set(
"persisted_queue",
[
{
"destination": dest,
"content": f"m{i}",
"title": "t",
"fields": None,
"method": None,
}
for i in range(5)
],
)
bot._load_persisted_queue()
assert bot.queue.qsize() <= 2
assert len(bot.storage.get("persisted_queue")) <= 2
def test_rrc_member_and_nick_caps():
identity = MagicMock()
identity.hash = bytes(range(16))
client = RRCClient(hub_hash=bytes(range(16, 32)), identity=identity)
room = "general"
for i in range(MAX_MEMBERS_PER_ROOM + 50):
client._track_member(room, i.to_bytes(16, "big"))
assert len(client.members[room]) == MAX_MEMBERS_PER_ROOM
for i in range(MAX_TRACKED_NICKS + 50):
client._track_nick(i.to_bytes(16, "big"), f"n{i}")
assert len(client.nicks) == MAX_TRACKED_NICKS
def test_rrc_resource_expectation_cap():
identity = MagicMock()
identity.hash = bytes(range(16))
client = RRCClient(hub_hash=bytes(range(16, 32)), identity=identity)
for i in range(MAX_RESOURCE_EXPECTATIONS + 10):
client._remember_resource_expectation(
i.to_bytes(8, "big"),
{"kind": "motd", "size": 1, "expires": 0},
)
assert len(client._resource_expectations) == MAX_RESOURCE_EXPECTATIONS
def test_rrc_template_defaults():
bot = RRCBot(test_mode=True)
assert bot.bot.config.rrc_hubs == [DEFAULT_RRC_HUB]
assert bot.bot.config.rrc_rooms == DEFAULT_RRC_ROOMS
assert DEFAULT_RRC_HUB == "664fc0e8d2e448658e37bb3f34e6c88f"
assert "general" in DEFAULT_RRC_ROOMS
def test_rrc_template_uses_home_reticulum(tmp_path, monkeypatch):
home_rns = tmp_path / "fake_home_reticulum"
home_rns.mkdir()
(home_rns / "config").write_text("[reticulum]\nshare_instance = Yes\n")
monkeypatch.setenv("LXMFY_RETICULUM_CONFIG_DIR", str(home_rns))
bot = RRCBot(test_mode=True)
assert bot.bot.reticulum_config_dir == str(home_rns)

View file

@ -0,0 +1,143 @@
"""Tests for LXMF outgoing queue crash recovery."""
from __future__ import annotations
from types import SimpleNamespace
from unittest.mock import MagicMock, patch
from lxmfy import BotConfig, LXMFBot
def _bot(tmp_path, name: str) -> LXMFBot:
config = BotConfig(
test_mode=True,
config_path=str(tmp_path / f"cfg_{name}"),
storage_path=str(tmp_path / f"data_{name}"),
announce_enabled=False,
cogs_enabled=False,
landlock_enabled=False,
message_persistence_enabled=True,
)
return LXMFBot(**config.__dict__)
def test_persisted_queue_survives_and_restores(tmp_path):
bot = _bot(tmp_path, "survive")
assert bot.send("aabbccddeeff00112233445566778899", "hello crash", title="t")
assert bot.storage.get("persisted_queue")
assert len(bot.storage.get("persisted_queue")) == 1
assert bot.storage.get("persisted_queue")[0]["content"] == "hello crash"
bot.queue.get(block=False)
bot._persist_queue()
assert bot.storage.get("persisted_queue") == []
def test_persisted_queue_restore_keeps_success_and_failures(tmp_path):
bot = _bot(tmp_path, "restore")
good = "aabbccddeeff00112233445566778899"
fail = "11223344556677889900aabbccddeeff"
bot.storage.set(
"persisted_queue",
[
{
"destination": good,
"content": "one",
"title": "t",
"fields": None,
"method": None,
},
{
"destination": fail,
"content": "two",
"title": "t",
"fields": None,
"method": None,
},
],
)
original_send = bot.send
def flaky_send(destination, message, **kwargs):
if destination == fail:
raise ValueError("boom")
return original_send(destination, message, **kwargs)
bot.send = flaky_send # type: ignore[method-assign]
bot._load_persisted_queue()
remaining = bot.storage.get("persisted_queue")
contents = [item["content"] for item in remaining]
assert "one" in contents
assert "two" in contents
assert not bot.queue.empty()
def test_persisted_queue_restore_keeps_unqueued(tmp_path):
bot = _bot(tmp_path, "unqueued")
bot.storage.set(
"persisted_queue",
[
{
"destination": "aabbccddeeff00112233445566778899",
"content": "pending",
"title": "t",
"fields": None,
"method": None,
},
],
)
bot.send = MagicMock(return_value=False) # type: ignore[method-assign]
bot._load_persisted_queue()
remaining = bot.storage.get("persisted_queue")
assert remaining == [
{
"destination": "aabbccddeeff00112233445566778899",
"content": "pending",
"title": "t",
"fields": None,
"method": None,
},
]
def test_cleanup_persists_remaining_queue(tmp_path):
bot = _bot(tmp_path, "cleanup")
bot.send("aabbccddeeff00112233445566778899", "still queued")
bot.cleanup()
assert bot.storage.get("persisted_queue")
assert bot.storage.get("persisted_queue")[0]["content"] == "still queued"
def test_persist_serializes_queue_items(tmp_path):
bot = _bot(tmp_path, "serialize")
msg = SimpleNamespace(
destination_hash=bytes.fromhex("aabbccddeeff00112233445566778899"),
content=b"bytes content",
title=b"title",
fields={"a": 1},
desired_method="direct",
)
bot.queue.put(msg)
bot._persist_queue()
persisted = bot.storage.get("persisted_queue")
assert persisted[0]["content"] == "bytes content"
assert persisted[0]["title"] == "title"
assert persisted[0]["fields"] == {"a": 1}
def test_run_requeues_on_outbound_failure(tmp_path):
bot = _bot(tmp_path, "requeue")
bot.send("aabbccddeeff00112233445566778899", "retry me")
bot.router = MagicMock()
bot.router.handle_outbound.side_effect = RuntimeError("link down")
with (
patch.object(bot.scheduler, "start"),
patch("lxmfy.core.time.sleep", side_effect=KeyboardInterrupt),
):
bot.run(delay=0)
assert not bot.queue.empty()
assert bot.storage.get("persisted_queue")[0]["content"] == "retry me"

View file

@ -0,0 +1,250 @@
"""Tests for Reticulum config discovery and shared-instance isolation."""
from __future__ import annotations
import multiprocessing
import time
from pathlib import Path
import pytest
from lxmfy.reticulum_config import (
discover_user_reticulum_config_dir,
ensure_isolated_share_instance_disabled,
is_isolated_reticulum_dir,
resolve_reticulum_config_dir,
)
def test_discover_user_reticulum_prefers_system_then_xdg(tmp_path):
etc = tmp_path / "etc_reticulum"
xdg = tmp_path / ".config" / "reticulum"
home = tmp_path / ".reticulum"
for path in (etc, xdg, home):
path.mkdir(parents=True)
(path / "config").write_text("[reticulum]\nshare_instance = Yes\n")
found = discover_user_reticulum_config_dir(
home=str(tmp_path),
system_dir=str(etc),
)
assert found == str(etc.resolve())
found_xdg = discover_user_reticulum_config_dir(
home=str(tmp_path),
system_dir=str(tmp_path / "missing_etc"),
)
assert found_xdg == str(xdg.resolve())
def test_discover_user_reticulum_falls_back_to_dot_reticulum(tmp_path):
home = tmp_path / ".reticulum"
home.mkdir()
(home / "config").write_text("[reticulum]\nshare_instance = Yes\n")
found = discover_user_reticulum_config_dir(
home=str(tmp_path),
system_dir=str(tmp_path / "missing_etc"),
)
assert found == str(home.resolve())
def test_resolve_prefers_explicit_then_env_then_discovery(tmp_path, monkeypatch):
bot_cfg = tmp_path / "bot"
bot_cfg.mkdir()
user = tmp_path / ".reticulum"
user.mkdir()
(user / "config").write_text("[reticulum]\n")
explicit = tmp_path / "explicit"
explicit.mkdir()
missing_etc = str(tmp_path / "missing_etc")
assert resolve_reticulum_config_dir(
str(explicit),
str(bot_cfg),
environ={},
home=str(tmp_path),
system_dir=missing_etc,
) == str(explicit.resolve())
assert resolve_reticulum_config_dir(
None,
str(bot_cfg),
environ={"LXMFY_RETICULUM_CONFIG_DIR": str(user)},
home=str(tmp_path),
system_dir=missing_etc,
) == str(user.resolve())
assert resolve_reticulum_config_dir(
None,
str(bot_cfg),
environ={},
home=str(tmp_path),
system_dir=missing_etc,
) == str(user.resolve())
empty_home = tmp_path / "empty_home"
empty_home.mkdir()
assert resolve_reticulum_config_dir(
None,
str(bot_cfg),
environ={},
home=str(empty_home),
system_dir=missing_etc,
) == str(bot_cfg.resolve())
def test_ensure_isolated_creates_share_instance_no(tmp_path):
rns_dir = tmp_path / "isolated"
assert ensure_isolated_share_instance_disabled(str(rns_dir)) is True
text = (rns_dir / "config").read_text()
assert "share_instance = No" in text
assert ensure_isolated_share_instance_disabled(str(rns_dir)) is False
def test_ensure_isolated_rewrites_share_instance_yes(tmp_path):
rns_dir = tmp_path / "isolated"
rns_dir.mkdir()
(rns_dir / "config").write_text(
"[reticulum]\nshare_instance = Yes\nenable_transport = Yes\n",
)
assert ensure_isolated_share_instance_disabled(str(rns_dir)) is True
text = (rns_dir / "config").read_text()
assert "share_instance = No" in text
assert "share_instance = Yes" not in text
def test_is_isolated_reticulum_dir(tmp_path):
bot = tmp_path / "bot"
other = tmp_path / "other"
bot.mkdir()
other.mkdir()
assert is_isolated_reticulum_dir(str(bot), str(bot)) is True
assert is_isolated_reticulum_dir(str(other), str(bot)) is False
def _shared_instance_worker(
role: str,
cfg: str,
q: multiprocessing.Queue,
delay: float = 0.0,
):
import RNS
if delay:
time.sleep(delay)
try:
r = RNS.Reticulum(configdir=cfg, loglevel=RNS.LOG_ERROR)
q.put(
(
role,
"started",
r.is_shared_instance,
r.is_connected_to_shared_instance,
r.is_standalone_instance,
),
)
try:
client = r.get_rpc_client()
client.close()
q.put((role, "rpc_ok"))
except Exception as e:
q.put((role, f"{type(e).__name__}: {e}"))
if role == "master":
time.sleep(6)
finally:
try:
RNS.Reticulum.exit_handler()
except Exception:
pass
def _write_tcp_shared_config(
path: Path, *, share: bool, iface: int, control: int
) -> None:
path.mkdir(parents=True, exist_ok=True)
share_val = "Yes" if share else "No"
(path / "config").write_text(
"[reticulum]\n"
"enable_transport = Yes\n"
f"share_instance = {share_val}\n"
"shared_instance_type = tcp\n"
f"shared_instance_port = {iface}\n"
f"instance_control_port = {control}\n"
"\n"
"[logging]\n"
"loglevel = 3\n"
"\n"
"[interfaces]\n",
encoding="utf-8",
)
@pytest.mark.integration
def test_colliding_shared_instance_rejects_digest(tmp_path):
"""Different config dirs on the same shared ports fail RPC auth."""
master = tmp_path / "master"
client = tmp_path / "client"
_write_tcp_shared_config(master, share=True, iface=47628, control=47629)
_write_tcp_shared_config(client, share=True, iface=47628, control=47629)
q: multiprocessing.Queue = multiprocessing.Queue()
p1 = multiprocessing.Process(
target=_shared_instance_worker,
args=("master", str(master), q),
)
p2 = multiprocessing.Process(
target=_shared_instance_worker,
args=("client", str(client), q, 2.0),
)
p1.start()
p2.start()
p2.join(timeout=20)
p1.terminate()
p1.join(timeout=5)
results = []
while not q.empty():
results.append(q.get())
assert any(
r[0] == "client" and "digest sent was rejected" in str(r[1]) for r in results
)
@pytest.mark.integration
def test_isolated_share_instance_avoids_digest_rejection(tmp_path):
"""Guarantee: isolated configs with share_instance=No do not RPC-collide.
RETICULUM_DIGEST_PROVED
"""
master = tmp_path / "master"
client = tmp_path / "client"
ensure_isolated_share_instance_disabled(str(master))
ensure_isolated_share_instance_disabled(str(client))
_write_tcp_shared_config(master, share=False, iface=47728, control=47729)
_write_tcp_shared_config(client, share=False, iface=47738, control=47739)
q: multiprocessing.Queue = multiprocessing.Queue()
p1 = multiprocessing.Process(
target=_shared_instance_worker,
args=("master", str(master), q),
)
p2 = multiprocessing.Process(
target=_shared_instance_worker,
args=("client", str(client), q, 1.5),
)
p1.start()
p2.start()
p2.join(timeout=20)
p1.terminate()
p1.join(timeout=5)
results = []
while not q.empty():
results.append(q.get())
started = [r for r in results if len(r) >= 5 and r[1] == "started"]
assert len(started) == 2
assert all(r[4] is True for r in started)
assert not any("digest sent was rejected" in str(item) for item in results)
print("RETICULUM_DIGEST_PROVED")

587
vendor/lxmfy/tests/test_rrc.py vendored Normal file
View file

@ -0,0 +1,587 @@
"""Tests for RRC CBOR encoding and client session behavior."""
from __future__ import annotations
from unittest.mock import MagicMock, patch
import cbor2
import pytest
import RNS
from hypothesis import given, settings
from hypothesis import strategies as st
from lxmfy import BotConfig, LXMFBot
from lxmfy.rrc import (
DEFAULT_DEST_NAME,
RRCClient,
RRCManager,
RRC_VERSION,
STATUS_CONNECTED,
T_ACTION,
T_ERROR,
T_HELLO,
T_JOINED,
T_MSG,
T_NOTICE,
T_PING,
T_PONG,
T_WELCOME,
decode_envelope,
encode_envelope,
make_envelope,
normalize_room,
)
from lxmfy.rrc.constants import (
B_HELLO_CAPS,
B_HELLO_NAME,
B_HELLO_VER,
B_WELCOME_HUB,
B_WELCOME_LIMITS,
B_WELCOME_VER,
CAP_ACTION,
K_BODY,
K_ID,
K_NICK,
K_ROOM,
K_SRC,
K_T,
K_TS,
K_V,
L_MAX_MSG_BODY_BYTES,
L_MAX_NICK_BYTES,
)
def _src() -> bytes:
return bytes(range(16))
def test_normalize_room():
assert normalize_room(" Lobby ") == "lobby"
assert normalize_room("#General") == "#general"
with pytest.raises(ValueError):
normalize_room(" ")
with pytest.raises(ValueError):
normalize_room(None)
def test_make_envelope_roundtrip_cbor():
src = _src()
env = make_envelope(
T_MSG,
src=src,
room="lobby",
body="Hello, world!",
nick="alice",
mid=b"\x7a\x3f\x8e\x12\x45\xc9\xa1\x6d",
ts=1737849600000,
)
raw = encode_envelope(env)
decoded = decode_envelope(raw)
assert decoded is not None
assert decoded[K_V] == RRC_VERSION
assert decoded[K_T] == T_MSG
assert decoded[K_SRC] == src
assert decoded[K_ROOM] == "lobby"
assert decoded[K_BODY] == "Hello, world!"
assert decoded[K_NICK] == "alice"
assert decoded[K_ID] == b"\x7a\x3f\x8e\x12\x45\xc9\xa1\x6d"
assert decoded[K_TS] == 1737849600000
def test_envelope_rejects_bad_src():
with pytest.raises(ValueError):
make_envelope(T_HELLO, src=b"short")
def test_decode_malformed_returns_none():
assert decode_envelope(b"not-cbor") is None
assert decode_envelope(cbor2.dumps(["list"])) is None
def test_unknown_keys_preserved():
src = _src()
env = make_envelope(T_MSG, src=src, room="x", body="y")
env[99] = "extension"
decoded = decode_envelope(encode_envelope(env))
assert decoded is not None
assert decoded[99] == "extension"
def test_spec_size_budget_example():
"""Worst-case MSG budget from 3-RRC should stay near the MTU."""
import os
src = _src()
env = make_envelope(
T_MSG,
src=src,
room="a" * 64,
body="b" * 350,
nick="c" * 32,
mid=os.urandom(8),
ts=(1 << 40),
)
encoded = encode_envelope(env)
assert len(encoded) <= 500
@given(
room=st.text(min_size=1, max_size=32).filter(lambda s: s.strip()),
body=st.text(min_size=1, max_size=120),
nick=st.one_of(st.none(), st.text(min_size=1, max_size=16)),
)
@settings(max_examples=40, deadline=None)
def test_envelope_roundtrip_property(room, body, nick):
src = _src()
env = make_envelope(T_MSG, src=src, room=room, body=body, nick=nick)
decoded = decode_envelope(encode_envelope(env))
assert decoded is not None
assert decoded[K_T] == T_MSG
assert decoded[K_BODY] == body
assert decoded[K_ROOM] == room
if nick:
assert decoded[K_NICK] == nick
else:
assert K_NICK not in decoded
def _client(events: list | None = None) -> RRCClient:
identity = MagicMock()
identity.hash = _src()
hub = bytes(range(16, 32))
def on_event(event, client, payload):
if events is not None:
events.append((event, payload))
return RRCClient(
identity=identity,
hub_hash=hub,
nick="TestBot",
auto_reconnect=False,
on_event=on_event,
)
def test_client_handles_welcome_and_limits():
events = []
client = _client(events)
body = {
B_WELCOME_HUB: "ExampleHub",
B_WELCOME_VER: "0.1.0",
B_WELCOME_LIMITS: {
L_MAX_NICK_BYTES: 24,
L_MAX_MSG_BODY_BYTES: 200,
},
}
env = make_envelope(T_WELCOME, src=bytes(range(16, 32)), body=body)
client._on_packet(encode_envelope(env))
assert client.welcomed is True
assert client.status == STATUS_CONNECTED
assert client.hub_name == "ExampleHub"
assert client.max_nick_bytes == 24
assert client.max_msg_body_bytes == 200
assert any(e[0] == "welcome" for e in events)
def test_client_handles_msg_and_mention():
events = []
client = _client(events)
peer = bytes(range(32, 48))
env = make_envelope(
T_MSG,
src=peer,
room="Lobby",
body="hey @TestBot are you there?",
nick="alice",
)
client._on_packet(encode_envelope(env))
msgs = [p for e, p in events if e == "msg"]
assert len(msgs) == 1
assert msgs[0].room == "lobby"
assert msgs[0].mention is True
assert msgs[0].nick == "alice"
assert client.nicks[peer] == "alice"
def test_client_handles_notice_and_action():
events = []
client = _client(events)
peer = bytes(range(32, 48))
notice = make_envelope(T_NOTICE, src=peer, room="lobby", body="system note")
action = make_envelope(T_ACTION, src=peer, room="lobby", body="waves")
client._on_packet(encode_envelope(notice))
client._on_packet(encode_envelope(action))
kinds = [e for e, _ in events]
assert "notice" in kinds
assert "action" in kinds
def test_client_ping_pong():
events = []
client = _client(events)
client.link = MagicMock()
client.link.status = RNS.Link.ACTIVE
with patch.object(client, "_send_env") as send_env:
ping = make_envelope(T_PING, src=bytes(range(16, 32)), body=b"12345678")
client._on_packet(encode_envelope(ping))
assert send_env.called
pong_env = send_env.call_args[0][0]
assert pong_env[K_T] == T_PONG
assert pong_env[K_BODY] == b"12345678"
body = b"abcdefgh"
client._pending_pings[body] = 1
pong = make_envelope(T_PONG, src=bytes(range(16, 32)), body=body)
client._on_packet(encode_envelope(pong))
assert any(e[0] == "pong" for e in events)
def test_client_joined_and_error():
events = []
client = _client(events)
client._pending_joins.add("lobby")
joined = make_envelope(
T_JOINED,
src=bytes(range(16, 32)),
room="lobby",
body=[_src()],
)
client._on_packet(encode_envelope(joined))
assert "lobby" in client.rooms
assert any(e[0] == "joined" for e in events)
client._pending_joins.add("secret")
err = make_envelope(
T_ERROR,
src=bytes(range(16, 32)),
room="secret",
body="denied",
)
client._on_packet(encode_envelope(err))
assert "secret" not in client.rooms
assert any(e[0] == "error" for e in events)
def test_client_send_message_requires_active_link():
client = _client()
with pytest.raises(RuntimeError):
client.send_message("lobby", "hi")
def test_client_send_message_encodes_and_tracks_id():
client = _client()
client.link = MagicMock()
client.link.status = RNS.Link.ACTIVE
client.welcomed = True
with (
patch.object(client, "_packet_would_fit", return_value=True),
patch("lxmfy.rrc.client.RNS.Packet") as packet_cls,
):
packet = MagicMock()
packet_cls.return_value = packet
mid = client.send_message("Lobby", "hello there")
assert isinstance(mid, bytes)
assert mid in client._sent_ids
packet.send.assert_called_once()
payload = packet_cls.call_args[0][1]
decoded = decode_envelope(payload)
assert decoded is not None
assert decoded[K_T] == T_MSG
assert decoded[K_ROOM] == "lobby"
assert decoded[K_BODY] == "hello there"
def test_hello_body_shape():
client = _client()
link = MagicMock()
with patch("lxmfy.rrc.client.RNS.Packet") as packet_cls:
packet = MagicMock()
packet_cls.return_value = packet
client._send_hello(link)
payload = packet_cls.call_args[0][1]
env = decode_envelope(payload)
assert env is not None
assert env[K_T] == T_HELLO
assert env[K_BODY][B_HELLO_NAME] == "lxmfy"
assert env[K_BODY][B_HELLO_VER]
assert env[K_BODY][B_HELLO_CAPS][CAP_ACTION] is True
def test_manager_connect_and_status():
identity = MagicMock()
identity.hash = _src()
manager = RRCManager(identity=identity, nick="MgrBot", auto_reconnect=False)
hub = bytes(range(16, 32)).hex()
with patch.object(RRCClient, "connect") as connect:
client = manager.connect(hub, rooms=["lobby"])
connect.assert_called_once()
assert client.nick == "MgrBot"
assert "lobby" in client._auto_join_rooms
assert manager.get(hub) is client
assert len(manager.status()) == 1
manager.disconnect(hub)
assert manager.get(hub) is None
def test_bot_exposes_rrc_in_test_mode(tmp_path):
config = BotConfig(
test_mode=True,
config_path=str(tmp_path / "cfg"),
storage_path=str(tmp_path / "data"),
announce_enabled=False,
cogs_enabled=False,
landlock_enabled=False,
rrc_enabled=True,
rrc_hubs=["aabbccddeeff00112233445566778899"],
rrc_rooms=["lobby"],
rrc_nick="UnitBot",
)
bot = LXMFBot(**config.__dict__)
assert isinstance(bot.rrc, RRCManager)
assert bot.rrc.nick == "UnitBot"
assert bot.rrc.dest_name == DEFAULT_DEST_NAME
called = []
@bot.on_rrc
def handler(event, client, payload):
called.append(event)
bot._rrc_event("welcome", MagicMock(hub_hash=_src()), {"hub_name": "x"})
assert called == ["welcome"]
with pytest.raises(RuntimeError):
bot.connect_rrc("aabbccddeeff00112233445566778899")
def test_default_dest_name_constant():
assert DEFAULT_DEST_NAME == "rrc.hub"
assert RRC_VERSION == 1
def test_validate_envelope_rejects_bad_fields():
from lxmfy.rrc import validate_envelope
src = _src()
good = make_envelope(T_MSG, src=src, room="lobby", body="hi")
assert validate_envelope(good) is True
bad_version = dict(good)
bad_version[K_V] = 99
assert validate_envelope(bad_version) is False
bad_src = dict(good)
bad_src[K_SRC] = b"short"
assert validate_envelope(bad_src) is False
bad_id = dict(good)
bad_id[K_ID] = b"1234"
assert validate_envelope(bad_id) is False
def test_pre_welcome_send_rejected():
client = _client()
client.link = MagicMock()
client.link.status = RNS.Link.ACTIVE
client.welcomed = False
with pytest.raises(RuntimeError, match="not welcomed"):
client.send_message("lobby", "too early")
def test_on_closed_clears_session_and_preserves_rejoin():
client = _client()
client.auto_reconnect = False
client.rooms.add("lobby")
client.nicks[_src()] = "alice"
client._pending_pings[b"12345678"] = 1
client.welcomed = True
client._on_closed(MagicMock())
assert client.rooms == set()
assert client.nicks == {}
assert client._pending_pings == {}
assert client.welcomed is False
assert "lobby" in client._rejoin_rooms
def test_welcome_rejoins_previous_rooms():
client = _client()
client.link = MagicMock()
client.link.status = RNS.Link.ACTIVE
client._rejoin_rooms.add("lobby")
client._auto_join_rooms = ["ops"]
with patch.object(client, "join") as join:
env = make_envelope(
T_WELCOME,
src=bytes(range(16, 32)),
body={B_WELCOME_HUB: "Hub"},
)
client._on_packet(encode_envelope(env))
joined = {call.args[0] for call in join.call_args_list}
assert joined == {"lobby", "ops"}
assert client.connected is True
def test_hub_limit_enforcement():
client = _client()
client.link = MagicMock()
client.link.status = RNS.Link.ACTIVE
client.welcomed = True
client.max_msg_body_bytes = 5
with pytest.raises(ValueError, match="too long"):
client.send_message("lobby", "toolong")
client.max_room_name_bytes = 3
with pytest.raises(ValueError, match="room name too long"):
client.join("lobby")
client.max_room_name_bytes = 64
client.max_rooms_per_session = 1
client.rooms.add("a")
with pytest.raises(RuntimeError, match="max rooms"):
client.join("b")
with pytest.raises(ValueError, match="nick too long"):
client.set_nick("x" * 100)
def test_rate_limit_enforcement():
client = _client()
client.link = MagicMock()
client.link.status = RNS.Link.ACTIVE
client.welcomed = True
client.rate_limit_msgs_per_minute = 2
with (
patch.object(client, "_packet_would_fit", return_value=True),
patch("lxmfy.rrc.client.RNS.Packet") as packet_cls,
):
packet_cls.return_value = MagicMock()
client.send_message("lobby", "one")
client.send_message("lobby", "two")
with pytest.raises(RuntimeError, match="rate limit"):
client.send_message("lobby", "three")
def test_mention_word_boundary():
events = []
client = _client(events)
peer = bytes(range(32, 48))
env = make_envelope(
T_MSG,
src=peer,
room="lobby",
body="notatestbot but @TestBot yes",
nick="alice",
)
client._on_packet(encode_envelope(env))
msgs = [p for e, p in events if e == "msg"]
assert msgs and msgs[0].mention is True
events.clear()
env2 = make_envelope(
T_MSG,
src=peer,
room="lobby",
body="email testbot@example.com",
nick="alice",
)
client._on_packet(encode_envelope(env2))
msgs = [p for e, p in events if e == "msg"]
assert msgs and msgs[0].mention is False
def test_resource_envelope_expectation():
from lxmfy.rrc.constants import (
B_RES_ENCODING,
B_RES_ID,
B_RES_KIND,
B_RES_SHA256,
B_RES_SIZE,
T_RESOURCE_ENVELOPE,
)
client = _client()
rid = b"12345678"
body = {
B_RES_ID: rid,
B_RES_KIND: "motd",
B_RES_SIZE: 5,
B_RES_SHA256: __import__("hashlib").sha256(b"hello").digest(),
B_RES_ENCODING: "utf-8",
}
env = make_envelope(T_RESOURCE_ENVELOPE, src=bytes(range(16, 32)), body=body)
client._on_packet(encode_envelope(env))
assert rid in client._resource_expectations
assert client._resource_expectations[rid]["kind"] == "motd"
def test_manager_send_action_and_persist(tmp_path):
from lxmfy.storage import JSONStorage, Storage
identity = MagicMock()
identity.hash = _src()
storage = Storage(JSONStorage(str(tmp_path / "store")))
manager = RRCManager(
identity=identity,
nick="MgrBot",
auto_reconnect=False,
storage=storage,
persist_sessions=True,
)
hub = bytes(range(16, 32)).hex()
with patch.object(RRCClient, "connect"):
client = manager.connect(hub, rooms=["lobby"])
client.rooms.add("lobby")
manager.save_sessions()
saved = storage.get("rrc_sessions", [])
assert saved and saved[0]["hub_hash"] == hub
assert "lobby" in saved[0]["rooms"]
client.link = MagicMock()
client.link.status = RNS.Link.ACTIVE
client.welcomed = True
client.status = STATUS_CONNECTED
with (
patch.object(client, "_packet_would_fit", return_value=True),
patch("lxmfy.rrc.client.RNS.Packet") as packet_cls,
):
packet_cls.return_value = MagicMock()
mid = manager.send_action("lobby", "waves", hub_hash=hub)
assert isinstance(mid, bytes)
with (
patch.object(client, "_packet_would_fit", return_value=True),
patch("lxmfy.rrc.client.RNS.Packet") as packet_cls,
):
packet_cls.return_value = MagicMock()
manager.join("ops", hub_hash=hub)
saved = storage.get("rrc_sessions", [])
assert "ops" in saved[0]["rooms"] or "ops" in client._rejoin_rooms
def test_save_sessions_includes_auto_join_rooms(tmp_path):
from lxmfy.storage import JSONStorage, Storage
identity = MagicMock()
identity.hash = _src()
storage = Storage(JSONStorage(str(tmp_path / "store2")))
manager = RRCManager(
identity=identity,
nick="MgrBot",
auto_reconnect=False,
storage=storage,
persist_sessions=True,
)
hub = bytes(range(16, 32)).hex()
with patch.object(RRCClient, "connect"):
client = manager.connect(hub, rooms=["general"])
saved = storage.get("rrc_sessions", [])
assert saved[0]["rooms"] == ["general"]
assert "general" in client._auto_join_rooms
assert "general" in client._rejoin_rooms

238
vendor/lxmfy/tests/test_rrc_live.py vendored Normal file
View file

@ -0,0 +1,238 @@
"""Live RRC smoke test against a local rrcd hub over TCP loopback.
Requires rrcd installed and LXMFY_LIVE_RRC=1. Skips otherwise.
"""
from __future__ import annotations
import os
import socket
import subprocess
import tempfile
import time
from pathlib import Path
import pytest
import RNS
from lxmfy.rrc import RRCClient
REPO_ROOT = Path(__file__).resolve().parents[1]
def _can_import_rrcd() -> bool:
try:
import rrcd # noqa: F401
return True
except Exception:
return False
rrcd_available = _can_import_rrcd()
live_enabled = os.environ.get("LXMFY_LIVE_RRC", "").strip().lower() in {
"1",
"true",
"yes",
}
def _free_port() -> int:
sock = socket.socket()
sock.bind(("127.0.0.1", 0))
port = sock.getsockname()[1]
sock.close()
return port
def _write_rns_config(path: Path, *, server: bool, port: int) -> None:
path.mkdir(parents=True, exist_ok=True)
if server:
iface = f"""
[interfaces]
[[TCP Server]]
type = TCPServerInterface
enabled = Yes
listen_ip = 127.0.0.1
listen_port = {port}
"""
else:
iface = f"""
[interfaces]
[[TCP Client]]
type = TCPClientInterface
enabled = Yes
target_host = 127.0.0.1
target_port = {port}
"""
(path / "config").write_text(
f"""
[reticulum]
enable_transport = Yes
share_instance = No
panic_on_interface_error = No
[logging]
loglevel = 3
{iface}
""".lstrip(),
)
@pytest.mark.integration
@pytest.mark.e2e
@pytest.mark.skipif(not rrcd_available, reason="rrcd not installed")
@pytest.mark.skipif(
not live_enabled,
reason="Set LXMFY_LIVE_RRC=1 to run live RRC hub smoke test",
)
def test_live_rrc_hello_join_msg_roundtrip():
"""Run rrcd on a TCP server and connect an LXMFy RRC client over TCP."""
with tempfile.TemporaryDirectory() as tmp:
home = Path(tmp)
port = _free_port()
hub_rns = home / "hub_rns"
client_rns = home / "client_rns"
rrcd_home = home / "rrcd"
rrcd_home.mkdir()
_write_rns_config(hub_rns, server=True, port=port)
_write_rns_config(client_rns, server=False, port=port)
env = os.environ.copy()
env["RRCD_HOME"] = str(rrcd_home)
init = subprocess.run(
[
"poetry",
"run",
"python",
"-m",
"rrcd",
"--configdir",
str(hub_rns),
"--hub-name",
"LXMFyLiveHub",
],
cwd=str(REPO_ROOT),
env=env,
capture_output=True,
text=True,
check=False,
)
identity_path = rrcd_home / "hub_identity"
assert identity_path.is_file(), init.stdout + init.stderr
# rrcd.toml defaults configdir to empty and would override --configdir.
rrcd_toml = rrcd_home / "rrcd.toml"
text = rrcd_toml.read_text()
text = text.replace('configdir = ""', f'configdir = "{hub_rns}"')
if "announce_period_s = 0.0" in text:
text = text.replace("announce_period_s = 0.0", "announce_period_s = 3.0")
rrcd_toml.write_text(text)
hub_proc = subprocess.Popen(
[
"poetry",
"run",
"python",
"-m",
"rrcd",
"--configdir",
str(hub_rns),
"--hub-name",
"LXMFyLiveHub",
"--announce-period",
"3",
"--log-level",
"INFO",
],
cwd=str(REPO_ROOT),
env=env,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True,
)
try:
port_deadline = time.time() + 15
while time.time() < port_deadline:
try:
with socket.create_connection(("127.0.0.1", port), timeout=0.5):
break
except OSError:
time.sleep(0.2)
else:
out = ""
try:
hub_proc.terminate()
out = hub_proc.communicate(timeout=2)[0] or ""
except Exception:
pass
pytest.fail(f"hub TCP server did not start on {port}\n{out}")
RNS.Reticulum(configdir=str(client_rns), loglevel=RNS.LOG_ERROR)
time.sleep(6.0)
hub_identity = RNS.Identity.from_file(str(identity_path))
app_name, aspects = RNS.Destination.app_and_aspects_from_name("rrc.hub")
hub_hash = RNS.Destination.hash(hub_identity, app_name, *aspects)
RNS.Identity.remember(
RNS.Identity.full_hash(hub_hash),
hub_hash,
hub_identity.get_public_key(),
)
path_deadline = time.time() + 20
while time.time() < path_deadline and not RNS.Transport.has_path(hub_hash):
RNS.Transport.request_path(hub_hash)
time.sleep(0.5)
events = []
def on_event(event, client, payload):
events.append((event, payload))
client = RRCClient(
identity=RNS.Identity(),
hub_hash=hub_hash,
nick="LiveBot",
auto_reconnect=False,
on_event=on_event,
)
client.set_auto_join(["lobby"])
client.connect()
deadline = time.time() + 30
while time.time() < deadline and not client.connected:
time.sleep(0.2)
assert client.connected, (
f"did not get WELCOME: {client.status_text} "
f"has_path={RNS.Transport.has_path(hub_hash)} events={events}"
)
join_deadline = time.time() + 15
while time.time() < join_deadline and "lobby" not in client.rooms:
time.sleep(0.2)
assert "lobby" in client.rooms
mid = client.send_message("lobby", "live smoke from lxmfy")
assert isinstance(mid, bytes) and len(mid) == 8
client.disconnect()
assert any(e[0] == "welcome" for e in events)
finally:
hub_proc.terminate()
try:
hub_proc.wait(timeout=5)
except subprocess.TimeoutExpired:
hub_proc.kill()
try:
out = hub_proc.stdout.read() if hub_proc.stdout else ""
if out:
print(out[-4000:])
except Exception:
pass
try:
RNS.Reticulum.exit_handler()
except Exception:
pass

265
vendor/lxmfy/uv.lock generated vendored Normal file
View file

@ -0,0 +1,265 @@
version = 1
revision = 3
requires-python = ">=3.11"
[[package]]
name = "cbor2"
version = "6.1.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/3a/6f/07b4af8da8bd27f640362b1ac8271d80895407f2ede0c2bcc9433c06e1ca/cbor2-6.1.3.tar.gz", hash = "sha256:8d70680acb55c04ea5b5ad86da094f9612b53d5a8a65d0f5b3aafc3ce917ecbb", size = 89503, upload-time = "2026-07-04T10:36:48.793Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f8/2e/013b2c478c41585bf5c8f9659328412eda4fe8ed30ffeb8e4fde87f8b9a3/cbor2-6.1.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:84edab2df31c981d258d652a82a3e30eb7368d86d9d7284216282f65403a1e00", size = 421187, upload-time = "2026-07-04T10:35:54.334Z" },
{ url = "https://files.pythonhosted.org/packages/96/78/840809f265a4537fde0ba646d92d61c500435fd811961d70776fc021b7ab/cbor2-6.1.3-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:932e0894476fad36b186c0da6e9b1433358bea564a60ae4799e51182568ff29f", size = 463784, upload-time = "2026-07-04T10:35:55.771Z" },
{ url = "https://files.pythonhosted.org/packages/3d/c5/4dad6125eea17b35ca5580a4f7308226c8a4511dfb91b94c329b167b6218/cbor2-6.1.3-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:5f14159423a984c387982901f67313d6582251b6733c23e8bd925d73173691bf", size = 472119, upload-time = "2026-07-04T10:35:57.122Z" },
{ url = "https://files.pythonhosted.org/packages/b2/f7/5f0387ee7b5601c6af5277051612b8163f82ccbddbae807bbc1326754e3e/cbor2-6.1.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:622ec874664b4db54bc6df40f82832ad30fa5c875ad85cde84392ac62bb33d15", size = 528659, upload-time = "2026-07-04T10:35:58.496Z" },
{ url = "https://files.pythonhosted.org/packages/d7/7f/371ce0c200955a8a999e0a34398834ba88ef2fecda8437c3264c0673aa33/cbor2-6.1.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:c9144756fa7c9298d5882d1f7ad379c4a0059803a4c70329965a000bc79bf02d", size = 538983, upload-time = "2026-07-04T10:35:59.828Z" },
{ url = "https://files.pythonhosted.org/packages/48/d4/3cb4d40ce9bbfb41098656a0be5a8d01f24fa54cdc6d2665cbbaefbb8ba0/cbor2-6.1.3-cp311-cp311-win32.whl", hash = "sha256:144f8cfd2e9149389c34026243aeb646184cd78a2c657822be9bc9e7a2c5f3f5", size = 282264, upload-time = "2026-07-04T10:36:01.131Z" },
{ url = "https://files.pythonhosted.org/packages/36/1f/e9d123a071ee67ebca70b37401a03b80641d86953a72e8ea41194f99095a/cbor2-6.1.3-cp311-cp311-win_amd64.whl", hash = "sha256:187fd06befc59e6cafafc2709e5f1f3df8afe8bca5646f9cb5b70fc7e6ab1783", size = 303941, upload-time = "2026-07-04T10:36:02.45Z" },
{ url = "https://files.pythonhosted.org/packages/32/4f/efb2ed376421641e372bfebe9fd98f11c9de3bbac1da9f2b8be5c96eb335/cbor2-6.1.3-cp311-cp311-win_arm64.whl", hash = "sha256:43f0f694f47958de50fc84e6268a3015cc2a7fce88b231456c053bc5a1c6c828", size = 296378, upload-time = "2026-07-04T10:36:03.77Z" },
{ url = "https://files.pythonhosted.org/packages/31/16/cff14259c3d19a7f0ae88b6996fe4c85f6ff1764dad889ac8a39e843e39c/cbor2-6.1.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3d939f55097c21e032f5a2d67592fcc57298986281f219356e2f519e4466f4ea", size = 412779, upload-time = "2026-07-04T10:36:04.975Z" },
{ url = "https://files.pythonhosted.org/packages/50/6c/f3641d19b7b85a63cb2756c10164131489c2cb46b379ec51ae22283fefb9/cbor2-6.1.3-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:b025009478d644dab407164fd60e3ef4381af284f5af6966df94c663756d949e", size = 457781, upload-time = "2026-07-04T10:36:06.349Z" },
{ url = "https://files.pythonhosted.org/packages/55/85/0c55a66f3037056bfb8e1c7184168085fdea67ae5830404498bcf466233b/cbor2-6.1.3-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:2226d32e102e375737656ad5d141ad8c6ae3e705e04e263f24756f0eb379c6c1", size = 468373, upload-time = "2026-07-04T10:36:07.769Z" },
{ url = "https://files.pythonhosted.org/packages/46/74/40f7db3e0d880560193916a5c9b744fcf299558bed7113f77c28237c7c29/cbor2-6.1.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e61d465244d66ffed36492eef3b44d43795d76a2bba0663a2f15c186af7f7513", size = 523844, upload-time = "2026-07-04T10:36:09.404Z" },
{ url = "https://files.pythonhosted.org/packages/b5/a1/b5e07d6a08441c3a552fe2ae48ccb7e9dfc5065b9f6a3bae9879b4f0fbc0/cbor2-6.1.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:87fe7be8fab6ec4796aa127c1a52e09e79dbafd2aa31caf809cf04b8080a5975", size = 536238, upload-time = "2026-07-04T10:36:10.914Z" },
{ url = "https://files.pythonhosted.org/packages/c9/99/e166be0fd74bf3a91f5a0d103e34883efbc438d970f72cc8200e274787e5/cbor2-6.1.3-cp312-cp312-win32.whl", hash = "sha256:da25d345f01e6a40b2e5c57ef96b4dcff7be69394fb62f0f70e07f437f2376a9", size = 279858, upload-time = "2026-07-04T10:36:12.247Z" },
{ url = "https://files.pythonhosted.org/packages/d2/1b/90b4a121e40aba189c55a5822dd3c698eaf487e1d4a780ab18c804a5ef1c/cbor2-6.1.3-cp312-cp312-win_amd64.whl", hash = "sha256:d5514f693db6fa6f433b4096e9b604e6a7bf151c9ef1d2db86d0858e4c5e768f", size = 300929, upload-time = "2026-07-04T10:36:13.564Z" },
{ url = "https://files.pythonhosted.org/packages/19/db/52c58a8d33464927389dde8103997b3fa51b081ce29b347ac2cc4fd0dfbf/cbor2-6.1.3-cp312-cp312-win_arm64.whl", hash = "sha256:3d43183d7beb3d3cd198d69b31bd2ee487ed704a1150c75cb0a66d6ad63d8c1a", size = 290908, upload-time = "2026-07-04T10:36:14.857Z" },
{ url = "https://files.pythonhosted.org/packages/1c/8c/5024d623dcf3f2057ec8c991f584b939ba5f9025a5ce8c31f6fac067137a/cbor2-6.1.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:21c74b8ab67977c8b87b727247eeb730145b0068ad6d47f71e9f80f6b48c65f8", size = 412334, upload-time = "2026-07-04T10:36:16.299Z" },
{ url = "https://files.pythonhosted.org/packages/61/f3/e50654203c3b746166a96bea680eb6463b20c2c160cc14dfbe43f215ef6c/cbor2-6.1.3-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:f291a0ae4c1ed96eadb0afa9752568c7424f7d6fa818676d5e33005fcd22ddd9", size = 457125, upload-time = "2026-07-04T10:36:17.684Z" },
{ url = "https://files.pythonhosted.org/packages/44/86/6ef007f0d4f7afba90a80cb1657984de542e7474d2afaa7e920ac9860df3/cbor2-6.1.3-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:dc8e44c7bf172687195dcd428157885bc00ea06efc0ea30fb371163b92bef733", size = 467651, upload-time = "2026-07-04T10:36:19.007Z" },
{ url = "https://files.pythonhosted.org/packages/b2/f4/b5aa27813c02f37e03eb86bd908163562edd6fc7f99665bc7bbb25ef5e6c/cbor2-6.1.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:8ccf4d263983d830dd429d2b01f27be58ac02ba7c790c45d861f767eb63963e5", size = 523296, upload-time = "2026-07-04T10:36:20.504Z" },
{ url = "https://files.pythonhosted.org/packages/5b/46/e17b2bce2efdc26bfa045b4f6168f02923ac5f0e79732a1b3c42ce9ca9de/cbor2-6.1.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8719a7a2a2a82168844533389957b8f617a139f5f40e4d0ad7ed905fd3abebd1", size = 535537, upload-time = "2026-07-04T10:36:21.761Z" },
{ url = "https://files.pythonhosted.org/packages/54/bc/add350acf37f367ae429f997f2e047b042f2d5ef9ca62461c923fbb0f3c3/cbor2-6.1.3-cp313-cp313-win32.whl", hash = "sha256:c73b54ce09dd8d522f3c1540426e36172ba0f34abf3d89eb93909a5e14590003", size = 279233, upload-time = "2026-07-04T10:36:23.071Z" },
{ url = "https://files.pythonhosted.org/packages/e8/6f/1bbfce3b3131e4e03e8a86966a38ff92ebb72215fcf36aeecea1547f3e4b/cbor2-6.1.3-cp313-cp313-win_amd64.whl", hash = "sha256:b77df56c462c10eb3444db8ef78d8c3e71d9ef8d021ea92e97c1f9e3aa918690", size = 300585, upload-time = "2026-07-04T10:36:24.563Z" },
{ url = "https://files.pythonhosted.org/packages/e7/65/3945702dd84b6e5b7800c9c7f1ada038d33d12d0042de10e38164cc03dfd/cbor2-6.1.3-cp313-cp313-win_arm64.whl", hash = "sha256:b144be2ab3e9584ee7b6359d2a92fee0a5bec1d00dbd34c215ccc2040ac0b2ab", size = 290357, upload-time = "2026-07-04T10:36:25.983Z" },
{ url = "https://files.pythonhosted.org/packages/8f/40/04ad7d34182b27487a1824422b361b32d2607727ef20e563056eba62d12a/cbor2-6.1.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:3f3167ca9920b90db4ff72652db109dfd93b56ee0d583aca12f3a5d9a7019477", size = 414615, upload-time = "2026-07-04T10:36:27.299Z" },
{ url = "https://files.pythonhosted.org/packages/8e/29/94238c61f90653a606535e7509a2af312089fe10dafcb4cf82d6905a7a1a/cbor2-6.1.3-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:48c677971e4b71e685491e1a267d9924dc205e7ddbe3f34fd2562f16c0f6bfca", size = 459084, upload-time = "2026-07-04T10:36:28.717Z" },
{ url = "https://files.pythonhosted.org/packages/c8/cb/6bd33461e8be8ded7ebb0fa38994a63752aefae2b4fcd1b2cc71ee3c06f1/cbor2-6.1.3-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:ad4f3c6dfc6b83331eb04c6975efb2839ab65a3aa81502bc2b3f7945d4c4aa44", size = 469310, upload-time = "2026-07-04T10:36:30.136Z" },
{ url = "https://files.pythonhosted.org/packages/8e/d1/94195bcd8fcc1030ecaf22a7a825faa08891b5bb2d3553e465e50fe115f5/cbor2-6.1.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:8bcd609eab4a39745123bfb28e73311abba3d14975a87f5906e0e8b910d918ac", size = 524287, upload-time = "2026-07-04T10:36:31.453Z" },
{ url = "https://files.pythonhosted.org/packages/06/55/57178fbf2d1206af5299c688f9c917b83f30636694c8f932dc89c6652545/cbor2-6.1.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:672727ecb27d7fb3ca0bf8a58fc489d5374ab1fee680ed3d0348a11d9d3ca78f", size = 537031, upload-time = "2026-07-04T10:36:32.769Z" },
{ url = "https://files.pythonhosted.org/packages/fb/01/beefe26258d66ce39a9b057b679bc67dba81698d5a63e0ada5b4752a5c87/cbor2-6.1.3-cp314-cp314-win32.whl", hash = "sha256:1413cef2aa7f478a38298cd3492a055e8e8e45d17fb53bbe103e79ca15c33f3c", size = 286256, upload-time = "2026-07-04T10:36:34.078Z" },
{ url = "https://files.pythonhosted.org/packages/1b/2d/79eb513a2586a2053a6f18b33693beda65e2c766820d99676a306573fed5/cbor2-6.1.3-cp314-cp314-win_amd64.whl", hash = "sha256:59df264d4a508ba61daaa0bf3c2f92d63275509549a0875c1fa38176f651e4f8", size = 313744, upload-time = "2026-07-04T10:36:35.58Z" },
{ url = "https://files.pythonhosted.org/packages/a1/c7/ab9828e4efc26badf89f1397f866f3ef03ef65cdcd55518254b84bdaf86e/cbor2-6.1.3-cp314-cp314-win_arm64.whl", hash = "sha256:b62b5d80a0eb4305cd5f0217faa4d7747bd64fe0dff9b88415e7be3782f8249b", size = 304280, upload-time = "2026-07-04T10:36:36.865Z" },
{ url = "https://files.pythonhosted.org/packages/33/cf/54a497ad1026833c1c92d482edbda0bdebb48314b51563d2fcea24ab89b4/cbor2-6.1.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:856ab525bfc599588b8d2a45babb7c3400c693ea0bb574d818467d997102fe24", size = 409638, upload-time = "2026-07-04T10:36:38.089Z" },
{ url = "https://files.pythonhosted.org/packages/3b/03/efdecd0848b9c9e43242537f6dd8ac5d441a077d362e5e6954c7775b866a/cbor2-6.1.3-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:a076f35abf1dd0a4de6e2f7f4d4932abafc951a26275b6aa4a3b370c2fd3bbf4", size = 452193, upload-time = "2026-07-04T10:36:39.56Z" },
{ url = "https://files.pythonhosted.org/packages/ef/bf/b5f43c75dc5f0ca3127919d3273d8671917932aa3e90767da63867e0f06f/cbor2-6.1.3-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:37ccab1d0bd3f57ff536a41e44165d0c99cb166ac6e5ffb8b93c42304b56e48d", size = 466614, upload-time = "2026-07-04T10:36:40.936Z" },
{ url = "https://files.pythonhosted.org/packages/23/ee/e85b2ddd46b3b43e39a986704353b433efab0881234e1b4d824229fc2a75/cbor2-6.1.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:76d257ce797e651fa430b0269e8e8c43549c54ce8b0d12860569b3709bf1326f", size = 518503, upload-time = "2026-07-04T10:36:42.289Z" },
{ url = "https://files.pythonhosted.org/packages/60/13/c740c0002f127dc3e9e87b61a5d1285dd3b71aa11d8e0f6a408fc1f36173/cbor2-6.1.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:4af35baadb66f7c9cbb3998eab469767c04641552416c1c69dd4d3d183797119", size = 534236, upload-time = "2026-07-04T10:36:43.641Z" },
{ url = "https://files.pythonhosted.org/packages/73/cd/a57d97177f3777c96f3406efb0ad60794fdbf249d497ec24559bfd1d0328/cbor2-6.1.3-cp314-cp314t-win32.whl", hash = "sha256:61c92661665bccfed4ffa69d1fe10097f2c820d262f56a8cf909a5ebf9f6d8c6", size = 282446, upload-time = "2026-07-04T10:36:44.888Z" },
{ url = "https://files.pythonhosted.org/packages/11/c8/dd54878589df22c863d526cb82e1bb20e953d40223436449a52481c49804/cbor2-6.1.3-cp314-cp314t-win_amd64.whl", hash = "sha256:dc6bcd030bf5043662b84b0ca0f0ca942491bf509105db30cedca6e2ce82d158", size = 310300, upload-time = "2026-07-04T10:36:46.212Z" },
{ url = "https://files.pythonhosted.org/packages/05/d0/b0780a396d145e3356bfdc48578d021ade1818a6c7d7842a3d6bc6f16fbb/cbor2-6.1.3-cp314-cp314t-win_arm64.whl", hash = "sha256:da98a5e0ae9487bed497ac74e2c850b49975a3b7b5314b76c3843e2c83a6c8c4", size = 299391, upload-time = "2026-07-04T10:36:47.629Z" },
]
[[package]]
name = "cffi"
version = "2.1.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pycparser", marker = "implementation_name != 'PyPy'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/57/5f/ff100cae70ebe9d8df1c01a00e510e45d9adb5c1fdda84791b199141de97/cffi-2.1.0.tar.gz", hash = "sha256:efc1cdd798b1aaf39b4610bba7aad28c9bea9b910f25c784ccf9ec1fa719d1f9", size = 531036, upload-time = "2026-07-06T21:34:30.382Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d3/67/85c89a59ba36a671e79638f44d466749f08179266a57e4f2ffdf92174072/cffi-2.1.0-cp311-cp311-macosx_10_15_x86_64.whl", hash = "sha256:02cb7ff33ded4f1532476731f89ede53e2e488a8e6205515a82144246ffa7dcc", size = 183845, upload-time = "2026-07-06T21:32:26.32Z" },
{ url = "https://files.pythonhosted.org/packages/ea/dd/e3b0baa2d3d6a857ac72b7efbf18e32e487c9cdafcc13049ad765495b15e/cffi-2.1.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f5bce581e6b8c235e566a14768a943b172ada3ed73537bb0c0be1edee312d4e7", size = 184186, upload-time = "2026-07-06T21:32:28.025Z" },
{ url = "https://files.pythonhosted.org/packages/65/68/9f3ef890cf3c6ab97bd531c5677f67613d302165d16f8142b2811782a614/cffi-2.1.0-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:30b65779d598c370374fefabf138d456fd6f3216bfa7bedfab1ba82025b0cd93", size = 211892, upload-time = "2026-07-06T21:32:29.565Z" },
{ url = "https://files.pythonhosted.org/packages/22/d7/1a74539db16d8bfd839ff1515948948efbb162e574650fd3d846896eea95/cffi-2.1.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:88023dfe18799507b73f1dbb0d14326a17465de1bc9c9c7655c22845e9ddc3a2", size = 218793, upload-time = "2026-07-06T21:32:30.951Z" },
{ url = "https://files.pythonhosted.org/packages/ec/d1/9a5b7169499e8e8d8e636de70b97ac7c9447104d2ff1a2cd94790cea5162/cffi-2.1.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:0a96b74cda968eebbad56d973efe5098974f0a9fb323865bf99ea1fd24e3e64c", size = 205737, upload-time = "2026-07-06T21:32:32.216Z" },
{ url = "https://files.pythonhosted.org/packages/ba/b0/e131a9c41f10607926278453d9596163594fe1c4ebc46efe3b5e5b34eb84/cffi-2.1.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a5781494d4d400a3f47f8f1da94b324f6e6b440a53387774002890a2a2f4b50f", size = 204909, upload-time = "2026-07-06T21:32:33.655Z" },
{ url = "https://files.pythonhosted.org/packages/fb/d2/4398416cd699b35167947c6e22aca52c47e69ad5695073c9f1f2c52e04aa/cffi-2.1.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:aa7a1b53a2a4452ada2d1b5dade9960b2522f1e61293a811a077439e39029565", size = 217883, upload-time = "2026-07-06T21:32:35.173Z" },
{ url = "https://files.pythonhosted.org/packages/a2/a5/d4fe77b589e5e82d43ebc809bf2e6474afe8e48e32ea050b9357645b6471/cffi-2.1.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:9d8272c0e483b024e1b9ad029821470ed8ec65631dbd90217469da0e7cd89f1c", size = 221251, upload-time = "2026-07-06T21:32:36.527Z" },
{ url = "https://files.pythonhosted.org/packages/22/f0/a2fc43084c0433caf7f461bccc013e28f848d04ee1c5ed7fce71423cf4d9/cffi-2.1.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:7762faa47e8ff7eb80bd261d9a7d8eea2d8baa69de5e95b70c1f338bbe712f02", size = 214250, upload-time = "2026-07-06T21:32:37.852Z" },
{ url = "https://files.pythonhosted.org/packages/04/8c/b925975448cf20634a9fbd5efceb807219db452653648d2897c0989cab2d/cffi-2.1.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:89095c1968b4ba8285840e131bf2891b09ae137fe2146905acae0354fbce1b5e", size = 219441, upload-time = "2026-07-06T21:32:39.146Z" },
{ url = "https://files.pythonhosted.org/packages/eb/da/5c4918a2d61d86fa927d716cb3d8e4626ef8dc8f605a599d32f33897f59a/cffi-2.1.0-cp311-cp311-win32.whl", hash = "sha256:64c753a0f87a256020004f37a1c8c02c480e725f910f0b2a0f3f07debd1b2479", size = 174496, upload-time = "2026-07-06T21:32:40.467Z" },
{ url = "https://files.pythonhosted.org/packages/f9/c8/6c2de1d55cf35ef8b92885d5ef280790f0fb9634d87ea1cc315176aecd61/cffi-2.1.0-cp311-cp311-win_amd64.whl", hash = "sha256:4f26194e3d95e06501b942642855aed4f953d55e95d7d01b7c4483db3ecff458", size = 185113, upload-time = "2026-07-06T21:32:41.761Z" },
{ url = "https://files.pythonhosted.org/packages/9e/4e/e8d7cb5783f1841a3c8fb3a7735838d7484d08ec08c9f984b14cac1ac0e9/cffi-2.1.0-cp311-cp311-win_arm64.whl", hash = "sha256:35aaea0c7ee0e58a5cd8c2fd1a48fdf7ece0d2699b7ecdda08194e9ce5dd9b3d", size = 179927, upload-time = "2026-07-06T21:32:42.961Z" },
{ url = "https://files.pythonhosted.org/packages/1e/85/990925db5df586ec90beb97529c853497e7f85ba0234830447faf41c3057/cffi-2.1.0-cp312-cp312-macosx_10_15_x86_64.whl", hash = "sha256:df2b82571a1b30f58a87bf4e5a9e78d2b1eff6c6ce8fd3aa3757221f93f0863f", size = 184829, upload-time = "2026-07-06T21:32:44.324Z" },
{ url = "https://files.pythonhosted.org/packages/4b/92/e7bb136ad6b5352603732cf907ef862ca103f20f2031c1735a46300c20c9/cffi-2.1.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:78474632761faa0fb96f30b1c928c84ebcf68713cbb80d15bab09dfe61640fde", size = 184728, upload-time = "2026-07-06T21:32:45.683Z" },
{ url = "https://files.pythonhosted.org/packages/c3/c0/d1ec30ffb370f748f2fb54425972bfef9871e0132e82fb589c46b6676049/cffi-2.1.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:5972433ad71a9e46516584ef60a0fda12d9dc459938d1539c3ddecf9bdc1368d", size = 214815, upload-time = "2026-07-06T21:32:48.557Z" },
{ url = "https://files.pythonhosted.org/packages/1b/dc/5620cf930688be01f2d673804291de757a934c90b946dbdc3d84130c2ea4/cffi-2.1.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b6422532152adf4e59b110cb2808cee7a033800952f5c036b4af047ee43199e7", size = 222429, upload-time = "2026-07-06T21:32:49.848Z" },
{ url = "https://files.pythonhosted.org/packages/4b/a4/77b53abbf7a1e0beb9637edbef2a94d15f9c822f591e85d439ffd91519a6/cffi-2.1.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:46b1c8db8f6122420f32d02fffb924c2fe9bc772d228c7c711748fff56aabb2b", size = 210315, upload-time = "2026-07-06T21:32:51.221Z" },
{ url = "https://files.pythonhosted.org/packages/58/0c/f528df19cc94b675087324d4760d9e6d5bfae97d6217aa4fac43de4f5fcc/cffi-2.1.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9fafc5aa2e2a39aaf7f8cc0c1f044a9b07fca12e558dca53a3cc5c654ad67a7", size = 208859, upload-time = "2026-07-06T21:32:52.512Z" },
{ url = "https://files.pythonhosted.org/packages/62/f2/c9522a81c32132799a1972c39f5c5f8b4c8b9f00488a23feaa6c06f07741/cffi-2.1.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1e9f50d192a3e525b15a75ab5114e442d83d657b7ec29182a991bc9a88fd3a66", size = 221844, upload-time = "2026-07-06T21:32:53.704Z" },
{ url = "https://files.pythonhosted.org/packages/6e/28/bd53988b9833e8f8ad539d26f4c07a6b3f6bcb1e9e02e7ca038250b3428d/cffi-2.1.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:98fff996e983a36d3aa2eca83af40c5821202e7e6f32d13ae94e3d2286f10cfe", size = 225287, upload-time = "2026-07-06T21:32:54.907Z" },
{ url = "https://files.pythonhosted.org/packages/79/99/0d0fd37f055224085f42bbb2c022d002e17dde4a97972822327b07d84101/cffi-2.1.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:379de10ce1ba048b1448599d1b37b24caee16309d1ac98d3982fc997f768700b", size = 223681, upload-time = "2026-07-06T21:32:56.329Z" },
{ url = "https://files.pythonhosted.org/packages/b0/80/c138990aa2a70b1a269f6e06348729836d733d6f970867943f61d367f8cc/cffi-2.1.0-cp312-cp312-win32.whl", hash = "sha256:9b8f0f26ca4e7513c534d351eca551947d053fac438f2a04ac96d882909b0d3a", size = 175269, upload-time = "2026-07-06T21:32:57.777Z" },
{ url = "https://files.pythonhosted.org/packages/a8/eb/f636456ff21a83fc13c032b58cc5dde061691546ac79efa284b2989b7982/cffi-2.1.0-cp312-cp312-win_amd64.whl", hash = "sha256:c97f080ea627e2863524c5af3836e2270b5f5dfff1f104392b959f8df0c5d384", size = 185881, upload-time = "2026-07-06T21:32:59.253Z" },
{ url = "https://files.pythonhosted.org/packages/dd/2c/400ea43e721727dca8a65c4521390e9196757caba4a45643acb2b63271b8/cffi-2.1.0-cp312-cp312-win_arm64.whl", hash = "sha256:6d194185eabd279f1c05ebe3504265ddfc5ad2b58d0714f7db9f01da592e9eb6", size = 180088, upload-time = "2026-07-06T21:33:02.278Z" },
{ url = "https://files.pythonhosted.org/packages/96/88/a996879e2eeccb815f6e3a5967b12a308257412acec882039d386bd2aa7b/cffi-2.1.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:10537b1df4967ca26d21e5072d7d54188354483b91dc75058968d3f0cf13fbda", size = 194331, upload-time = "2026-07-06T21:33:03.697Z" },
{ url = "https://files.pythonhosted.org/packages/58/85/7ae00d5c8dd6266f4e944c3db630f3c5c9a98b61d469c714d848b1d8138a/cffi-2.1.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:a95b05f9baf29b91171b3a8bd2020b028835243e7b0ff6bb23e2a3c228518b1b", size = 196966, upload-time = "2026-07-06T21:33:05.353Z" },
{ url = "https://files.pythonhosted.org/packages/8c/e9/45c3a76ad8d43ad9261f4c95436da61128d3ca545d72b9612c0ab5be0b1c/cffi-2.1.0-cp313-cp313-macosx_10_15_x86_64.whl", hash = "sha256:15faec4adfff450819f3aee0e2e02c812de6edb88203aa58807955db2003472a", size = 184795, upload-time = "2026-07-06T21:33:06.699Z" },
{ url = "https://files.pythonhosted.org/packages/84/4c/82f132cb4418ee6d953d982b19191e87e2a6372c8a4ce36e50b69d6ade4a/cffi-2.1.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:716ff8ec22f20b4d988b12884086bcef0fc99737043e503f7a3935a6be99b1ea", size = 184746, upload-time = "2026-07-06T21:33:08.071Z" },
{ url = "https://files.pythonhosted.org/packages/a0/1c/4ed5a0e5bdca6cbc275556de3328dd1b76fd0c11cc13c88fe66d1d8715f2/cffi-2.1.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:63960549e4f8dc41e31accb97b975abaecfc44c03e396c093a6436763c2ea7db", size = 214747, upload-time = "2026-07-06T21:33:09.671Z" },
{ url = "https://files.pythonhosted.org/packages/3a/a6/e879bb68cc23a2bc9ba8f4b7d8019f0c2694bad2ab6c4a3701d429439f58/cffi-2.1.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ff067a8d8d880e7809e4ac88eb009bb848870115317b306666502ccad30b147f", size = 222392, upload-time = "2026-07-06T21:33:10.896Z" },
{ url = "https://files.pythonhosted.org/packages/88/f6/01890cfd63c08f8eb96a8319b0443690197d240a8bd6346048cf7bde9190/cffi-2.1.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:3b926723c13eba9f81d2ef3820d63aeceec3b2d4639906047bf675cb8a7a500d", size = 210285, upload-time = "2026-07-06T21:33:12.251Z" },
{ url = "https://files.pythonhosted.org/packages/a6/cf/2b684132056f438567b61e19d690dd31cd0921ace051e0a458be6074369e/cffi-2.1.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:47ff3a8bfd8cb9da1af7524b965127095055654c177fcfc7578debcb015eecd0", size = 208801, upload-time = "2026-07-06T21:33:13.617Z" },
{ url = "https://files.pythonhosted.org/packages/6f/08/f2e7d62c460faae0926f2d6e423694aa409ced3bc1fe2927a0a6e5f05416/cffi-2.1.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:799416bae98336e400981ff6e532d67d5c709cfb30afb79865a1315f94b0e224", size = 221808, upload-time = "2026-07-06T21:33:15.466Z" },
{ url = "https://files.pythonhosted.org/packages/38/37/04f54b8e63a02f3d908332c9effbf8c366167c6f733ed8a3d4f79b7e2a1e/cffi-2.1.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:961be50688f7fba2fa65f63712d3b9b341a22311f5253460ce933f52f0de1c8c", size = 225241, upload-time = "2026-07-06T21:33:16.869Z" },
{ url = "https://files.pythonhosted.org/packages/a9/d6/c72eecca433cd3e681c65ed313ab4835d9d4a379704d0f628a6a05f51c2e/cffi-2.1.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:bf5c6cf48238b0eb4c086978c492ad1cbc22373fc5b2d7353b3a598ce6db887a", size = 223588, upload-time = "2026-07-06T21:33:18.239Z" },
{ url = "https://files.pythonhosted.org/packages/c6/4b/e706f67279140f92939da3475ad610df18bfd52d50f14953a8e5fede71d5/cffi-2.1.0-cp313-cp313-win32.whl", hash = "sha256:db3eb7d46527159a878ec3460e9d40615bc25ba337d477db681aea6e4f05c5d2", size = 175248, upload-time = "2026-07-06T21:33:19.799Z" },
{ url = "https://files.pythonhosted.org/packages/5a/47/59eb7975cb0e4ef0afa764ea945b29a5bb4537a9f771cb7d6c8a5dd74c95/cffi-2.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:8e74a6135550c4748af665b1b1118b6aab33b1fc6a16f9aff630af107c3b4512", size = 185717, upload-time = "2026-07-06T21:33:21.47Z" },
{ url = "https://files.pythonhosted.org/packages/5a/af/34fee85c48f8d94efc8597bc09470c9dd274c145f1c12e0fbc6ab6d38d74/cffi-2.1.0-cp313-cp313-win_arm64.whl", hash = "sha256:2282cd5e38aa8accd03e99d1256af8411c84cdbee6a89d841b563fdbd1f3e50f", size = 180114, upload-time = "2026-07-06T21:33:22.515Z" },
{ url = "https://files.pythonhosted.org/packages/d8/f0/81478e482afa03f6d18dc8f2afb5edc45b3080853b634b5ed91961be0998/cffi-2.1.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:d2117334c3af3bdcb9a88522b844a2bdb5efdc4f71c6c822df55486ae1c3347a", size = 194142, upload-time = "2026-07-06T21:33:23.657Z" },
{ url = "https://files.pythonhosted.org/packages/7d/95/8de304305cd9204974b0ca051b86d307cafca13aa575a0ef1b44d92c0d8c/cffi-2.1.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:702c436735fbe99d59ada02a1f65cfc0d31c0ee8b7290912f8fbc5cd1e4b16c3", size = 196819, upload-time = "2026-07-06T21:33:25.007Z" },
{ url = "https://files.pythonhosted.org/packages/20/71/7c8372d30e42415602ed9f268f7cfd66f1b855fed881ecd168bcb45dbc0b/cffi-2.1.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:1ff3456eab0d889592d1936d6125bbfbc7ae4d3354a700f8bd80450a66445d4d", size = 184965, upload-time = "2026-07-06T21:33:26.605Z" },
{ url = "https://files.pythonhosted.org/packages/d6/5c/584e626835f0375c928176c04137c96927165cb8733cdb3150ec04e5ee5e/cffi-2.1.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c4165821e131d6d4ca444347c2b694e2311bcfa3fe5a861cc72968f28867beac", size = 184952, upload-time = "2026-07-06T21:33:27.823Z" },
{ url = "https://files.pythonhosted.org/packages/2e/d2/065fcae1c73979fac8e054462478d0ff8a29c40cdc2ed7ea5676a061df53/cffi-2.1.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:276f20fffd7b396e12516ba8edf9509210ac248cbbc5acbc39cd512f9f59ebe6", size = 222353, upload-time = "2026-07-06T21:33:29.178Z" },
{ url = "https://files.pythonhosted.org/packages/ed/a5/e8bbb1ce5b3ac2f53ad6a10bde44318a5a8d99d4f4a000d44a6e39aeb3e4/cffi-2.1.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:7d5980a3433d4b71a5e120f9dd551403d7824e31e2e67124fe2769c404c06913", size = 210051, upload-time = "2026-07-06T21:33:30.534Z" },
{ url = "https://files.pythonhosted.org/packages/28/ed/c127d3ac36e899c965e3361357c3befacd6578c03f40125183e41c3b219e/cffi-2.1.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:6ca4919c6e4f89aa99c42510b42cf54596892c00b3f9077f6bdd1505e24b9c8d", size = 208630, upload-time = "2026-07-06T21:33:31.753Z" },
{ url = "https://files.pythonhosted.org/packages/cc/d7/97d3136f81db489ec8d1d67748c110d6c994268fd7528014aa9f2b085e4e/cffi-2.1.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d53d10f7da99ae46f7373b9150393e9c5eab9b224909982b43832668de4779f5", size = 221593, upload-time = "2026-07-06T21:33:33.044Z" },
{ url = "https://files.pythonhosted.org/packages/d3/27/93195977168ee63aed233a1a0993a2178798654d1f4bddcdd321d6fd3b21/cffi-2.1.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c351efb95e832a853a29361675f33a7ce53de1a109cd73fd47af0712213aa4ce", size = 225146, upload-time = "2026-07-06T21:33:34.224Z" },
{ url = "https://files.pythonhosted.org/packages/b3/c1/6dbd291ee2ae5a50a034aa057207081f545923bbf15dad4511e985aafff5/cffi-2.1.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:dbf7c7a88e2bac086f06d14577332760bdeecc42bdec8ac4077f6260557d9326", size = 223240, upload-time = "2026-07-06T21:33:35.57Z" },
{ url = "https://files.pythonhosted.org/packages/0f/6f/ade5ce9863a57992a6ea3d0d10d7e29b8749fc127204b3d493d667b2815f/cffi-2.1.0-cp314-cp314-win32.whl", hash = "sha256:1854b724d00f6654c742097d5387569021be12d3a0f770eae1df8f8acfcc6acd", size = 177723, upload-time = "2026-07-06T21:33:51.626Z" },
{ url = "https://files.pythonhosted.org/packages/41/de/92b9eeed4ae4a21d6fd9b2a2c8505cbed573299902ea73981cc13f7ff62c/cffi-2.1.0-cp314-cp314-win_amd64.whl", hash = "sha256:1b96bfe2c4bd825681b7d311ad6d9b7280a091f43e8f63da5729638083cd3bfb", size = 187937, upload-time = "2026-07-06T21:33:53.403Z" },
{ url = "https://files.pythonhosted.org/packages/2e/1a/cc6ae6c2913a03aab8898eee57963cf1035b8df5872ed8b9115fcc7e2be8/cffi-2.1.0-cp314-cp314-win_arm64.whl", hash = "sha256:7d28dff1db6764108bc30788d85d61c876beff416d9a49cb9dd7c5a9f34f5804", size = 183001, upload-time = "2026-07-06T21:33:54.74Z" },
{ url = "https://files.pythonhosted.org/packages/14/f0/134c00ce0779ec86dea2aa1aac69339c2741a8045072676763512363a2ea/cffi-2.1.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:7ea6b3e2c4250ff1de21c630fe72d0f63eb95c2c32ffbf64a358cf4a8836d714", size = 188538, upload-time = "2026-07-06T21:33:36.792Z" },
{ url = "https://files.pythonhosted.org/packages/50/d8/3b86aba791cb610d24e8a3e1b2cd529e71fa15096b04e4d4e360049d4a4c/cffi-2.1.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6af371f3767faeffc6ac1ef57cdfd25844403e9d3f476c5537caee499de96376", size = 188230, upload-time = "2026-07-06T21:33:38.011Z" },
{ url = "https://files.pythonhosted.org/packages/14/d0/117dcd9209255ad8571fbc8c92ef32593a1d294dcec91ddc4e4db50606f2/cffi-2.1.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:eb4e8997a49aa2c08a3e43c9045d224448b8941d88e7ac163c7d383e560cbf98", size = 223899, upload-time = "2026-07-06T21:33:39.514Z" },
{ url = "https://files.pythonhosted.org/packages/b6/3d/f20f8b886b254e3ad10e15cd4186d3aed49f3e6a35ab37aab9f8f25f7c03/cffi-2.1.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:bf01d8c84cbea96b944c73b22182e6c7c432b3475632b8111dbfdc95ddad6e13", size = 211652, upload-time = "2026-07-06T21:33:40.851Z" },
{ url = "https://files.pythonhosted.org/packages/28/3b/fad54de07260b93ddeef4b96d0131d57ea900675df1d410ae1deee52d7a6/cffi-2.1.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:33eb1ad83ebe8f313e0df035c406227d55a79456704a863fad9842136af5ad7d", size = 210755, upload-time = "2026-07-06T21:33:42.183Z" },
{ url = "https://files.pythonhosted.org/packages/cc/82/3d5c705acb7abbba9bbd7d79b8e62e0f25b6120eb7ae6ac49f1b721722fe/cffi-2.1.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ac0f1a2d0cfa7eea3f2aaf006ab6e70e8feeb16b75d65b7e5939982ca2f11056", size = 223933, upload-time = "2026-07-06T21:33:43.603Z" },
{ url = "https://files.pythonhosted.org/packages/6c/d0/47e338384ab6b1004241002fa616301020cea4fc95f283506565d252f276/cffi-2.1.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c16914df9fb7f500e440e6875fa23ff5e0b31db01fa9c06af98d59a91f0dc2e4", size = 226749, upload-time = "2026-07-06T21:33:45.046Z" },
{ url = "https://files.pythonhosted.org/packages/70/25/65bd5b58ea4bfdfc15cde02cb5365f89ef8ab8b2adfb8fe5c4bd4233382f/cffi-2.1.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5ecbd0499275d57506d397eebe1981cee87b47fcd9ef5c22cab7ed7644a39a94", size = 225703, upload-time = "2026-07-06T21:33:46.374Z" },
{ url = "https://files.pythonhosted.org/packages/dc/78/aa01ac599a8a4322533d45a1f9bc93b338276d2d59dabbe7c6d92a775c81/cffi-2.1.0-cp314-cp314t-win32.whl", hash = "sha256:7d034dcffa09e9a46c93fa3a3be402096cb5354ac6e41ab8e5cc9cd8b642ad76", size = 182857, upload-time = "2026-07-06T21:33:47.696Z" },
{ url = "https://files.pythonhosted.org/packages/b9/26/d00496b22de4d4228f32dde94ad996f350c8aad676d63bcca0743c8dea4d/cffi-2.1.0-cp314-cp314t-win_amd64.whl", hash = "sha256:0582a58f3051372229ca8e7f5f589f9e5632678208d8636fea3676711fdf7fe5", size = 194065, upload-time = "2026-07-06T21:33:48.953Z" },
{ url = "https://files.pythonhosted.org/packages/d5/dd/0c7dbf815a579ff005008a2d815a55d6bb047c349eef536d9dc53d3f0a8d/cffi-2.1.0-cp314-cp314t-win_arm64.whl", hash = "sha256:510aeeeac94811b138077451da1fb18b308a5feab47dd2b603af55804155e1c8", size = 186404, upload-time = "2026-07-06T21:33:50.309Z" },
{ url = "https://files.pythonhosted.org/packages/55/c7/8c8c50cb11c6750051daf12164098a9a6f027ac4356967fd4d800a07f242/cffi-2.1.0-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:2e9dabb9abcb7ad15938c7196ad5c1718a4e6d33cc79b4c0209bdb64c4a54a5c", size = 194121, upload-time = "2026-07-06T21:33:56.109Z" },
{ url = "https://files.pythonhosted.org/packages/99/e2/67680bf19a6b60d2bb7ff83baefa2a4c3d2d7dc0f3277034b802e1fc504c/cffi-2.1.0-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:37f525a7e7e50c017fdebe58b787be310ad59357ae43a053943a6e1a6c526001", size = 196820, upload-time = "2026-07-06T21:33:57.288Z" },
{ url = "https://files.pythonhosted.org/packages/ed/da/4bbe583a3b3a5c8c60892124fe17f3fa3656523faf0d3484eae90f091853/cffi-2.1.0-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:95f2954c2c9473d892eca6e0409f3568b37ab62a8eedb122461f73cc273476e3", size = 184936, upload-time = "2026-07-06T21:33:58.765Z" },
{ url = "https://files.pythonhosted.org/packages/e5/4b/1f4c36ab273980d7aa75bb126ea4f8971f24a96108acad3a0a084028c57b/cffi-2.1.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:cdf2448aab5f661c9315308ec8b93f4e8a1a67a3c733f8631067a2b67d5913dc", size = 185045, upload-time = "2026-07-06T21:34:00.085Z" },
{ url = "https://files.pythonhosted.org/packages/ef/c3/ad299dc38f3583f8d916b299f028af418a9ec98bc695fcbebeae7420691c/cffi-2.1.0-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:90bec57cf82089383bd06a605b3eb8daebf7e5a668520beaf6e327a83a947699", size = 222342, upload-time = "2026-07-06T21:34:01.814Z" },
{ url = "https://files.pythonhosted.org/packages/eb/d8/df4543cc087245044ed02ef3ad8e0a26619d0075ac7a77a12dc81177851b/cffi-2.1.0-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:6274dcb2d15cef48daa73ed1be5a40d501d74dccd0cd6db364776d12cb6ba022", size = 210073, upload-time = "2026-07-06T21:34:03.255Z" },
{ url = "https://files.pythonhosted.org/packages/2c/0e/fac738d73728c6cea2a88a2883dca54892496cbba88a1dc1f2909cb8a6f5/cffi-2.1.0-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:2b71d409cccee78310ab5dec549aed052aaea483346e282c7b02362596e01bb0", size = 208551, upload-time = "2026-07-06T21:34:04.433Z" },
{ url = "https://files.pythonhosted.org/packages/e6/3f/0b04a700dd64f465c93020253a793a82c9b4dff9961f48facd0df945d9b8/cffi-2.1.0-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7d3538f9c0e50670f4deb93dbb696576e60590369cae2faf7de681e597a8a1f1", size = 221649, upload-time = "2026-07-06T21:34:06.157Z" },
{ url = "https://files.pythonhosted.org/packages/5d/7c/b7379a5704c79eda57ce075869ba70a0368d1c850f803b3c0d078d39dcaf/cffi-2.1.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:8f9ec95b8a043d3dfbc74d9abc6f7baf524dd27a8dc160b0a32ff9cdab650c28", size = 225203, upload-time = "2026-07-06T21:34:07.489Z" },
{ url = "https://files.pythonhosted.org/packages/5a/02/d5e6c43ea85c41bda2a184a3418f195fe7cf602967a8d2b94e085b83deef/cffi-2.1.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:af5e2915d41fe6c961694d7bfdc8562942638200f3ce2765dfb8b745cf997629", size = 223263, upload-time = "2026-07-06T21:34:08.712Z" },
{ url = "https://files.pythonhosted.org/packages/2c/d8/772b8259bf75749adffb1c546828978381fb516f60cf701f6c83daf60c85/cffi-2.1.0-cp315-cp315-win32.whl", hash = "sha256:0a42c688d19fca6e095a53c6a6e2295a5b050a8b289f109adab02a9e61a25de6", size = 177696, upload-time = "2026-07-06T21:34:26.355Z" },
{ url = "https://files.pythonhosted.org/packages/2f/dd/afa2191fc6d57fedd26e5844a2fe2fcc0bbfa00961bbaa5a41e4921e7cca/cffi-2.1.0-cp315-cp315-win_amd64.whl", hash = "sha256:bccbbb5ee76a61f9d99b5bf3846a51d7fca4b6a732fe46f89295610edaf41853", size = 187914, upload-time = "2026-07-06T21:34:27.58Z" },
{ url = "https://files.pythonhosted.org/packages/05/ef/6cd4f8c671517162379dc79cfae5aea9106bc38abb89628d5c16adf6a838/cffi-2.1.0-cp315-cp315-win_arm64.whl", hash = "sha256:8d35c139744adb3e727cd51b1a18324bbe44b8bd41bf8322bca4d41289f48eda", size = 183004, upload-time = "2026-07-06T21:34:28.905Z" },
{ url = "https://files.pythonhosted.org/packages/11/b6/12fc55092817a5faa26fb8c40c7f9d662e11a46ee248c137aafc42517d92/cffi-2.1.0-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:f9912624a0c0b834b7520d7769b3644453aabc0a7e1c839da7359f050750e9bc", size = 188378, upload-time = "2026-07-06T21:34:09.926Z" },
{ url = "https://files.pythonhosted.org/packages/8d/2e/cdac88979f295fde5daa69622c7d2111e56e7ceb94f211357fbe452339e4/cffi-2.1.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:df92f2aba50eb4d96718b68ef76f2e57a57b54f2fa62333496d16c6d585a85ca", size = 188319, upload-time = "2026-07-06T21:34:11.101Z" },
{ url = "https://files.pythonhosted.org/packages/e0/27/1d0b408497e41a74795af122d7b603c418c5fed0171450f899afd04e594f/cffi-2.1.0-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:0520e1f4c35f44e209cbbb421b67eec42e6a157f59444dfb6058874ff3610e5d", size = 223904, upload-time = "2026-07-06T21:34:12.606Z" },
{ url = "https://files.pythonhosted.org/packages/8b/31/e115c985105dd7ffb32444505f18ceb874bb42d992af05d5dced7ecf1980/cffi-2.1.0-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:3681e031db29958a7502f5c0c9d6bbc4c36cb20f7b104086fa642d1799631ff8", size = 211554, upload-time = "2026-07-06T21:34:13.987Z" },
{ url = "https://files.pythonhosted.org/packages/5a/67/9e6e09409336d9e515c58367e7cfcf4f89df06ad25252675595a58eb59d5/cffi-2.1.0-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:762f99479dcb369f60ab9017ad4ab97a36a1dd7c1ee5a3b15db0f4b8659120cd", size = 210795, upload-time = "2026-07-06T21:34:15.972Z" },
{ url = "https://files.pythonhosted.org/packages/19/e5/d3cc82a4a0be7902af279c04181ad038449c096734464a5ae1de3e1401bd/cffi-2.1.0-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0611e7ebf90573a535ebdc33ae9da222d037853983e13359f580fab781ca017f", size = 223843, upload-time = "2026-07-06T21:34:17.509Z" },
{ url = "https://files.pythonhosted.org/packages/b9/65/b434abc97ce7cecc2c640fde160507c0ecc7e21544b483ba3325d2e2ea17/cffi-2.1.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:86cf8755a791f72c85dc287128cc62d4f24d392e3f1e15837245623f4a33cccc", size = 226773, upload-time = "2026-07-06T21:34:19.05Z" },
{ url = "https://files.pythonhosted.org/packages/b5/9f/d4dc66ca651eb1145a133314cda721abf13cfac3d28c4a0402263ae6ad75/cffi-2.1.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:ba00f661f8ba35d075c937174e27c2c421cec3942fd2e0ea3e66996757c0fdd9", size = 225719, upload-time = "2026-07-06T21:34:20.576Z" },
{ url = "https://files.pythonhosted.org/packages/68/5a/e536c528bc8057496c360c0978559a2dc45653f89dd6151078aa7d8fca1a/cffi-2.1.0-cp315-cp315t-win32.whl", hash = "sha256:cb96698e3c7413d906ce83f8ffd245ec1bd94707541f299d0ce4d6b0193e982b", size = 182760, upload-time = "2026-07-06T21:34:22.059Z" },
{ url = "https://files.pythonhosted.org/packages/d3/0b/0ffe8b82d3875bced5fa1e7986a7a46b748262a40ab7f60b475eb9fb1bb3/cffi-2.1.0-cp315-cp315t-win_amd64.whl", hash = "sha256:f146d154428a2523f9cc7936c02353c2459b8f6cf07d3cd1ee1c0a611109c5d5", size = 193769, upload-time = "2026-07-06T21:34:23.589Z" },
{ url = "https://files.pythonhosted.org/packages/a0/17/1073b53b68c9b5ca6914adf5f8bf55aacc2d3be102418c90700160ea8605/cffi-2.1.0-cp315-cp315t-win_arm64.whl", hash = "sha256:cbb7640ce37159548d2147b5b8c241f962143d4c71231431820783f4dc78f210", size = 186405, upload-time = "2026-07-06T21:34:24.857Z" },
]
[[package]]
name = "cryptography"
version = "49.0.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "cffi", marker = "platform_python_implementation != 'PyPy'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/1f/99/d1c90d6041656cc6ee229dc99cd67fd0cd5aec3c5f7d72fffc27cc750054/cryptography-49.0.0.tar.gz", hash = "sha256:f89660a348f4f78a92366240a61404e337586ef7f5909a2fef59ca88ef505493", size = 854345, upload-time = "2026-06-12T20:02:30.512Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/9b/22/adf66990e63584a68dfb50c24f48a125c07b1699899381c8151e63ed458c/cryptography-49.0.0-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:966fe0e9c67490071f14c0d2b1cb2dfb3023c5ce39457343931415f08382f2db", size = 4032100, upload-time = "2026-06-12T20:02:32.143Z" },
{ url = "https://files.pythonhosted.org/packages/09/41/3797cfaf69cae04a13ee78ebd83f0678d9c02b4779d21ce24445326f1a69/cryptography-49.0.0-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:36d1709f992593689b45bda411498d62c6e365f2ca00b84657d4dadd24de16db", size = 4692978, upload-time = "2026-06-12T20:01:21.305Z" },
{ url = "https://files.pythonhosted.org/packages/e6/8b/43011f7ebe515a8aa20d61f290a326cd890c2e738e16e59eaff8d9c3a412/cryptography-49.0.0-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0e959b578856a3924bc0cbb710fc12c387b9412a951389f3ca61704a9e25f325", size = 4716422, upload-time = "2026-06-12T20:01:48.566Z" },
{ url = "https://files.pythonhosted.org/packages/4a/91/01ce7303a4579e6d3a6abef01bd322848e9ea7a219adcabc5048b9033571/cryptography-49.0.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:53ecee2e23f7169b6117e99fc8a944e5e50f79e69758a83b52a00cb98ab2b2d2", size = 4700503, upload-time = "2026-06-12T20:02:47.091Z" },
{ url = "https://files.pythonhosted.org/packages/62/99/a2c95cf8293f07491e9e27c20cc4dcd18176d944e674679adeb1d0173fd6/cryptography-49.0.0-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:2eda353d8a27bcbcaa4cbed18994a74ab4d19a2ca897db188ea269ab9b71419b", size = 5309779, upload-time = "2026-06-12T20:02:08.987Z" },
{ url = "https://files.pythonhosted.org/packages/20/2c/0622f20ff02b2ef32558733443805dc82fd4c275be01b2d19d14676f3a1b/cryptography-49.0.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:2afe9051da7ae7bd5905da5a949280c7d2bb75682e188f650a9d0f2756b834c6", size = 4749683, upload-time = "2026-06-12T20:02:03.335Z" },
{ url = "https://files.pythonhosted.org/packages/a3/5b/c5246635d5fd3b64e0d45ae10e99fd32fe9676a79915ccfe5a61ba9af1a5/cryptography-49.0.0-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:0b82e28ee398a386f0807bba7884d30f25218855690f45115831bcce5d90822c", size = 4337874, upload-time = "2026-06-12T20:02:54.323Z" },
{ url = "https://files.pythonhosted.org/packages/6d/88/05563c7fe2e914e87d1a536d06fe83e66b4e1d95cb593e05aea375531da8/cryptography-49.0.0-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:ccac2bfebc306b862133e3bb71f3f6ee8bb525240089b2d952e4144b3a6d5da7", size = 4700283, upload-time = "2026-06-12T20:01:34.822Z" },
{ url = "https://files.pythonhosted.org/packages/c4/b6/d7696e4e890d6ae1469935164c9e5215c557671cb78d6e3f458ccceaa632/cryptography-49.0.0-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:d0527ce944105f257f605a827d6ebead966c752038b6e8656abb9c5edee6fc68", size = 5265844, upload-time = "2026-06-12T20:01:24.09Z" },
{ url = "https://files.pythonhosted.org/packages/a9/3c/f3ad17eecc1a57b0ba236dc01f90e783c51f4a2f35f64777cc4f47a184b2/cryptography-49.0.0-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:cbc77da8c523d5abd028635ba850a6966fcee2c82e2bf65a41d1d8afe0f98be9", size = 4749290, upload-time = "2026-06-12T20:01:30.848Z" },
{ url = "https://files.pythonhosted.org/packages/4f/01/339573cf1023163a400b0b5d16f6d507de413b9f60be6fd1b77feeaf6737/cryptography-49.0.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:b87e65d263b3e5d3bb92a57e2a6638e2f31110fa7aa890c7b2dbba42248d0a3f", size = 4834612, upload-time = "2026-06-12T20:01:29.246Z" },
{ url = "https://files.pythonhosted.org/packages/71/fd/577302e213a1be9468f92d1afef66fcf1ef83d516819d9992ca547f592bd/cryptography-49.0.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:66ec79c3904820572d7e987abdf304281f141d37ad9a489b8e97066e7b9b6459", size = 4980804, upload-time = "2026-06-12T20:01:42.853Z" },
{ url = "https://files.pythonhosted.org/packages/1f/09/f42b1d190c5ba75f72062a387f8030d1d75f6ab035788f1d9c4b01de6525/cryptography-49.0.0-cp311-abi3-win_amd64.whl", hash = "sha256:e5dfc1e64de5677cec922ffa8da89c546d0415bf6efdf081842e5d44c84e1f0e", size = 3810026, upload-time = "2026-06-12T20:02:39.262Z" },
{ url = "https://files.pythonhosted.org/packages/ec/9e/db72b3ae7fc9cfad53e630e56c6ae83b9b6ff0bf3718ffb8012d20b3aabf/cryptography-49.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:73a205dce83953d131a4aa1e0fd917a2fd1c5b1eef251e9d7152efefcbf5caf7", size = 4013892, upload-time = "2026-06-12T20:02:10.735Z" },
{ url = "https://files.pythonhosted.org/packages/86/12/c48a424f38db03027be9f7ed5c7dc5de9933dbee992865f98b13727a009d/cryptography-49.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:196ecd6a36e4e9aa10270393bb98d8df88fccee0bf1e5128b91ae4eb4375896d", size = 4678835, upload-time = "2026-06-12T20:02:48.743Z" },
{ url = "https://files.pythonhosted.org/packages/68/28/8a3ad4653662c93fc44dc4e5d8fd374c25c42e07b34bbfbadf49cf57a5a8/cryptography-49.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7abcee80084cda3f7691f3eb1ce480d8df49cec637b429aa35986c1de71738aa", size = 4697239, upload-time = "2026-06-12T20:02:56.03Z" },
{ url = "https://files.pythonhosted.org/packages/a8/b2/2193fc74f81aee4f9b62733133b73b5176718932ed8f2e4b03fa040480a6/cryptography-49.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:4ae387c9cb68ea569ca17e490d66d8142b81c3cc814bf179974b7d146e490bbb", size = 4685593, upload-time = "2026-06-12T20:02:50.666Z" },
{ url = "https://files.pythonhosted.org/packages/47/f1/1d3eaa243bfc5de4a187b22aa8c048b3e4980bfbe830ac46e6bac2e66947/cryptography-49.0.0-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:f37d847238971164fdbc68ade6f6574aecc9c0af714190e2083429ff68f4ce9d", size = 5289961, upload-time = "2026-06-12T20:01:46.468Z" },
{ url = "https://files.pythonhosted.org/packages/58/39/2d51306721330c486495853eda1c567880ff036de15a14c4b74f399934af/cryptography-49.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:c2bc30226390d60ea19d9f82b19db005fe0452154a23c1c410c12ea801e43561", size = 4731145, upload-time = "2026-06-12T20:02:16.832Z" },
{ url = "https://files.pythonhosted.org/packages/17/50/983e838c7fd0d87fd8c969bcdd328edaf5f756e38df5281637424c155873/cryptography-49.0.0-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:07cab27cc7b7e0fd28e5e26bb9eeedde5c135c868b46de4a27845abe94af6122", size = 4321719, upload-time = "2026-06-12T20:02:52.611Z" },
{ url = "https://files.pythonhosted.org/packages/a7/f5/8f571d7e27c55bce9f76f026143bcb1e040a4233149ecca0bea5fa5dd5f7/cryptography-49.0.0-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:b20133d204d2bb56ba047642199603876c872026ca53e79c35b83772ab2cc505", size = 4685209, upload-time = "2026-06-12T20:02:07.282Z" },
{ url = "https://files.pythonhosted.org/packages/e7/84/0e27016a6fc5a0886f797018b26aa42f40c09a82332bff77822a451deaaa/cryptography-49.0.0-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:b970c6da94d5bb18629db453d14f2a1300f6bf59b61e9b82377931ef95504866", size = 5246285, upload-time = "2026-06-12T20:01:32.439Z" },
{ url = "https://files.pythonhosted.org/packages/11/2d/5e1fb307cb5931881516b464c98774b3f2c36b5d4bb9a2830253cf553cad/cryptography-49.0.0-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:d8ecde755e2e91bf773fc94e8c9d730cd7f2007004cb492263a794ec3899a1c8", size = 4730441, upload-time = "2026-06-12T20:02:01.469Z" },
{ url = "https://files.pythonhosted.org/packages/e4/c0/bff5a02ee731d207d6a1ed51732549d8c53d2bc8da1d10ec6f2844201d68/cryptography-49.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e3fb64c420688e5319ae25113a354015abbd8dffbfbc41781a1ea66fc7622ac3", size = 4815869, upload-time = "2026-06-12T20:01:36.574Z" },
{ url = "https://files.pythonhosted.org/packages/b9/26/814681d14248d95d73d5c3eea0c39a94eb8302df966f670a2c60de90974b/cryptography-49.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:32703d93296f5c1f4b53349ad3a250c2cae0fdecd3a3dd5d47e616d8d616af27", size = 4960948, upload-time = "2026-06-12T20:02:18.688Z" },
{ url = "https://files.pythonhosted.org/packages/4c/fe/93ecac273d3738939d023612ad12cca9a3740a5345d69fda04134c43fd96/cryptography-49.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:33cd0565932807baddb67b96dbee92f2c374b5c89dee09fd74079aeb8c8dba61", size = 3799153, upload-time = "2026-06-12T20:01:39.059Z" },
{ url = "https://files.pythonhosted.org/packages/19/2a/5bb823f5bedcf80718cea7fbc95ec5515cca3769633c4b01a32be7f30e7c/cryptography-49.0.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:ec5e529fb80935c94fe7b729f9972b50e351a0e6b50aa294fd5cabb109fcc29a", size = 4025947, upload-time = "2026-06-12T20:01:25.745Z" },
{ url = "https://files.pythonhosted.org/packages/3d/df/40577043ca124e17012f408ddddaeb213b856336ac82ddb3bc915f39e29f/cryptography-49.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f78ff2c9ed8dc2d036b0f4d640e22522213d047c1b14e61205a7e55c80a494d4", size = 4692429, upload-time = "2026-06-12T20:01:53.628Z" },
{ url = "https://files.pythonhosted.org/packages/2c/99/2d13299eb3dd27b02dcfaafcc91d6b5cb3329f7cbd6d8f51921acd566c1a/cryptography-49.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:35b151772baff2c74cba7fa290ceaff4c3b11c0c881eb93eb5dbc05a7cfbba18", size = 4700968, upload-time = "2026-06-12T20:02:45.383Z" },
{ url = "https://files.pythonhosted.org/packages/a5/4d/9c0cd02f95e2602dd5e563da149ee0830abef3537be8b34dc56281ebe27a/cryptography-49.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:0f21641cf4b30fca7aee061ced0ec7ad7b073518088b7c9969a297c0ae796c69", size = 4697758, upload-time = "2026-06-12T20:01:41.13Z" },
{ url = "https://files.pythonhosted.org/packages/24/01/186c825898477d77e2324d5360fefe622ff1d8d1963ec0554e2cada8ec77/cryptography-49.0.0-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:9e82dcc8e56052715fb18b2429e3bca4823b1629136a2084fc45a9a5cecb9b64", size = 5298863, upload-time = "2026-06-12T20:02:24.579Z" },
{ url = "https://files.pythonhosted.org/packages/b8/7b/62cbbab75d0659865bf0273790031544a0b16c8072d258f9428dcd8190dc/cryptography-49.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:6f2debedf9ca60cf1d5bd466475638af5130f89965605cd818484d19987d3a21", size = 4735983, upload-time = "2026-06-12T20:01:50.14Z" },
{ url = "https://files.pythonhosted.org/packages/6c/72/3e798c064bc39e471008075d0f9bc9daf77a80879c092e4a8e170c585ed4/cryptography-49.0.0-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:8c25ceb16df5b9435f3f6a9829204985b0e0cbee3b48aacd432c7d2c850b44d9", size = 4334173, upload-time = "2026-06-12T20:01:44.743Z" },
{ url = "https://files.pythonhosted.org/packages/f0/ee/6fca21d1ac73e06f8bef71940abfd4d2f6472b4bca284d770f32bd4086f6/cryptography-49.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:28d8b15e6275f12c8a207dc309dfa957903c927d08d0cc937ee3f63f200693cc", size = 4697298, upload-time = "2026-06-12T20:02:20.918Z" },
{ url = "https://files.pythonhosted.org/packages/67/d0/a5fcd3515f0bae49a7b6d0413cc1bdccdcc1fc0047037a0d480642cdc5d6/cryptography-49.0.0-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:6fc361c34fb6aac015ce19435876635e5c6d21db31998b0920f675f131e043b8", size = 5254338, upload-time = "2026-06-12T20:02:22.737Z" },
{ url = "https://files.pythonhosted.org/packages/a0/84/84fe36f19caf857d61cb7fc9c63035a47ffabd84ea12d1d393148efa3615/cryptography-49.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:2400ef9c9e2299a25614eb1dea3db54a69b1349efd043bfac9c67630d136df36", size = 4735650, upload-time = "2026-06-12T20:02:41.389Z" },
{ url = "https://files.pythonhosted.org/packages/6c/a0/db537264e234f7273a73ec020873d6d6b39dfd8a53db78b550ca8320440e/cryptography-49.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:67e1d20ad9ef3a563c59ef22e7a8a0b8210bd26604369ea4a30a7c66aefe504e", size = 4834820, upload-time = "2026-06-12T20:01:51.847Z" },
{ url = "https://files.pythonhosted.org/packages/93/77/8df9eb486495979bccecd1062e2eaf435250e84437040295b57d09048b0b/cryptography-49.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:42b0684e0e40cf26122427802486f6d93aea593612603a94fbf260c7eb1e9c1b", size = 4967968, upload-time = "2026-06-12T20:02:12.524Z" },
{ url = "https://files.pythonhosted.org/packages/c2/e6/f60198ea8d9dfa15fff9ed4ca02ce362f6eadd9ba757dcc50634c4257b63/cryptography-49.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:026ac7423e6fa66872d3bf889be5974507da3944f866f704fa200eadacd00001", size = 3785547, upload-time = "2026-06-12T20:02:26.847Z" },
{ url = "https://files.pythonhosted.org/packages/63/d3/4a83af35d65e3fad632c926fad684c193ea4398569ccb0bbbc7fe8f5dc9a/cryptography-49.0.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:fc1e275c2f1d97b1a6450b8b0ea3ebfa6e087a611c2b26cb2404d48588abab7b", size = 3993685, upload-time = "2026-06-12T20:02:14.883Z" },
{ url = "https://files.pythonhosted.org/packages/d6/a7/f9dac0ab7f80368c56993a7bf638ef9935f825c91902798481fac0898138/cryptography-49.0.0-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:c83782480a4a9da4d0feb51950131ba32e12e70813848b3343f6e18c28a66838", size = 4676239, upload-time = "2026-06-12T20:02:28.793Z" },
{ url = "https://files.pythonhosted.org/packages/d7/70/2ba3769dd0ae167e2f33dfa9592d45db6ff9a61d62ca1a5b3d1bdd09068f/cryptography-49.0.0-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:b39efa323140595abd3ecca8529d321ae50f55f3aa3ba9cc81ea56a6011953d5", size = 4715584, upload-time = "2026-06-12T20:01:27.495Z" },
{ url = "https://files.pythonhosted.org/packages/94/64/2923570ac1c0bd3a737aa366ac3abbbbde273042308b8cde95e2364a6e6a/cryptography-49.0.0-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:b47db11c2c3525083296069b98ac5221907455e989ae0c2e3008bde851921615", size = 4675885, upload-time = "2026-06-12T20:01:55.49Z" },
{ url = "https://files.pythonhosted.org/packages/ab/f8/614dc7e051418cfe53d55173c1e24c6b0085e89996fe90508c2fdf769aef/cryptography-49.0.0-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:084ef1af862eb07ec46d25f68689f2102a9fc0e05ce7b80f14f5fe51e4eef0f6", size = 4715449, upload-time = "2026-06-12T20:02:05.469Z" },
{ url = "https://files.pythonhosted.org/packages/aa/50/a9caea39ad19c431c1a3f8a31114df65b260cdfe67786b6c7e7c040c4c44/cryptography-49.0.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:be9fcb48a55f023493482827d4f459bd263cc20efde64f204b97c123201850c6", size = 3783731, upload-time = "2026-06-12T20:02:43.319Z" },
]
[[package]]
name = "lxmf"
version = "1.0.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "rns" },
]
sdist = { url = "https://files.pythonhosted.org/packages/33/2b/b1df90fce728db3a0659da44bfa8f1337670ccc6a937b7a988c119a4b05c/lxmf-1.0.1.tar.gz", hash = "sha256:d12ead448296cbd09203462d8dc96a26eb71ef168b4197b133a970d483a607ea", size = 71315, upload-time = "2026-06-01T12:26:07.874Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/86/ff/4374bc1ed3a6b5e3d4c9c9e40793b7ecf77998d3a41eae66501155aac8f3/lxmf-1.0.1-py3-none-any.whl", hash = "sha256:96fbf9a02dcd311fd31129724c08faa5f42a0e4e1bab63bea7552fbc983f4fb8", size = 64470, upload-time = "2026-06-01T12:26:06.044Z" },
]
[[package]]
name = "lxmfy"
version = "2.0.1"
source = { editable = "." }
dependencies = [
{ name = "cbor2" },
{ name = "lxmf" },
{ name = "rns" },
]
[package.metadata]
requires-dist = [
{ name = "cbor2", specifier = ">=5.4.0" },
{ name = "lxmf", specifier = ">=1.0.1" },
{ name = "rns", specifier = ">=1.3.9" },
]
[[package]]
name = "pycparser"
version = "3.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" },
]
[[package]]
name = "pyserial"
version = "3.5"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/1e/7d/ae3f0a63f41e4d2f6cb66a5b57197850f919f59e558159a4dd3a818f5082/pyserial-3.5.tar.gz", hash = "sha256:3c77e014170dfffbd816e6ffc205e9842efb10be9f58ec16d3e8675b4925cddb", size = 159125, upload-time = "2020-11-23T03:59:15.045Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/07/bc/587a445451b253b285629263eb51c2d8e9bcea4fc97826266d186f96f558/pyserial-3.5-py2.py3-none-any.whl", hash = "sha256:c4451db6ba391ca6ca299fb3ec7bae67a5c55dde170964c7a14ceefec02f2cf0", size = 90585, upload-time = "2020-11-23T03:59:13.41Z" },
]
[[package]]
name = "rns"
version = "1.4.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "cryptography" },
{ name = "pyserial" },
]
sdist = { url = "https://files.pythonhosted.org/packages/3c/45/5aaed5107e0d3f434aada725224e1c54af96555959da312c01e12228c1de/rns-1.4.0.tar.gz", hash = "sha256:fa9e76d0a78bf253eae66137e6bbdc65f470db3950a95b034ce32ca845ff0e44", size = 519727, upload-time = "2026-07-20T17:13:03.624Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/56/4e/1a50351fba424b891a61e5f0fed9f3f631d7a034ae5edcc531d79562467c/rns-1.4.0-py3-none-any.whl", hash = "sha256:a88c6ae15c289867b2d8ee8f6ba4363f4dc728283f892b0790dba226f7dddcae", size = 606736, upload-time = "2026-07-20T17:12:58.403Z" },
]