mirror of
https://github.com/Quad4-Software/MeshChatX.git
synced 2026-08-18 09:49:09 -04:00
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:
parent
a40a27c880
commit
e5756d7ab0
92 changed files with 10075 additions and 3842 deletions
28
CHANGELOG.md
28
CHANGELOG.md
|
|
@ -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
0
cogs/__init__.py
Normal file
18
cogs/basic.py
Normal file
18
cogs/basic.py
Normal 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
0
config/cogs/__init__.py
Normal 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
|
||||
|
|
|
|||
|
|
@ -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`.
|
||||
|
|
|
|||
37
docs/agents/conventions/path-jail.md
Normal file
37
docs/agents/conventions/path-jail.md
Normal 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`.
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
118
docs/agents/skills/path-jail-local-fs/SKILL.md
Normal file
118
docs/agents/skills/path-jail-local-fs/SKILL.md
Normal 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
|
||||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
BIN
meshchatx.rsm
BIN
meshchatx.rsm
Binary file not shown.
|
|
@ -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(),
|
||||
|
|
|
|||
85
meshchatx/src/backend/active_sessions.py
Normal file
85
meshchatx/src/backend/active_sessions.py
Normal 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,
|
||||
}
|
||||
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
28
meshchatx/src/frontend/js/activeSessions.js
Normal file
28
meshchatx/src/frontend/js/activeSessions.js
Normal 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 };
|
||||
}
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 friend’s 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.",
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 d’affinité d’affichage Windows pour que MeshChatX soit omis des captures d’écran, de l’enregistrement 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é"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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": "Удалено"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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": "已删除"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
44
package.json
44
package.json
|
|
@ -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
4042
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load diff
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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}",
|
||||
|
|
|
|||
|
|
@ -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"],
|
||||
|
|
|
|||
304
tests/backend/test_active_sessions.py
Normal file
304
tests/backend/test_active_sessions.py
Normal 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
|
||||
|
|
@ -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),
|
||||
)
|
||||
|
|
|
|||
66
tests/backend/test_lxmf_inbound_cancel.py
Normal file
66
tests/backend/test_lxmf_inbound_cancel.py
Normal 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"]
|
||||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 }) => {
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
76
tests/frontend/AppMultiSessionWarning.test.js
Normal file
76
tests/frontend/AppMultiSessionWarning.test.js
Normal 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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
39
tests/frontend/activeSessions.test.js
Normal file
39
tests/frontend/activeSessions.test.js
Normal 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
4
vendor/README.txt
vendored
|
|
@ -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
|
||||
|
|
|
|||
39
vendor/lxmfy/CHANGELOG.md
vendored
39
vendor/lxmfy/CHANGELOG.md
vendored
|
|
@ -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
|
||||
|
|
|
|||
42
vendor/lxmfy/CONTRIBUTING.md
vendored
42
vendor/lxmfy/CONTRIBUTING.md
vendored
|
|
@ -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.
|
||||
|
||||
33
vendor/lxmfy/README.md
vendored
33
vendor/lxmfy/README.md
vendored
|
|
@ -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:
|
||||
|
|
|
|||
4
vendor/lxmfy/TODO.md
vendored
4
vendor/lxmfy/TODO.md
vendored
|
|
@ -1,4 +0,0 @@
|
|||
- LXST Support
|
||||
- More template bots
|
||||
- Improve NLP
|
||||
- Knowledge Graph
|
||||
99
vendor/lxmfy/docs/source/api-reference.rst
vendored
99
vendor/lxmfy/docs/source/api-reference.rst
vendored
|
|
@ -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
|
||||
|
|
|
|||
90
vendor/lxmfy/docs/source/creating-bots.rst
vendored
90
vendor/lxmfy/docs/source/creating-bots.rst
vendored
|
|
@ -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`)
|
||||
|
|
|
|||
10
vendor/lxmfy/docs/source/quick-start.rst
vendored
10
vendor/lxmfy/docs/source/quick-start.rst
vendored
|
|
@ -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:**
|
||||
|
||||
|
|
|
|||
22
vendor/lxmfy/lxmfy/__init__.py
vendored
22
vendor/lxmfy/lxmfy/__init__.py
vendored
|
|
@ -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__
|
||||
|
|
|
|||
4
vendor/lxmfy/lxmfy/__version__.py
vendored
4
vendor/lxmfy/lxmfy/__version__.py
vendored
|
|
@ -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"
|
||||
|
|
|
|||
31
vendor/lxmfy/lxmfy/cli.py
vendored
31
vendor/lxmfy/lxmfy/cli.py
vendored
|
|
@ -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:
|
||||
|
|
|
|||
16
vendor/lxmfy/lxmfy/config.py
vendored
16
vendor/lxmfy/lxmfy/config.py
vendored
|
|
@ -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."""
|
||||
|
|
|
|||
339
vendor/lxmfy/lxmfy/core.py
vendored
339
vendor/lxmfy/lxmfy/core.py
vendored
|
|
@ -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
141
vendor/lxmfy/lxmfy/reticulum_config.py
vendored
Normal 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
70
vendor/lxmfy/lxmfy/rrc/__init__.py
vendored
Normal 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
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
92
vendor/lxmfy/lxmfy/rrc/constants.py
vendored
Normal 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
149
vendor/lxmfy/lxmfy/rrc/envelope.py
vendored
Normal 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
275
vendor/lxmfy/lxmfy/rrc/manager.py
vendored
Normal 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")
|
||||
3
vendor/lxmfy/lxmfy/templates/__init__.py
vendored
3
vendor/lxmfy/lxmfy/templates/__init__.py
vendored
|
|
@ -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
79
vendor/lxmfy/lxmfy/templates/rrc_bot.py
vendored
Normal 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
67
vendor/lxmfy/poetry.lock
generated
vendored
|
|
@ -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"
|
||||
|
|
|
|||
9
vendor/lxmfy/pyproject.toml
vendored
9
vendor/lxmfy/pyproject.toml
vendored
|
|
@ -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]
|
||||
|
|
|
|||
6
vendor/lxmfy/tests/pytest.ini
vendored
6
vendor/lxmfy/tests/pytest.ini
vendored
|
|
@ -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
|
||||
|
|
|
|||
2
vendor/lxmfy/tests/test_cli.py
vendored
2
vendor/lxmfy/tests/test_cli.py
vendored
|
|
@ -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()
|
||||
|
|
|
|||
4
vendor/lxmfy/tests/test_integration.py
vendored
4
vendor/lxmfy/tests/test_integration.py
vendored
|
|
@ -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
416
vendor/lxmfy/tests/test_live_lxmf.py
vendored
Normal 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
129
vendor/lxmfy/tests/test_memory_guards.py
vendored
Normal 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)
|
||||
143
vendor/lxmfy/tests/test_message_persistence.py
vendored
Normal file
143
vendor/lxmfy/tests/test_message_persistence.py
vendored
Normal 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"
|
||||
250
vendor/lxmfy/tests/test_reticulum_config.py
vendored
Normal file
250
vendor/lxmfy/tests/test_reticulum_config.py
vendored
Normal 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
587
vendor/lxmfy/tests/test_rrc.py
vendored
Normal 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
238
vendor/lxmfy/tests/test_rrc_live.py
vendored
Normal 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
265
vendor/lxmfy/uv.lock
generated
vendored
Normal 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" },
|
||||
]
|
||||
Loading…
Add table
Add a link
Reference in a new issue