From ec74a2651e897325b14269ae52d11b1e12f0b6e2 Mon Sep 17 00:00:00 2001 From: Ivan Date: Sun, 19 Jul 2026 14:02:21 -0500 Subject: [PATCH] feat: improve audio handling in Docker and headless environments with hostless LXST audio backends and web audio bridge integration --- Dockerfile | 4 + Dockerfile.hardened | 3 + Taskfile.yml | 2 + docs/agents/conventions/tests.md | 18 + docs/en/audio-calls.md | 2 + docs/en/tools.md | 8 +- meshchatx.rsm | Bin 157970 -> 158741 bytes meshchatx/android_codec2.py | 13 + meshchatx/meshchat.py | 121 ++++- meshchatx/src/backend/database/__init__.py | 14 +- meshchatx/src/backend/docs_manager.py | 15 +- meshchatx/src/backend/identity_context.py | 450 ++++++++++++------ meshchatx/src/backend/integrity_manager.py | 34 +- meshchatx/src/backend/rncp_handler.py | 12 +- meshchatx/src/backend/rns_filesync_handler.py | 128 +++-- meshchatx/src/backend/telephone_manager.py | 10 + meshchatx/src/backend/web_audio_bridge.py | 179 ++++++- meshchatx/src/frontend/components/App.vue | 48 +- .../frontend/components/LanguageSelector.vue | 33 +- .../src/frontend/components/call/CallPage.vue | 39 +- .../components/filesync/RnsFilesyncPage.vue | 29 +- .../components/layout/AppShellBanners.vue | 16 + meshchatx/src/frontend/js/GlobalState.js | 2 + meshchatx/src/frontend/js/localeLoader.js | 84 ++++ .../src/frontend/js/networkStartupWait.js | 68 ++- meshchatx/src/frontend/locales/de.json | 1 + meshchatx/src/frontend/locales/en.json | 1 + meshchatx/src/frontend/locales/es.json | 1 + meshchatx/src/frontend/locales/fi.json | 1 + meshchatx/src/frontend/locales/fr.json | 1 + meshchatx/src/frontend/locales/it.json | 1 + meshchatx/src/frontend/locales/nl.json | 1 + meshchatx/src/frontend/locales/ru.json | 1 + meshchatx/src/frontend/locales/zh.json | 1 + meshchatx/src/frontend/main.js | 58 ++- .../public/meshchatx-docs/en/audio-calls.md | 4 + scripts/patch_lxst_codec2_optional.py | 155 ++++++ tests/backend/test_auto_resend_regression.py | 19 +- .../backend/test_deferred_network_startup.py | 2 + .../test_discovered_interfaces_security.py | 41 +- tests/backend/test_emergency_mode.py | 13 +- tests/backend/test_gif_utils.py | 43 +- tests/backend/test_hex_identifier_utils.py | 28 +- tests/backend/test_integrity_critical_only.py | 73 +++ .../test_lxst_telephony_adversarial.py | 333 +++++++++++++ tests/backend/test_phased_startup_guards.py | 413 ++++++++++++++++ tests/backend/test_rncp_handler_extended.py | 132 +++++ tests/backend/test_rns_filesync_handler.py | 5 +- tests/backend/test_rns_filesync_security.py | 262 +++++----- tests/backend/test_rrc_protocol_fuzz.py | 33 +- tests/backend/test_security_fuzzing.py | 58 +-- tests/backend/test_sticker_utils.py | 90 +++- tests/backend/test_web_audio_bridge.py | 18 +- tests/frontend/CallPage.test.js | 17 + tests/frontend/RnsFilesyncPage.test.js | 2 +- tests/frontend/behaviorContracts.test.js | 52 ++ tests/frontend/localeLoader.test.js | 65 +++ .../networkStartupWait.adversarial.test.js | 289 +++++++++++ tests/frontend/networkStartupWait.test.js | 53 ++- 59 files changed, 3069 insertions(+), 530 deletions(-) create mode 100644 meshchatx/src/frontend/js/localeLoader.js create mode 100644 scripts/patch_lxst_codec2_optional.py create mode 100644 tests/backend/test_integrity_critical_only.py create mode 100644 tests/backend/test_lxst_telephony_adversarial.py create mode 100644 tests/backend/test_phased_startup_guards.py create mode 100644 tests/frontend/localeLoader.test.js create mode 100644 tests/frontend/networkStartupWait.adversarial.test.js diff --git a/Dockerfile b/Dockerfile index 25c303d0..b0622c69 100644 --- a/Dockerfile +++ b/Dockerfile @@ -69,10 +69,12 @@ RUN uv sync --no-group dev --no-install-project && \ COPY meshchatx ./meshchatx COPY scripts/docker-bake-lxst-filterlib-musl.py ./scripts/docker-bake-lxst-filterlib-musl.py COPY scripts/patch_lxst_pyogg_ogg_ctypes.py ./scripts/patch_lxst_pyogg_ogg_ctypes.py +COPY scripts/patch_lxst_codec2_optional.py ./scripts/patch_lxst_codec2_optional.py COPY --from=build-frontend /src/meshchatx/public ./meshchatx/public RUN pip install --no-cache-dir . && \ python scripts/patch_lxst_pyogg_ogg_ctypes.py && \ + python scripts/patch_lxst_codec2_optional.py && \ python scripts/docker-bake-lxst-filterlib-musl.py && \ rm -rf /opt/venv/lib/python*/site-packages/LXST/Platforms/android && \ find /opt/venv -type d -name "tests" -exec rm -rf {} + && \ @@ -109,6 +111,8 @@ LABEL org.opencontainers.image.created="${OCI_CREATED}" ENV PATH="/opt/venv/bin:$PATH" ENV PYTHONUNBUFFERED=1 ENV PYTHONDONTWRITEBYTECODE=1 +# No PulseAudio in the image: LXST LineSource/LineSink cannot open host devices. +ENV MESHCHAT_FORCE_WEB_AUDIO=1 USER meshchat diff --git a/Dockerfile.hardened b/Dockerfile.hardened index 1d8c706f..0b8855ce 100644 --- a/Dockerfile.hardened +++ b/Dockerfile.hardened @@ -60,10 +60,12 @@ RUN uv sync --no-group dev --no-install-project && \ COPY meshchatx ./meshchatx COPY scripts/patch_lxst_pyogg_ogg_ctypes.py ./scripts/patch_lxst_pyogg_ogg_ctypes.py +COPY scripts/patch_lxst_codec2_optional.py ./scripts/patch_lxst_codec2_optional.py COPY --from=build-frontend /src/meshchatx/public ./meshchatx/public RUN pip install --no-cache-dir . && \ python scripts/patch_lxst_pyogg_ogg_ctypes.py && \ + python scripts/patch_lxst_codec2_optional.py && \ rm -rf /opt/venv/lib/python*/site-packages/LXST/Platforms/android && \ find /opt/venv -type d -name "tests" -exec rm -rf {} + && \ find /opt/venv -type d -name "test" -exec rm -rf {} + && \ @@ -99,6 +101,7 @@ LABEL org.opencontainers.image.created="${OCI_CREATED}" ENV PATH="/opt/venv/bin:$PATH" ENV PYTHONUNBUFFERED=1 ENV PYTHONDONTWRITEBYTECODE=1 +ENV MESHCHAT_FORCE_WEB_AUDIO=1 USER meshchat diff --git a/Taskfile.yml b/Taskfile.yml index 399f3589..4fa27ead 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -95,6 +95,7 @@ tasks: cmds: - 'uv sync --group dev{{if ne .UV_SYNC_FLAGS ""}} {{.UV_SYNC_FLAGS}}{{end}}' - uv run python scripts/patch_lxst_pyogg_ogg_ctypes.py + - uv run python scripts/patch_lxst_codec2_optional.py deps:backend:rns: aliases: [deps:be:rns, pip-rns] @@ -162,6 +163,7 @@ tasks: cmds: - 'uv sync --group dev{{if ne .UV_SYNC_FLAGS ""}} {{.UV_SYNC_FLAGS}}{{end}}' - uv run python scripts/patch_lxst_pyogg_ogg_ctypes.py + - uv run python scripts/patch_lxst_codec2_optional.py - uv run pip install ruff # --- Execution --- diff --git a/docs/agents/conventions/tests.md b/docs/agents/conventions/tests.md index c2c00f8e..61e583a5 100644 --- a/docs/agents/conventions/tests.md +++ b/docs/agents/conventions/tests.md @@ -9,6 +9,24 @@ Applies when editing `tests/**/*.{py,js}`. - Landlock tests that apply the sandbox must run in a subprocess (one restrict per process). - Long-running / notification soak suites can hang. Prefer timeouts and avoid piping pytest through `tail` in agent shells. +## Oracle style (no soft fuzz) + +Property and fuzz tests must assert an accept or reject outcome, not only that nothing crashed. + +Refuse these patterns: + +- Bare `except Exception: pass` around the code under test +- `never_raises` tests with no postcondition +- Asserting only that a result dict has an `"ok"` key without checking True or False +- Mocks that return the success path for every input under a security oracle + +Prefer: + +- Independent oracle: given input X, predict accept or reject, then assert the code matches +- Jail oracles: on success, resolved path stays under the allowed root +- Closed reason sets: on `ValueError`, the message is one of the known machine reasons +- Round-trip or shape invariants when the API is pure parsing + ## Extended Edge Case Tester (EECT) and Live Validation (LV) - EECT packs live under `tests/backend/eect/packs/` and use marker `eect`. diff --git a/docs/en/audio-calls.md b/docs/en/audio-calls.md index 1c935145..1f0892a9 100644 --- a/docs/en/audio-calls.md +++ b/docs/en/audio-calls.md @@ -81,6 +81,8 @@ Callee UI: ring, answer, or decline - Use headphones on mobile and Quest builds to prevent echo. - Review microphone permissions in Electron or the Android system settings if the UI shows no input level. - Keep LXST and Reticulum versions aligned with MeshChatX release notes when upgrading. +- **Docker / headless web**: containers have no PulseAudio host devices. MeshChatX forces the web audio bridge (`MESHCHAT_FORCE_WEB_AUDIO=1`) and installs hostless LXST backends so calls can use the browser mic/speaker. Enable telephone in settings, then place a call from the web UI over HTTPS. +- **Android Codec2**: native `libcodec2.so` must be preloaded before `pycodec2`. If Codec2 profiles are hidden, check `/api/v1/telephone/codec2/status` and rebuild with vendor wheels that bundle `pycodec2/libcodec2.so`. ## See also diff --git a/docs/en/tools.md b/docs/en/tools.md index 8026202c..3f2a62b0 100644 --- a/docs/en/tools.md +++ b/docs/en/tools.md @@ -17,11 +17,11 @@ Use these when messages or pages fail despite interfaces showing as enabled. ## File transfer and shell -| Tool | Purpose | -| ------------ | ---------------------------------------------------- | -| RNCP | Send or fetch files over Reticulum | +| Tool | Purpose | +| ------------ | -------------------------------------------------------- | +| RNCP | Send or fetch files over Reticulum | | RNS FileSync | Sync a directory with peers over `rns_filesync.filesync` | -| RNSH | Remote shell sessions with streamed output | +| RNSH | Remote shell sessions with streamed output | RNCP progress events arrive on the WebSocket as `rncp.transfer.progress`. FileSync progress and peer events use `filesync.sync.progress`, `filesync.peer.connected`, `filesync.peer.disconnected`, `filesync.file.updated`, `filesync.file.deleted`, and `filesync.error`. diff --git a/meshchatx.rsm b/meshchatx.rsm index 5fb7207d9269ed414910fc8925c2cb6787d628bd..53531b3e2635401881ca76be2eb3c08fc09c1eeb 100644 GIT binary patch delta 4606 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 delta 3903 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 A{{R30 diff --git a/meshchatx/android_codec2.py b/meshchatx/android_codec2.py index 25dcc15b..0d4baca2 100644 --- a/meshchatx/android_codec2.py +++ b/meshchatx/android_codec2.py @@ -6,6 +6,7 @@ from __future__ import annotations import ctypes import logging +import os import sys from pathlib import Path @@ -51,12 +52,24 @@ def _libcodec2_candidates() -> list[Path]: seen.add(key) candidates.append(path) + explicit = Path(os.environ.get("MESHCHAT_LIBCODEC2_PATH", "") or "") + if explicit.is_file(): + add(explicit) + for entry in sys.path: if not entry: continue root = Path(entry) add(root / "pycodec2" / "libcodec2.so") add(root / "chaquopy" / "lib" / "libcodec2.so") + # Some Chaquopy layouts nest native libs under site-packages directly. + add(root / "libcodec2.so") + + # Extracted APK native lib dirs (ABI-specific jniLibs sync target). + for env_key in ("MESHCHAT_NATIVE_LIB_DIR", "ANDROID_NATIVE_LIBRARY_DIR"): + native_dir = Path(os.environ.get(env_key, "") or "") + if native_dir.is_dir(): + add(native_dir / "libcodec2.so") return candidates diff --git a/meshchatx/meshchat.py b/meshchatx/meshchat.py index 2712b816..0b324591 100644 --- a/meshchatx/meshchat.py +++ b/meshchatx/meshchat.py @@ -453,11 +453,13 @@ class ReticulumMeshChat: memory_diag_enabled: bool = False, plugins_enabled: bool = True, defer_network_setup: bool = False, + headless: bool = False, ): self.running = True self.plugins_enabled = plugins_enabled self._memory_diag_enabled = memory_diag_enabled self._mem_diag = None + self._headless = bool(headless) self.migration_context = ( migration_context if migration_context is not None else {} ) @@ -506,8 +508,10 @@ class ReticulumMeshChat: self._startup_error: str | None = None self._network_ready = not defer_network_setup self._network_degraded = False - self._ui_ready = not defer_network_setup + # HTTP can serve the shell immediately while RNS/identity finish. + self._ui_ready = True self._rns_recovery_actions: list[str] = [] + self._reticulum_secondary_started = False # track announce timestamps for rate calculation self.announce_timestamps = [] @@ -541,7 +545,11 @@ class ReticulumMeshChat: self._auto_resend_coordinator = AutoResendCoordinator() AsyncUtils.ensure_background_loop() - self.web_audio_bridge = WebAudioBridge(None, None) + self.web_audio_bridge = WebAudioBridge( + None, + None, + force_enabled=self.web_audio_required(), + ) self.rns_link_manager = RnsLinkManager( self_identity_getter=lambda: self.identity, reticulum_getter=lambda: getattr(self, "reticulum", None), @@ -568,6 +576,41 @@ class ReticulumMeshChat: else: self.setup_identity(identity) self._mark_network_ready() + self._finish_deferred_startup_services() + + def web_audio_required(self) -> bool: + """True when LXST host audio is unusable and the browser bridge is mandatory. + + Chaquopy Android has no usable LXST LineSource path. Docker/Alpine images + typically lack PulseAudio. Do not key this off --headless alone: frozen + Electron also uses headless (no auto browser) while still having host audio. + + MESHCHAT_FORCE_WEB_AUDIO=1 forces the bridge for debugging or custom hosts. + """ + if _is_chaquopy_android(): + return True + force = os.environ.get("MESHCHAT_FORCE_WEB_AUDIO", "").strip().lower() + if force in ("1", "true", "yes", "on"): + return True + cached = getattr(self, "_host_audio_unavailable_cached", None) + if cached is not None: + return cached + unavailable = self._probe_host_audio_unavailable() + self._host_audio_unavailable_cached = unavailable + return unavailable + + @staticmethod + def _probe_host_audio_unavailable() -> bool: + """Return True when LXST cannot open a host capture device.""" + try: + from LXST.Sources import Backend + + if Backend is None: + return True + Backend() + return False + except Exception: + return True # Proxy properties for backward compatibility @property @@ -1600,6 +1643,7 @@ class ReticulumMeshChat: except Exception as exc: print(f"Failed to persist session secret into config: {exc}") self._mark_network_ready() + self._finish_deferred_startup_services() print("Network stack ready", flush=True) if self.websocket_clients: try: @@ -1649,7 +1693,10 @@ class ReticulumMeshChat: self.web_audio_bridge = WebAudioBridge( self.current_context.telephone_manager, self.current_context.config, + force_enabled=self.web_audio_required(), ) + if self._network_ready: + self._finish_deferred_startup_services() return # Initialize Reticulum if not already done @@ -1663,18 +1710,7 @@ class ReticulumMeshChat: ) _restore_rns_console_logging_after_reticulum_init(self) self.page_node_manager.load_nodes() - self.page_node_manager.start_all() self.plugin_manager.set_app(self) - if self.plugins_enabled: - try: - self.plugin_manager.install_bundled_examples() - except Exception as exc: - print(f"Bundled plugin sync failed: {exc}", flush=True) - try: - self.sideband_plugin_loader.reload() - self._ensure_sideband_telemetry_loop() - except Exception as exc: - print(f"Sideband plugin loader init failed: {exc}") # Create new context self._set_startup_stage("identity") @@ -1685,6 +1721,7 @@ class ReticulumMeshChat: self.web_audio_bridge = WebAudioBridge( context.telephone_manager, context.config, + force_enabled=self.web_audio_required(), ) for node in self.page_node_manager.nodes.values(): @@ -1707,6 +1744,43 @@ class ReticulumMeshChat: ) self._health_monitor.start() + if self._network_ready: + self._finish_deferred_startup_services() + + def _finish_deferred_startup_services(self) -> None: + """Start non-critical services after network_ready is published.""" + context = self.current_context + if context is not None: + try: + context.setup_deferred_services() + except Exception as exc: + print(f"Deferred identity services failed: {exc}", flush=True) + self._start_deferred_reticulum_services() + + def _start_deferred_reticulum_services(self) -> None: + if self._reticulum_secondary_started: + return + if not hasattr(self, "reticulum"): + return + self._reticulum_secondary_started = True + try: + self.page_node_manager.start_all() + for node in self.page_node_manager.nodes.values(): + if node.running and node.destination: + self._register_local_page_node_announce(node) + except Exception as exc: + print(f"Deferred page node start failed: {exc}", flush=True) + if self.plugins_enabled: + try: + self.plugin_manager.install_bundled_examples() + except Exception as exc: + print(f"Bundled plugin sync failed: {exc}", flush=True) + try: + self.sideband_plugin_loader.reload() + self._ensure_sideband_telemetry_loop() + except Exception as exc: + print(f"Sideband plugin loader init failed: {exc}") + def _checkpoint_and_close(self): # delegated to database instance self.database._checkpoint_and_close() @@ -2563,6 +2637,7 @@ class ReticulumMeshChat: if switched_instance_name: self._write_reticulum_instance_name(instance_restore_name) self._mark_network_ready() + self._finish_deferred_startup_services() await self._send_rns_reload_status( "done", "RNS reload complete.", @@ -2587,6 +2662,7 @@ class ReticulumMeshChat: try: self.setup_identity(identity_to_restore) self._mark_network_ready() + self._finish_deferred_startup_services() return False except Exception as recover_exc: self._mark_network_degraded( @@ -5531,6 +5607,7 @@ class ReticulumMeshChat: self._network_degraded = False self._ui_ready = True self._network_ready = False + self._reticulum_secondary_started = False if hasattr(self, "reticulum"): with contextlib.suppress(Exception): delattr(self, "reticulum") @@ -5538,6 +5615,7 @@ class ReticulumMeshChat: try: self.setup_identity(identity) self._mark_network_ready() + self._finish_deferred_startup_services() return web.json_response( { "message": "Network stack recovered", @@ -7784,9 +7862,10 @@ class ReticulumMeshChat: ) await websocket_response.prepare(request) - # Chaquopy Android has no LXST host audio device, so always allow the websocket bridge. + # Chaquopy Android and headless/web deployments have no usable LXST + # host audio device, so always allow the websocket bridge. web_audio_allowed = ( - self.web_audio_bridge.config_enabled() or _is_chaquopy_android() + self.web_audio_bridge.config_enabled() or self.web_audio_required() ) if not web_audio_allowed: await websocket_response.send_str( @@ -10680,16 +10759,18 @@ class ReticulumMeshChat: "get", lambda: False, )() - or _is_chaquopy_android() + or self.web_audio_required() ) and not bool( getattr(self.voicemail_manager, "is_recording", False), ), + "required": self.web_audio_required(), "allow_fallback": getattr( self.config.telephone_web_audio_allow_fallback, "get", lambda: True, - )(), + )() + and not self.web_audio_required(), "has_client": bool( getattr(self.web_audio_bridge, "clients", []), ), @@ -23514,9 +23595,7 @@ class ReticulumMeshChat: continue fields = parse_fields_dict(failed_message.get("fields")) - allow_attachments = ( - ctx.config.allow_auto_resending_failed_messages_with_attachments.get() - ) + allow_attachments = ctx.config.allow_auto_resending_failed_messages_with_attachments.get() if not allow_attachments and fields_have_attachments(fields): print( "Not resending failed message with attachments, as setting is disabled", @@ -24252,6 +24331,7 @@ def main(): memory_diag_enabled=args.memory_diag, plugins_enabled=not args.disable_plugins, defer_network_setup=not needs_immediate_network, + headless=bool(args.headless), ) # store recovery on app for wiring with identity context @@ -24332,6 +24412,7 @@ def main(): ) reticulum_meshchat.setup_identity(identity) reticulum_meshchat._mark_network_ready() + reticulum_meshchat._finish_deferred_startup_services() else: print(f"Error: Snapshot not found at {snapshot_path}") diff --git a/meshchatx/src/backend/database/__init__.py b/meshchatx/src/backend/database/__init__.py index 4cc51ecc..e87c9269 100644 --- a/meshchatx/src/backend/database/__init__.py +++ b/meshchatx/src/backend/database/__init__.py @@ -257,16 +257,22 @@ class Database: return True return False - def check_db_health_at_open(self, storage_path): + def check_db_health_at_open(self, storage_path, *, quick: bool = False): """Run integrity and baseline checks after opening the database. Returns human-readable issue strings. Empty if healthy. + When quick is True, use PRAGMA quick_check instead of full integrity_check. """ issues = [] try: - integrity_rows = self.provider.integrity_check() + integrity_rows = ( + self.provider.quick_check() + if quick + else self.provider.integrity_check() + ) + check_label = "quick check" if quick else "integrity check" if not integrity_rows: - issues.append("Database integrity check failed: no result") + issues.append(f"Database {check_label} failed: no result") _log.warning("DB open health check: no result") else: first = integrity_rows[0] @@ -274,7 +280,7 @@ class Database: next(iter(first.values())) if isinstance(first, dict) else first[0] ) if val != "ok": - issues.append(f"Database integrity check failed: {val!s}") + issues.append(f"Database {check_label} failed: {val!s}") _log.warning("DB open health check: %s", val) except Exception as e: msg = f"Database integrity check error: {e!s}" diff --git a/meshchatx/src/backend/docs_manager.py b/meshchatx/src/backend/docs_manager.py index 52074322..c7f102b6 100644 --- a/meshchatx/src/backend/docs_manager.py +++ b/meshchatx/src/backend/docs_manager.py @@ -27,7 +27,15 @@ class DocsManager: build time with scripts/build/fetch_reticulum_manual.py (pnpm run build-docs). """ - def __init__(self, config, public_dir, project_root=None, storage_dir=None): + def __init__( + self, + config, + public_dir, + project_root=None, + storage_dir=None, + *, + populate: bool = True, + ): self.config = config self.public_dir = public_dir self.project_root = project_root @@ -65,6 +73,11 @@ class DocsManager: logging.exception(f"Failed to create documentation directories: {e}") self.last_error = str(e) + if populate: + self.ensure_meshchatx_docs_populated() + + def ensure_meshchatx_docs_populated(self): + """Copy/render MeshChatX docs into storage when writable.""" if os.path.exists(self.meshchatx_docs_dir) and os.access( self.meshchatx_docs_dir, os.W_OK, diff --git a/meshchatx/src/backend/identity_context.py b/meshchatx/src/backend/identity_context.py index c560838f..c2b1a435 100644 --- a/meshchatx/src/backend/identity_context.py +++ b/meshchatx/src/backend/identity_context.py @@ -116,6 +116,11 @@ class IdentityContext: ) self.running = False + self._deferred_setup_done = False + self._deferred_setup_lock = threading.Lock() + self._deferred_setup_in_progress = False + self._deferred_setup_finished = threading.Event() + self._deferred_setup_finished.set() def _rrc_name_for_identity_hash(self, identity_hash): try: @@ -146,11 +151,20 @@ class IdentityContext: pass def setup(self): + """Initialize core messaging identity state. + + Secondary tools (RN*, bots, RRC connect, docs populate, map overlays) + are started by setup_deferred_services() after network_ready so the UI + and LXMF path become available sooner. + """ print(f"Setting up Identity Context for {self.identity_hash}...") # 0. Clear any previous integrity and database health issues on the app self.app.integrity_issues = [] self.app.database_health_issues = [] + self._deferred_setup_done = False + self._deferred_setup_in_progress = False + self._deferred_setup_finished.set() # 1. Cleanup RNS state for this identity if any lingers self.app.cleanup_rns_state_for_identity(self.identity.hash) @@ -162,9 +176,9 @@ class IdentityContext: else: self.database = Database(self.database_path) - # Check Integrity (skip in emergency mode) + # Critical integrity only at boot (full walk deferred) if not getattr(self.app, "emergency", False): - is_ok, issues = self.integrity_manager.check_integrity() + is_ok, issues = self.integrity_manager.check_integrity(critical_only=True) if not is_ok: print( f"INTEGRITY WARNING for {self.identity_hash}: {', '.join(issues)}", @@ -192,10 +206,9 @@ class IdentityContext: self.database.initialize() self.database._tune_sqlite_pragmas() - # 3. Initialize Config and Managers + # 3. Initialize Config and core managers self.config = ConfigManager(self.database) - # Apply overrides from CLI/ENV if provided if ( hasattr(self.app, "gitea_base_url_override") and self.app.gitea_base_url_override @@ -206,18 +219,7 @@ class IdentityContext: self.announce_manager = AnnounceManager(self.database, self.config) self.archiver_manager = ArchiverManager(self.database) self.map_manager = MapManager(self.config, self.app.storage_dir) - self.map_overlay_manager = MapOverlayManager( - self.config, - self.database, - self.storage_path, - reticulum_config_dir=getattr(self.app, "reticulum_config_dir", None), - identity=self.identity, - reticulum=getattr(self.app, "reticulum", None), - ) - try: - self.map_overlay_manager.start_scheduler() - except Exception: - pass + self.map_overlay_manager = None self.docs_manager = DocsManager( self.config, self.app.get_public_path(), @@ -227,6 +229,7 @@ class IdentityContext: ), ), storage_dir=self.storage_path, + populate=False, ) self.repository_server_manager = RepositoryServerManager( self.storage_path, @@ -241,7 +244,10 @@ class IdentityContext: self.database.messages.mark_stuck_messages_as_failed() if not getattr(self.app, "emergency", False): - db_issues = self.database.check_db_health_at_open(self.storage_path) + db_issues = self.database.check_db_health_at_open( + self.storage_path, + quick=True, + ) if db_issues: self.app.database_health_issues = db_issues print( @@ -266,9 +272,7 @@ class IdentityContext: self.config.lxmf_propagation_sync_limit_in_bytes.get() / 1000 ) - # Register LXMF delivery identity inbound_stamp_cost = self.config.lxmf_inbound_stamp_cost.get() - # Enforce max stamp cost when block strangers is enabled on startup if ( self.config.block_all_from_strangers.get() and isinstance(inbound_stamp_cost, int) @@ -282,7 +286,6 @@ class IdentityContext: stamp_cost=inbound_stamp_cost, ) - # Forwarding Manager self.forwarding_manager = ForwardingManager( self.database, self.lxmf_router_path, @@ -291,12 +294,10 @@ class IdentityContext: ) self.forwarding_manager.load_aliases() - # Register delivery callback self.message_router.register_delivery_callback( lambda msg: self.app.on_lxmf_delivery(msg, context=self), ) - # Restore preferred propagation node on startup with contextlib.suppress(Exception): preferred_node = ( self.config.lxmf_preferred_propagation_node_destination_hash.get() @@ -304,90 +305,12 @@ class IdentityContext: if preferred_node: self.app.set_active_propagation_node(preferred_node, context=self) - # Enable local propagation node on startup if configured with contextlib.suppress(Exception): if self.config.lxmf_local_propagation_node_enabled.get(): self.app.enable_local_propagation_node(True, context=self) - # 5. Initialize Handlers and Managers - self.rncp_handler = RNCPHandler( - reticulum_instance=getattr(self.app, "reticulum", None), - identity=self.identity, - storage_dir=self.app.storage_dir, - ) - self.rncp_handler.on_receive_completed = self._rncp_emit_receive_completed - self.rns_filesync_handler = RnsFilesyncHandler( - reticulum_instance=getattr(self.app, "reticulum", None), - identity=self.identity, - storage_dir=self.storage_path, - emit_callback=self._filesync_emit, - ) - self.rnsh_manager = RNSHManager( - storage_dir=self.storage_path, - reticulum_config_dir=getattr(self.app, "reticulum_config_dir", None), - ) - self.rnsh_manager.set_change_callback( - lambda session: self.app.on_rnsh_change(session, context=self), - ) - self.rnsh_manager.set_output_callback( - lambda session, chunk: self.app.on_rnsh_output( - session, - chunk, - context=self, - ), - ) - try: - self.rnsh_manager.load() - except Exception as exc: - print(f"Failed to load RNSH sessions for {self.identity_hash}: {exc}") - self.rnx_manager = RNXManager( - storage_dir=self.storage_path, - reticulum_config_dir=getattr(self.app, "reticulum_config_dir", None), - ) - self.rnx_manager.set_change_callback( - lambda session: self.app.on_rnx_change(session, context=self), - ) - self.rnx_manager.set_output_callback( - lambda session, chunk: self.app.on_rnx_output(session, chunk, context=self), - ) - try: - self.rnx_manager.load() - except Exception as exc: - print(f"Failed to load RNX sessions for {self.identity_hash}: {exc}") - self.rnstatus_handler = RNStatusHandler( - reticulum_instance=getattr(self.app, "reticulum", None), - ) - self.rnpath_handler = RNPathHandler( - reticulum_instance=getattr(self.app, "reticulum", None), - ) - self.rnpath_trace_handler = RNPathTraceHandler( - reticulum_instance=getattr(self.app, "reticulum", None), - identity=self.identity, - ) - self.rnprobe_handler = RNProbeHandler( - reticulum_instance=getattr(self.app, "reticulum", None), - identity=self.identity, - ) + # Telephone is part of core UX (calls overlay). - libretranslate_url = self.config.libretranslate_url.get() - libretranslate_api_key = self.config.libretranslate_api_key.get() - self.translator_handler = TranslatorHandler( - libretranslate_url=libretranslate_url, - libretranslate_api_key=libretranslate_api_key, - translator_argos_enabled=self.config.translator_argos_enabled.get(), - translator_libretranslate_enabled=self.config.translator_libretranslate_enabled.get(), - ) - - self.bot_handler = BotHandler( - identity_path=self.storage_path, - config_manager=self.config, - ) - try: - self.bot_handler.restore_enabled_bots() - except Exception as exc: - print(f"Failed to restore bots: {exc}") - - # Initialize managers identity = self.identity if identity is None: msg = "identity is required for manager setup" @@ -398,6 +321,9 @@ class IdentityContext: storage_dir=self.storage_path, db=self.database, ) + self.telephone_manager.web_audio_required = bool( + getattr(self.app, "web_audio_required", lambda: False)(), + ) self.telephone_manager.get_name_for_identity_hash = ( self.app.get_name_for_identity_hash ) @@ -418,7 +344,6 @@ class IdentityContext: lambda call: self.app.on_telephone_call_ended(call, context=self), ) - # Only initialize telephone hardware/profile if not in emergency mode if not getattr(self.app, "emergency", False): self.telephone_manager.init_telephone() with contextlib.suppress(Exception): @@ -462,65 +387,275 @@ class IdentityContext: context=self, ) - # Reticulum Relay Chat (optional) - rrc_enabled = self.config.rrc_enabled.get() if self.config else True - if rrc_enabled: - self.rrc_manager = RRCManager( - identity=self.identity, - storage_dir=self.storage_path, - get_nickname=lambda: ( - self.config.display_name.get() if self.config else None - ), - get_name_for_identity_hash=self._rrc_name_for_identity_hash, - ) - self.rrc_manager.set_change_callback( - lambda hub: self.app.on_rrc_change(hub, context=self), - ) - self.rrc_manager.set_message_callback( - lambda hub, msg: self.app.on_rrc_message(hub, msg, context=self), - ) - try: - self.rrc_manager.load() - except Exception as exc: - print(f"Failed to load RRC hubs for {self.identity_hash}: {exc}") + # Tool handlers stay None until deferred setup finishes. + self.rncp_handler = None + self.rns_filesync_handler = None + self.rnsh_manager = None + self.rnx_manager = None + self.rnstatus_handler = None + self.rnpath_handler = None + self.rnpath_trace_handler = None + self.rnprobe_handler = None + self.translator_handler = None + self.bot_handler = None + self.rrc_manager = None + self.rrc_server_manager = None - self.rrc_server_manager = RRCServerManager( - storage_dir=self.storage_path, - owner_identity=self.identity.hash, - ) - self.rrc_server_manager.set_change_callback( - lambda hub: self.app.on_rrc_server_change(hub, context=self), - ) - self.rrc_manager.set_server_manager(self.rrc_server_manager) - try: - self.rrc_server_manager.load() - except Exception as exc: - print( - f"Failed to load RRC hub servers for {self.identity_hash}: {exc}", - ) - - try: - self.rrc_manager.connect_auto_reconnect_hubs() - except Exception as exc: - print( - f"Failed to auto-connect RRC hubs for {self.identity_hash}: {exc}", - ) - else: - self.rrc_manager = None - self.rrc_server_manager = None - - # 6. Register Announce Handlers self.register_announce_handlers() - # 7. Start background threads self.running = True self.start_background_threads() - # Baseline integrity manifest after successful setup - if not getattr(self.app, "emergency", False): - self.integrity_manager.save_manifest() + print(f"Identity Context for {self.identity_hash} core is now running.") - print(f"Identity Context for {self.identity_hash} is now running.") + def setup_deferred_services(self): + """Finish non-critical managers after network_ready. + + Idempotent and teardown-safe: concurrent callers share one run, and + teardown waits for an in-flight run so handlers are not resurrected + after the context is stopped. + """ + if not self.running: + return + with self._deferred_setup_lock: + if self._deferred_setup_done or self._deferred_setup_in_progress: + return + if not self.running: + return + self._deferred_setup_in_progress = True + self._deferred_setup_finished.clear() + + try: + if not self.running: + return + print(f"Deferred setup for Identity Context {self.identity_hash}...") + self._run_deferred_services_body() + if self.running: + with self._deferred_setup_lock: + self._deferred_setup_done = True + print( + f"Identity Context for {self.identity_hash} deferred setup complete.", + ) + else: + print( + f"Deferred setup aborted for torn-down identity {self.identity_hash}", + ) + finally: + with self._deferred_setup_lock: + self._deferred_setup_in_progress = False + self._deferred_setup_finished.set() + + def _deferred_still_active(self) -> bool: + return bool(self.running) + + def _run_deferred_services_body(self): + if not self._deferred_still_active(): + return + + try: + if not self._deferred_still_active(): + return + self.map_overlay_manager = MapOverlayManager( + self.config, + self.database, + self.storage_path, + reticulum_config_dir=getattr(self.app, "reticulum_config_dir", None), + identity=self.identity, + reticulum=getattr(self.app, "reticulum", None), + ) + try: + self.map_overlay_manager.start_scheduler() + except Exception: + pass + except Exception as exc: + print(f"Failed to start map overlay manager: {exc}") + + try: + if self.docs_manager is not None and self._deferred_still_active(): + self.docs_manager.ensure_meshchatx_docs_populated() + except Exception as exc: + print(f"Failed to populate docs: {exc}") + + if not self._deferred_still_active(): + return + + try: + self.rncp_handler = RNCPHandler( + reticulum_instance=getattr(self.app, "reticulum", None), + identity=self.identity, + storage_dir=self.app.storage_dir, + ) + self.rncp_handler.on_receive_completed = self._rncp_emit_receive_completed + if not self._deferred_still_active(): + return + self.rns_filesync_handler = RnsFilesyncHandler( + reticulum_instance=getattr(self.app, "reticulum", None), + identity=self.identity, + storage_dir=self.storage_path, + emit_callback=self._filesync_emit, + ) + self.rnsh_manager = RNSHManager( + storage_dir=self.storage_path, + reticulum_config_dir=getattr(self.app, "reticulum_config_dir", None), + ) + self.rnsh_manager.set_change_callback( + lambda session: self.app.on_rnsh_change(session, context=self), + ) + self.rnsh_manager.set_output_callback( + lambda session, chunk: self.app.on_rnsh_output( + session, + chunk, + context=self, + ), + ) + try: + self.rnsh_manager.load() + except Exception as exc: + print(f"Failed to load RNSH sessions for {self.identity_hash}: {exc}") + if not self._deferred_still_active(): + return + self.rnx_manager = RNXManager( + storage_dir=self.storage_path, + reticulum_config_dir=getattr(self.app, "reticulum_config_dir", None), + ) + self.rnx_manager.set_change_callback( + lambda session: self.app.on_rnx_change(session, context=self), + ) + self.rnx_manager.set_output_callback( + lambda session, chunk: self.app.on_rnx_output( + session, + chunk, + context=self, + ), + ) + try: + self.rnx_manager.load() + except Exception as exc: + print(f"Failed to load RNX sessions for {self.identity_hash}: {exc}") + self.rnstatus_handler = RNStatusHandler( + reticulum_instance=getattr(self.app, "reticulum", None), + ) + self.rnpath_handler = RNPathHandler( + reticulum_instance=getattr(self.app, "reticulum", None), + ) + self.rnpath_trace_handler = RNPathTraceHandler( + reticulum_instance=getattr(self.app, "reticulum", None), + identity=self.identity, + ) + self.rnprobe_handler = RNProbeHandler( + reticulum_instance=getattr(self.app, "reticulum", None), + identity=self.identity, + ) + + libretranslate_url = self.config.libretranslate_url.get() + libretranslate_api_key = self.config.libretranslate_api_key.get() + self.translator_handler = TranslatorHandler( + libretranslate_url=libretranslate_url, + libretranslate_api_key=libretranslate_api_key, + translator_argos_enabled=self.config.translator_argos_enabled.get(), + translator_libretranslate_enabled=self.config.translator_libretranslate_enabled.get(), + ) + + self.bot_handler = BotHandler( + identity_path=self.storage_path, + config_manager=self.config, + ) + try: + self.bot_handler.restore_enabled_bots() + except Exception as exc: + print(f"Failed to restore bots: {exc}") + except Exception as exc: + print(f"Failed deferred tool manager setup: {exc}") + + if not self._deferred_still_active(): + return + + try: + rrc_enabled = self.config.rrc_enabled.get() if self.config else True + if rrc_enabled: + self.rrc_manager = RRCManager( + identity=self.identity, + storage_dir=self.storage_path, + get_nickname=lambda: ( + self.config.display_name.get() if self.config else None + ), + get_name_for_identity_hash=self._rrc_name_for_identity_hash, + ) + self.rrc_manager.set_change_callback( + lambda hub: self.app.on_rrc_change(hub, context=self), + ) + self.rrc_manager.set_message_callback( + lambda hub, msg: self.app.on_rrc_message(hub, msg, context=self), + ) + try: + self.rrc_manager.load() + except Exception as exc: + print(f"Failed to load RRC hubs for {self.identity_hash}: {exc}") + + self.rrc_server_manager = RRCServerManager( + storage_dir=self.storage_path, + owner_identity=self.identity.hash, + ) + self.rrc_server_manager.set_change_callback( + lambda hub: self.app.on_rrc_server_change(hub, context=self), + ) + self.rrc_manager.set_server_manager(self.rrc_server_manager) + try: + self.rrc_server_manager.load() + except Exception as exc: + print( + f"Failed to load RRC hub servers for {self.identity_hash}: {exc}", + ) + + try: + self.rrc_manager.connect_auto_reconnect_hubs() + except Exception as exc: + print( + f"Failed to auto-connect RRC hubs for {self.identity_hash}: {exc}", + ) + else: + self.rrc_manager = None + self.rrc_server_manager = None + except Exception as exc: + print(f"Failed deferred RRC setup: {exc}") + + if not self._deferred_still_active(): + return + + if not getattr(self.app, "emergency", False): + try: + is_ok, issues = self.integrity_manager.check_integrity() + if not is_ok: + print( + f"INTEGRITY WARNING (deferred) for {self.identity_hash}: {', '.join(issues)}", + ) + if not hasattr(self.app, "integrity_issues"): + self.app.integrity_issues = [] + for issue in issues: + if issue not in self.app.integrity_issues: + self.app.integrity_issues.append(issue) + if self._deferred_still_active(): + self.integrity_manager.save_manifest() + except Exception as exc: + print(f"Failed deferred integrity pass: {exc}") + + try: + if not self._deferred_still_active(): + return + full_db_issues = self.database.check_db_health_at_open( + self.storage_path, + quick=False, + ) + if full_db_issues: + existing = list( + getattr(self.app, "database_health_issues", []) or [], + ) + for issue in full_db_issues: + if issue not in existing: + existing.append(issue) + self.app.database_health_issues = existing + except Exception as exc: + print(f"Failed deferred DB health check: {exc}") def start_background_threads(self): # start background thread for auto announce loop @@ -673,6 +808,13 @@ class IdentityContext: def teardown(self): print(f"Tearing down Identity Context for {self.identity_hash}...") self.running = False + # Let an in-flight deferred setup notice running=False and exit before + # we null managers it may still be assigning. + finished = getattr(self, "_deferred_setup_finished", None) + if finished is not None and not finished.wait(timeout=30): + print( + f"Timed out waiting for deferred setup during teardown of {self.identity_hash}", + ) if self.auto_propagation_manager: self.auto_propagation_manager.stop() self.auto_propagation_manager = None diff --git a/meshchatx/src/backend/integrity_manager.py b/meshchatx/src/backend/integrity_manager.py index 5d143917..7403aeb9 100644 --- a/meshchatx/src/backend/integrity_manager.py +++ b/meshchatx/src/backend/integrity_manager.py @@ -164,8 +164,12 @@ class IntegrityManager: except Exception as e: return False, str(e) - def check_integrity(self): - """Verify the current state against the last saved manifest using advanced analytics.""" + def check_integrity(self, critical_only: bool = False): + """Verify the current state against the last saved manifest using advanced analytics. + + When critical_only is True, only identity and database markers are checked. + The full storage walk runs later so startup is not blocked by hashing every file. + """ if not self.manifest_path.exists(): return True, ["Initial run - no manifest yet"] @@ -205,6 +209,32 @@ class IntegrityManager: f"Database structural anomaly (Entropy Δ: {abs(actual_entropy - saved_entropy):.2f})", ) + # Critical identity/config files only during fast startup path. + if critical_only: + for rel_path, expected_hash in manifest_files.items(): + if self._should_ignore(rel_path): + continue + if not any(marker in rel_path for marker in ("identity", "config")): + continue + full_path = self.storage_dir / rel_path + if not full_path.exists(): + issues.append(f"File missing: {rel_path}") + continue + actual_hash = self._hash_file(full_path) + if actual_hash != expected_hash: + issues.append( + f"Critical security component integrity compromised: {rel_path}", + ) + if issues: + m_date = manifest.get("date", "Unknown") + m_time = manifest.get("time", "Unknown") + issues.insert( + 0, + f"Last integrity snapshot: {m_date} {m_time} (Identity: {m_id})", + ) + self.issues = issues + return len(issues) == 0, issues + # Check other critical files in storage_dir for root, _, files_in_dir in os.walk(self.storage_dir): for file in files_in_dir: diff --git a/meshchatx/src/backend/rncp_handler.py b/meshchatx/src/backend/rncp_handler.py index 29d70e62..6212d1f3 100644 --- a/meshchatx/src/backend/rncp_handler.py +++ b/meshchatx/src/backend/rncp_handler.py @@ -265,11 +265,17 @@ class RNCPHandler: if not self.fetch_jail: return self.REQ_FETCH_NOT_ALLOWED + if not isinstance(data, str) or "\x00" in data: + return self.REQ_FETCH_NOT_ALLOWED + if data.startswith(self.fetch_jail + "/"): data = data.replace(self.fetch_jail + "/", "") - file_path = os.path.realpath( - os.path.join(self.fetch_jail, data.lstrip("/")), - ) + try: + file_path = os.path.realpath( + os.path.join(self.fetch_jail, data.lstrip("/")), + ) + except (OSError, ValueError): + return self.REQ_FETCH_NOT_ALLOWED jail_real = os.path.realpath(self.fetch_jail) if file_path != jail_real and not file_path.startswith(jail_real + os.sep): return self.REQ_FETCH_NOT_ALLOWED diff --git a/meshchatx/src/backend/rns_filesync_handler.py b/meshchatx/src/backend/rns_filesync_handler.py index 031e9a54..fee597cb 100644 --- a/meshchatx/src/backend/rns_filesync_handler.py +++ b/meshchatx/src/backend/rns_filesync_handler.py @@ -12,9 +12,27 @@ 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.permissions import PermissionStore from rns_filesync.service import FileSyncService +_ALL_ALIASES = frozenset({"all", "a", "everyone", "*"}) + + +def _normalize_peer_hash(value: str | None) -> str | None: + cleaned = str(value or "").strip().lower().replace(":", "") + if not cleaned: + return None + if cleaned in _ALL_ALIASES: + return "all" + if len(cleaned) != 32: + return None + try: + bytes.fromhex(cleaned) + except ValueError: + return None + return cleaned + class RnsFilesyncHandler: """Host FileSync against the shared Reticulum stack for one identity.""" @@ -28,12 +46,13 @@ class RnsFilesyncHandler: ): self.reticulum = reticulum_instance self.identity = identity - self.storage_dir = storage_dir + self.storage_dir = os.path.realpath(storage_dir) self._emit_callback = emit_callback self._lock = threading.RLock() self.service: FileSyncService | None = None + self._permissions_cache: PermissionStore | None = None - self._root = os.path.join(storage_dir, "filesync") + self._root = os.path.join(self.storage_dir, "filesync") self._settings_path = os.path.join(self._root, "settings.json") self._acl_path = os.path.join(self._root, "acl.txt") self._sync_directory = os.path.join(self._root, "sync") @@ -52,8 +71,15 @@ class RnsFilesyncHandler: except Exception: pass - def _default_sync_directory(self) -> str: - return os.path.join(self._root, "sync") + def _resolve_sync_directory(self, path: str) -> str | None: + cleaned = str(path or "").strip() + if not cleaned: + return None + resolved = os.path.realpath(os.path.expanduser(cleaned)) + root = self.storage_dir + if resolved != root and not resolved.startswith(root + os.sep): + return None + return resolved def _load_settings(self) -> None: os.makedirs(self._root, exist_ok=True) @@ -68,7 +94,9 @@ class RnsFilesyncHandler: return sync_dir = data.get("sync_directory") if isinstance(sync_dir, str) and sync_dir.strip(): - self._sync_directory = os.path.realpath(os.path.expanduser(sync_dir.strip())) + resolved = self._resolve_sync_directory(sync_dir) + if resolved is not None: + self._sync_directory = resolved monitor = data.get("monitor") if isinstance(monitor, bool): self._monitor = monitor @@ -91,9 +119,23 @@ class RnsFilesyncHandler: def _load_permissions(self) -> PermissionStore: permissions = PermissionStore() + enforce_override: bool | None = None if os.path.isfile(self._acl_path): - with contextlib.suppress(Exception): - permissions.load_file(self._acl_path) + try: + with open(self._acl_path, encoding="utf-8") as handle: + text = handle.read() + lines = text.splitlines() + if lines: + first = lines[0].strip() + if first == "# enforce=false": + enforce_override = False + elif first == "# enforce=true": + enforce_override = True + permissions.load_allowed_text(text) + if enforce_override is not None: + permissions._enforce = enforce_override + except Exception: + pass return permissions def _save_acl(self, permissions: PermissionStore) -> None: @@ -104,16 +146,15 @@ class RnsFilesyncHandler: for target in rules.get(perm, []): short = {"read": "r", "write": "w", "delete": "d"}[perm] lines.append(f"{short}:{target}") - if permissions.enabled and not lines: - lines.append("# enforce=true") - elif not permissions.enabled: - lines.insert(0, "# enforce=false") - else: + if permissions.enabled: lines.insert(0, "# enforce=true") + else: + lines.insert(0, "# enforce=false") tmp = f"{self._acl_path}.tmp" with open(tmp, "w", encoding="utf-8") as handle: handle.write("\n".join(lines) + "\n") os.replace(tmp, self._acl_path) + self._permissions_cache = permissions def _wire_callbacks(self, service: FileSyncService) -> None: service.on_peer_connected = lambda payload: self._emit( @@ -144,7 +185,10 @@ class RnsFilesyncHandler: def _permissions(self) -> PermissionStore: if self.service is not None: return self.service.permissions - return self._load_permissions() + if self._permissions_cache is not None: + return self._permissions_cache + self._permissions_cache = self._load_permissions() + return self._permissions_cache def get_status(self) -> dict[str, Any]: with self._lock: @@ -180,10 +224,13 @@ class RnsFilesyncHandler: ) -> dict[str, Any]: with self._lock: if sync_directory is not None: - cleaned = str(sync_directory).strip() - if not cleaned: - return {"ok": False, "error": "sync_directory is required"} - self._sync_directory = os.path.realpath(os.path.expanduser(cleaned)) + resolved = self._resolve_sync_directory(sync_directory) + if resolved is None: + return { + "ok": False, + "error": "sync_directory must stay under identity storage", + } + self._sync_directory = resolved if monitor is not None: self._monitor = bool(monitor) if announce_interval is not None: @@ -202,14 +249,7 @@ class RnsFilesyncHandler: os.makedirs(self._sync_directory, exist_ok=True) permissions = self._load_permissions() - if os.path.isfile(self._acl_path): - try: - with open(self._acl_path, encoding="utf-8") as handle: - first = handle.readline().strip() - if first == "# enforce=false": - permissions._enforce = False - except Exception: - pass + self._permissions_cache = permissions service = FileSyncService( identity=self.identity, @@ -244,6 +284,7 @@ class RnsFilesyncHandler: def teardown(self) -> None: self.stop() + self._permissions_cache = None def list_peers(self) -> list[dict[str, Any]]: with self._lock: @@ -261,9 +302,9 @@ class RnsFilesyncHandler: with self._lock: if self.service is None: return {"ok": False, "error": "filesync is not running"} - cleaned = str(identity_hash or "").strip().lower().replace(":", "") - if not cleaned: - return {"ok": False, "error": "identity_hash is required"} + cleaned = _normalize_peer_hash(identity_hash) + if cleaned is None or cleaned == "all": + return {"ok": False, "error": "invalid identity_hash"} return self.service.connect_peer(cleaned) def disconnect_peer(self, peer_id: str) -> dict[str, Any]: @@ -294,6 +335,10 @@ class RnsFilesyncHandler: timeout_f = float(timeout) except (TypeError, ValueError): timeout_f = 10.0 + if timeout_f < 0.1: + timeout_f = 0.1 + if timeout_f > 120.0: + timeout_f = 120.0 files = self.service.browse_peer(cleaned, timeout=timeout_f) return {"ok": True, "peer_id": cleaned, "files": files} @@ -307,7 +352,11 @@ class RnsFilesyncHandler: return {"ok": False, "error": "peer_id is required"} if not cleaned_path: return {"ok": False, "error": "path is required"} - return self.service.download_file(cleaned_peer, cleaned_path) + try: + safe_path = normalize_relpath(cleaned_path) + except PathJailError as exc: + return {"ok": False, "error": str(exc)} + return self.service.download_file(cleaned_peer, safe_path) def get_acl(self) -> dict[str, Any]: with self._lock: @@ -333,12 +382,12 @@ class RnsFilesyncHandler: permissions.load_allowed_text(rules_text) else: permissions = self._permissions() - if self.service is None: - # Work on a fresh copy so we can persist even when stopped. - permissions = self._load_permissions() - if identity_hash and perms is not None: - granted = permissions.grant(identity_hash, perms) + if identity_hash is not None and perms is not None: + peer = _normalize_peer_hash(identity_hash) + if peer is None: + return {"ok": False, "error": "invalid identity_hash"} + granted = permissions.grant(peer, perms) if not granted and perms: return {"ok": False, "error": "no valid permissions provided"} @@ -373,10 +422,13 @@ class RnsFilesyncHandler: "ok": False, "error": "stop filesync before changing sync directory", } - cleaned = str(sync_directory).strip() - if not cleaned: - return {"ok": False, "error": "sync_directory is required"} - self._sync_directory = os.path.realpath(os.path.expanduser(cleaned)) + resolved = self._resolve_sync_directory(sync_directory) + if resolved is None: + return { + "ok": False, + "error": "sync_directory must stay under identity storage", + } + self._sync_directory = resolved if monitor is not None: self._monitor = bool(monitor) diff --git a/meshchatx/src/backend/telephone_manager.py b/meshchatx/src/backend/telephone_manager.py index 6889b4e7..d028a14e 100644 --- a/meshchatx/src/backend/telephone_manager.py +++ b/meshchatx/src/backend/telephone_manager.py @@ -92,6 +92,9 @@ class TelephoneManager: self.preferred_profile_id = None self._caller_allowed = None self._blocked_identity_hashes = None + # When True, LXST must not open PulseAudio LineSource/LineSink (Docker / + # headless / Android web bridge). Set by ReticulumMeshChat. + self.web_audio_required = False @property def is_recording(self): @@ -168,6 +171,13 @@ class TelephoneManager: if self.config_manager and not self.config_manager.telephone_enabled.get(): return + if self.web_audio_required: + from meshchatx.src.backend.web_audio_bridge import ( + install_hostless_lxst_audio, + ) + + install_hostless_lxst_audio() + # Never enable LXST auto_answer. MeshChatX answers only via explicit # user action or the separate voicemail timer after RINGING. self.telephone = Telephone(self.identity, auto_answer=None) diff --git a/meshchatx/src/backend/web_audio_bridge.py b/meshchatx/src/backend/web_audio_bridge.py index c674c129..1e80ad9c 100644 --- a/meshchatx/src/backend/web_audio_bridge.py +++ b/meshchatx/src/backend/web_audio_bridge.py @@ -15,11 +15,157 @@ from LXST.Sources import LocalSource from .telephone_manager import Tee +_HOSTLESS_AUDIO_INSTALLED = False +_ORIG_LINE_SOURCE = None +_ORIG_LINE_SINK = None + def _log_debug(msg: str): RNS.log(msg, RNS.LOG_DEBUG) +class HostlessAudioSource(LocalSource): + """LineSource stand-in that never opens PulseAudio / host capture devices. + + Docker headless and other web deployments have no usable host mic. LXST still + constructs LineSource during call setup before the websocket bridge attaches. + This class accepts the LineSource constructor shape and later yields to + WebAudioSource once a browser client connects. + """ + + def __init__( + self, + preferred_device=None, + target_frame_ms=60, + codec=None, + sink=None, + filters=None, + gain=0.0, + ease_in=0.0, + skip=0.0, + ): + self.preferred_device = preferred_device + self.target_frame_ms = target_frame_ms or 60 + self.sink = sink + self.filters = filters + self.gain = gain + self.ease_in = ease_in + self.skip = skip + self.codec = codec or Raw(channels=1, bitdepth=16) + self.channels = 1 + self.samplerate = 48000 + self.bitdepth = 16 + self.should_run = False + self.samples_per_frame = max( + 1, + int(round((self.target_frame_ms / 1000.0) * self.samplerate)), + ) + + def start(self): + self.should_run = True + + def stop(self): + self.should_run = False + + def can_receive(self, from_source=None): + return True + + def handle_frame(self, frame, source=None): + pass + + def push_pcm(self, pcm_bytes: bytes): + # Used if a bridge client attaches before WebAudioSource swap. + if not pcm_bytes or not self.sink: + return + try: + samples = ( + np.frombuffer(pcm_bytes, dtype=np.int16).astype(np.float32) / 32768.0 + ) + if samples.size == 0: + return + samples = samples.reshape(-1, 1) + frame = Raw(channels=1, bitdepth=16).encode(samples) + if self.sink.can_receive(from_source=self): + self.sink.handle_frame(frame, self) + except Exception as exc: + RNS.log(f"HostlessAudioSource: push_pcm failed: {exc}", RNS.LOG_ERROR) + + +class HostlessAudioSink(LocalSink): + """LineSink stand-in that discards PCM when no host speaker exists.""" + + def __init__(self, preferred_device=None, autodigest=True, low_latency=False): + self.preferred_device = preferred_device + self.autodigest = autodigest + self.low_latency = low_latency + self.should_run = False + self.samplerate = 48000 + self.channels = 1 + self._wants_low_latency = False + + def can_receive(self, from_source=None): + return True + + def handle_frame(self, frame, source=None): + pass + + def start(self): + self.should_run = True + + def stop(self): + self.should_run = False + + def enable_low_latency(self): + self._wants_low_latency = True + + +def install_hostless_lxst_audio() -> bool: + """Replace LXST LineSource/LineSink with hostless stand-ins (idempotent).""" + global _HOSTLESS_AUDIO_INSTALLED, _ORIG_LINE_SOURCE, _ORIG_LINE_SINK + if _HOSTLESS_AUDIO_INSTALLED: + return True + try: + from LXST.Primitives import Telephony as lxst_telephony + except Exception as exc: + RNS.log( + f"WebAudioBridge: cannot install hostless audio backends: {exc}", + RNS.LOG_ERROR, + ) + return False + + _ORIG_LINE_SOURCE = getattr(lxst_telephony, "LineSource", None) + _ORIG_LINE_SINK = getattr(lxst_telephony, "LineSink", None) + lxst_telephony.LineSource = HostlessAudioSource + lxst_telephony.LineSink = HostlessAudioSink + _HOSTLESS_AUDIO_INSTALLED = True + RNS.log( + "WebAudioBridge: installed hostless LXST audio backends (no PulseAudio)", + RNS.LOG_INFO, + ) + return True + + +def hostless_lxst_audio_installed() -> bool: + return _HOSTLESS_AUDIO_INSTALLED + + +def reset_hostless_lxst_audio_for_tests() -> None: + """Restore original LXST LineSource/LineSink (tests only).""" + global _HOSTLESS_AUDIO_INSTALLED, _ORIG_LINE_SOURCE, _ORIG_LINE_SINK + if not _HOSTLESS_AUDIO_INSTALLED: + return + with contextlib.suppress(Exception): + from LXST.Primitives import Telephony as lxst_telephony + + if _ORIG_LINE_SOURCE is not None: + lxst_telephony.LineSource = _ORIG_LINE_SOURCE + if _ORIG_LINE_SINK is not None: + lxst_telephony.LineSink = _ORIG_LINE_SINK + _HOSTLESS_AUDIO_INSTALLED = False + _ORIG_LINE_SOURCE = None + _ORIG_LINE_SINK = None + + class WebAudioSource(LocalSource): """Injects PCM frames (int16 little-endian) received over websocket into the transmit mixer.""" @@ -98,9 +244,10 @@ class WebAudioSink(LocalSink): class WebAudioBridge: """Coordinates websocket audio transport with an active LXST telephone call.""" - def __init__(self, telephone_manager, config_manager): + def __init__(self, telephone_manager, config_manager, force_enabled: bool = False): self.telephone_manager = telephone_manager self.config_manager = config_manager + self.force_enabled = bool(force_enabled) self.clients = set() self.tx_source: WebAudioSource | None = None self.rx_sink: WebAudioSink | None = None @@ -127,6 +274,8 @@ class WebAudioBridge: return getattr(self.telephone_manager, "telephone", None) def config_enabled(self): + if self.force_enabled: + return True return ( self.config_manager and hasattr(self.config_manager, "telephone_web_audio_enabled") @@ -134,12 +283,28 @@ class WebAudioBridge: ) def allow_fallback(self): + if self.force_enabled: + # Never restore PulseAudio host paths on headless/web-required hosts. + return False return ( self.config_manager and hasattr(self.config_manager, "telephone_web_audio_allow_fallback") and self.config_manager.telephone_web_audio_allow_fallback.get() ) + def get_diagnostics(self): + """Return a small status snapshot for /api/v1/telephone/status.""" + tele = self._tele() + return { + "force_enabled": self.force_enabled, + "config_enabled": bool(self.config_enabled()), + "hostless_backends": hostless_lxst_audio_installed(), + "client_count": len(self.clients), + "has_tx_source": self.tx_source is not None, + "has_rx_sink": self.rx_sink is not None, + "has_active_call": bool(tele and getattr(tele, "active_call", None)), + } + def attach_client(self, client): with self.lock: tele = self._tele() @@ -168,6 +333,7 @@ class WebAudioBridge: { "type": "web_audio.ready", "frame_ms": frame_ms, + "required": self.force_enabled, }, ), ) @@ -175,6 +341,17 @@ class WebAudioBridge: def push_client_frame(self, pcm_bytes: bytes): with self.lock: if not self.tx_source: + # Hostless LineSource stand-in still accepts PCM until swap. + tele = self._tele() + audio_in = getattr(tele, "audio_input", None) if tele else None + if audio_in is not None and hasattr(audio_in, "push_pcm"): + if getattr( + self.telephone_manager, + "is_voicemail_session_active", + False, + ): + return + audio_in.push_pcm(pcm_bytes) return # Drop frames during voicemail if getattr(self.telephone_manager, "is_voicemail_session_active", False): diff --git a/meshchatx/src/frontend/components/App.vue b/meshchatx/src/frontend/components/App.vue index 1be4a5c0..c0edf635 100644 --- a/meshchatx/src/frontend/components/App.vue +++ b/meshchatx/src/frontend/components/App.vue @@ -17,6 +17,8 @@ :view-backend-logs-label="$t('app.view_backend_logs')" :show-ws-reconnected="wsReconnectedBanner" :ws-reconnected-label="$t('app.backend_reconnected')" + :show-network-starting="showNetworkStartingBanner" + :network-starting-label="$t('app.network_starting')" :show-network-degraded="showNetworkDegradedBanner" :network-degraded-label="networkDegradedBannerLabel" :network-recovering="networkRecovering" @@ -653,6 +655,7 @@ import { BATTERY_SAVER_CHANGED_EVENT, loadBatterySaverPrefs, } from "../js/settings/batterySaverPrefs.js"; +import { setLocale } from "../js/localeLoader.js"; export default { name: "App", @@ -806,6 +809,14 @@ export default { showNetworkDegradedBanner() { return Boolean(GlobalState.networkDegraded) && this.$route?.name !== "auth"; }, + showNetworkStartingBanner() { + return ( + Boolean(GlobalState.networkStarting) && + !GlobalState.networkDegraded && + !GlobalState.networkReady && + this.$route?.name !== "auth" + ); + }, networkDegradedBannerLabel() { const detail = GlobalState.networkDegradedError; if (detail) { @@ -841,7 +852,7 @@ export default { config: { handler(newConfig) { if (newConfig && newConfig.language) { - this.$i18n.locale = newConfig.language; + void this.applyLocale(newConfig.language); } if (newConfig && newConfig.custom_ringtone_enabled !== undefined) { this.updateRingtonePlayer(); @@ -993,11 +1004,34 @@ export default { } const needShell = !GlobalState.authEnabled || (GlobalState.authenticated && this.$route.name !== "auth"); if (needShell && !this.shellRunning) { + if (GlobalState.networkStarting && !GlobalState.networkReady && !GlobalState.networkDegraded) { + this.waitForMeshThenStartShell(); + return; + } this.startShell(); } else if (!needShell && this.shellRunning) { this.stopShell(); } }, + waitForMeshThenStartShell() { + if (this._meshWaitStarted) { + return; + } + this._meshWaitStarted = true; + const stopWatch = watch( + () => [GlobalState.networkReady, GlobalState.networkDegraded, GlobalState.networkStarting], + () => { + if (GlobalState.networkReady || GlobalState.networkDegraded || !GlobalState.networkStarting) { + stopWatch(); + this._meshWaitStarted = false; + if (!this.shellRunning) { + this.applyShellAuthState(); + } + } + }, + { immediate: true } + ); + }, startShell() { if (this.shellRunning) { return; @@ -1808,6 +1842,16 @@ export default { "theme" ); }, + async applyLocale(langCode) { + if (!langCode) { + return; + } + try { + await setLocale(this.$i18n, langCode); + } catch { + this.$i18n.locale = langCode; + } + }, async onLanguageChange(langCode) { await this.updateConfig( { @@ -1815,7 +1859,7 @@ export default { }, "language" ); - this.$i18n.locale = langCode; + await this.applyLocale(langCode); }, async composeNewMessage() { // go to messages route diff --git a/meshchatx/src/frontend/components/LanguageSelector.vue b/meshchatx/src/frontend/components/LanguageSelector.vue index 7e2fe564..0d95b11d 100644 --- a/meshchatx/src/frontend/components/LanguageSelector.vue +++ b/meshchatx/src/frontend/components/LanguageSelector.vue @@ -44,18 +44,24 @@