feat: improve audio handling in Docker and headless environments with hostless LXST audio backends and web audio bridge integration

This commit is contained in:
Ivan 2026-07-19 14:02:21 -05:00
parent 9f71f3420c
commit ec74a2651e
No known key found for this signature in database
59 changed files with 3069 additions and 530 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

Binary file not shown.

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -44,18 +44,24 @@
<script>
import MaterialDesignIcon from "./MaterialDesignIcon.vue";
import { clampFloatingToViewport } from "../js/clampFloatingToViewport.js";
import { ensureLocaleMessages, listLocaleCodes } from "../js/localeLoader.js";
const localeModules = import.meta.glob("../locales/*.json", { eager: true });
const discoveredLanguages = Object.entries(localeModules)
.map(([filePath, mod]) => ({
code: filePath.match(/\/([^/]+)\.json$/)[1],
name: mod.default?._languageName || filePath.match(/\/([^/]+)\.json$/)[1],
}))
.sort((a, b) => {
if (a.code === "en") return -1;
if (b.code === "en") return 1;
return a.name.localeCompare(b.name);
});
const LANGUAGE_NAMES = {
de: "Deutsch",
en: "English",
es: "Español",
fi: "Suomi",
fr: "Français",
it: "Italiano",
nl: "Nederlands",
ru: "Русский",
zh: "中文",
};
const discoveredLanguages = listLocaleCodes().map((code) => ({
code,
name: LANGUAGE_NAMES[code] || code,
}));
export default {
name: "LanguageSelector",
@ -139,6 +145,11 @@ export default {
return;
}
try {
await ensureLocaleMessages(this.$i18n, langCode);
} catch {
// Locale pack may be unavailable in tests or offline shells.
}
this.$emit("language-change", langCode);
this.closeDropdown();
},

View file

@ -672,14 +672,21 @@
<div class="flex flex-col gap-1">
<Toggle
id="web-audio-toggle"
:model-value="config?.telephone_web_audio_enabled"
:model-value="webAudioBridgeEnabled"
:disabled="webAudioBridgeRequired"
label="Web Audio Bridge"
@update:model-value="onToggleWebAudio"
/>
<div class="text-xs text-gray-500 dark:text-zinc-400 px-1">
Web audio bridge allows web/electron to hook into LXST
backend for passing microphone and audio streams to active
telephone calls.
<template v-if="webAudioBridgeRequired">
Required on this host (no LXST host audio device).
Browser mic and speaker are used for calls.
</template>
<template v-else>
Web audio bridge allows web/electron to hook into LXST
backend for passing microphone and audio streams to
active telephone calls.
</template>
</div>
</div>
</div>
@ -719,10 +726,7 @@
</div>
<!-- Web Audio Device Selection -->
<div
v-if="config?.telephone_web_audio_enabled"
class="flex flex-col gap-2 mt-2"
>
<div v-if="webAudioBridgeEnabled" class="flex flex-col gap-2 mt-2">
<div class="flex flex-col gap-1">
<div
class="text-[10px] font-bold text-gray-500 uppercase tracking-widest px-1"
@ -2371,6 +2375,7 @@ export default {
return {
config: null,
activeCall: null,
webAudioBridgeRequired: false,
audioProfiles: [],
selectedAudioProfileId: null,
destinationHash: "",
@ -2472,6 +2477,9 @@ export default {
};
},
computed: {
webAudioBridgeEnabled() {
return Boolean(this.webAudioBridgeRequired || this.config?.telephone_web_audio_enabled);
},
isMicMuted() {
return this.localMicMuted;
},
@ -2906,10 +2914,9 @@ export default {
async disableWebAudioBridgeWithError(errorKey, error, stage = "unknown") {
this.logWebAudioFailure(stage, error);
ToastUtils.error(this.$t(errorKey));
// On Android the backend forces web_audio.enabled while Chaquopy is
// present. Permanently clearing the config flag just creates a 1s
// retry/toast loop without helping recovery.
if (!this.isMeshChatXAndroid()) {
// On Android / headless hosts the backend forces web audio. Permanently
// clearing the config flag just creates a retry/toast loop.
if (!this.isMeshChatXAndroid() && !this.webAudioBridgeRequired) {
if (this.config) {
this.config.telephone_web_audio_enabled = false;
}
@ -2922,6 +2929,9 @@ export default {
this.stopWebAudio();
},
async ensureWebAudio(webAudioStatus) {
if (webAudioStatus && typeof webAudioStatus.required === "boolean") {
this.webAudioBridgeRequired = webAudioStatus.required;
}
if (!webAudioStatus?.enabled) {
this.stopWebAudio();
return;
@ -2940,6 +2950,9 @@ export default {
},
async onToggleWebAudio(newVal) {
if (!this.config) return;
if (this.webAudioBridgeRequired && !newVal) {
return;
}
const previousValue = this.config.telephone_web_audio_enabled;
this.config.telephone_web_audio_enabled = newVal;
try {
@ -3202,7 +3215,7 @@ export default {
if (msg.type === "error") {
const errMsg = typeof msg.message === "string" ? msg.message : "";
if (errMsg.includes("Web audio is disabled in config")) {
if (this.config) {
if (!this.webAudioBridgeRequired && this.config) {
this.config.telephone_web_audio_enabled = false;
}
this.stopWebAudio();

View file

@ -120,7 +120,9 @@
<div class="flex flex-wrap gap-x-4 gap-y-1">
<span>
{{ $t("rns_filesync.running") }}:
<strong>{{ status.running ? $t("rns_filesync.yes") : $t("rns_filesync.no") }}</strong>
<strong>{{
status.running ? $t("rns_filesync.yes") : $t("rns_filesync.no")
}}</strong>
</span>
<span>
{{ $t("rns_filesync.peers_count") }}:
@ -302,9 +304,9 @@
{{ $t("rns_filesync.acl_grant") }}
</button>
</div>
<pre
class="p-3 rounded-lg bg-zinc-100 dark:bg-zinc-900 text-xs overflow-x-auto"
>{{ aclRulesText }}</pre>
<pre class="p-3 rounded-lg bg-zinc-100 dark:bg-zinc-900 text-xs overflow-x-auto">{{
aclRulesText
}}</pre>
</div>
</div>
</div>
@ -357,7 +359,7 @@ export default {
aclDelete: false,
aclRules: {},
lastProgress: "",
_wsHandlers: [],
wsHandlers: [],
};
},
computed: {
@ -374,16 +376,16 @@ export default {
await this.refreshAll();
},
beforeUnmount() {
for (const [type, handler] of this._wsHandlers) {
for (const [type, handler] of this.wsHandlers) {
offWsEvent(type, handler);
}
this._wsHandlers = [];
this.wsHandlers = [];
},
methods: {
bindWs() {
const bind = (type, handler) => {
onWsEvent(type, handler);
this._wsHandlers.push([type, handler]);
this.wsHandlers.push([type, handler]);
};
bind("filesync.sync.progress", (payload) => {
this.lastProgress = JSON.stringify(payload);
@ -406,18 +408,11 @@ export default {
});
bind("filesync.error", (payload) => {
const detail = payload?.error || payload?.message || "";
ToastUtils.error(
`${this.$t("rns_filesync.error")}${detail ? ": " + detail : ""}`,
);
ToastUtils.error(`${this.$t("rns_filesync.error")}${detail ? ": " + detail : ""}`);
});
},
async refreshAll() {
await Promise.all([
this.refreshStatus(),
this.refreshPeers(),
this.refreshFiles(),
this.refreshAcl(),
]);
await Promise.all([this.refreshStatus(), this.refreshPeers(), this.refreshFiles(), this.refreshAcl()]);
},
async refreshStatus() {
try {

View file

@ -45,6 +45,14 @@
>
{{ wsReconnectedLabel }}
</div>
<div
v-if="showNetworkStarting"
class="relative z-100 bg-sky-800 text-white px-4 py-2 text-center text-sm font-medium shadow-md border-b border-sky-900/80"
role="status"
aria-live="polite"
>
{{ networkStartingLabel }}
</div>
<div
v-if="showNetworkDegraded"
class="relative z-100 bg-amber-700 text-white px-4 py-3 text-center text-sm font-medium shadow-md border-b border-amber-800/80"
@ -120,6 +128,14 @@ export default {
type: String,
default: "",
},
showNetworkStarting: {
type: Boolean,
default: false,
},
networkStartingLabel: {
type: String,
default: "",
},
showNetworkDegraded: {
type: Boolean,
default: false,

View file

@ -18,6 +18,8 @@ const globalState = reactive({
hasPendingInterfaceChanges: false,
networkDegraded: false,
networkDegradedError: null,
networkStarting: false,
networkReady: true,
config: {
show_unknown_contact_banner: true,
banished_effect_enabled: true,

View file

@ -0,0 +1,84 @@
// SPDX-License-Identifier: 0BSD
const localeModules = import.meta.glob("../locales/*.json");
function resolveComposer(i18nOrComposer) {
if (!i18nOrComposer) {
return null;
}
return i18nOrComposer.global || i18nOrComposer;
}
/**
* Locale codes discovered from bundled JSON without loading message bodies.
* @returns {string[]}
*/
export function listLocaleCodes() {
return Object.keys(localeModules)
.map((filePath) => {
const match = filePath.match(/\/([^/]+)\.json$/);
return match ? match[1] : null;
})
.filter(Boolean)
.sort((a, b) => {
if (a === "en") {
return -1;
}
if (b === "en") {
return 1;
}
return a.localeCompare(b);
});
}
/**
* Load a locale message pack into vue-i18n when missing.
* @param {import("vue-i18n").I18n | import("vue-i18n").Composer} i18nOrComposer
* @param {string} code
* @returns {Promise<boolean>}
*/
export async function ensureLocaleMessages(i18nOrComposer, code) {
if (!code || typeof code !== "string") {
return false;
}
const composer = resolveComposer(i18nOrComposer);
if (!composer) {
return false;
}
if (composer.availableLocales?.includes(code)) {
return true;
}
if (typeof composer.setLocaleMessage !== "function") {
return false;
}
const loader = localeModules[`../locales/${code}.json`];
if (!loader) {
return false;
}
const mod = await loader();
composer.setLocaleMessage(code, mod.default || mod);
return true;
}
/**
* Apply a locale after ensuring its messages are loaded.
* @param {import("vue-i18n").I18n | import("vue-i18n").Composer} i18nOrComposer
* @param {string} code
* @returns {Promise<boolean>}
*/
export async function setLocale(i18nOrComposer, code) {
const ok = await ensureLocaleMessages(i18nOrComposer, code);
if (!ok) {
return false;
}
const composer = resolveComposer(i18nOrComposer);
if (!composer) {
return false;
}
if (composer.locale && typeof composer.locale === "object" && "value" in composer.locale) {
composer.locale.value = code;
} else {
composer.locale = code;
}
return true;
}

View file

@ -11,47 +11,63 @@ export const STARTUP_STAGE_LABELS = {
/**
* Interpret a /api/v1/status JSON body for boot gating.
* Only own properties are trusted so prototype pollution cannot spoof readiness.
* @param {unknown} data
* @returns {{ kind: "ready" | "degraded" | "failed" | "starting" | "invalid", stage?: string, error?: string, label?: string }}
* @returns {{ kind: "ready" | "ui" | "degraded" | "failed" | "starting" | "invalid", stage?: string, error?: string, label?: string }}
*/
export function interpretStartupStatus(data) {
if (!data || typeof data !== "object") {
if (!data || typeof data !== "object" || Array.isArray(data)) {
return { kind: "invalid" };
}
const status = data.status;
const stage = typeof data.stage === "string" ? data.stage : undefined;
const own = (key) => Object.prototype.hasOwnProperty.call(data, key);
const status = own("status") ? data.status : undefined;
const stage = own("stage") && typeof data.stage === "string" ? data.stage : undefined;
const networkReady = own("network_ready") && data.network_ready === true;
const uiReady = own("ui_ready") && data.ui_ready === true;
const networkDegraded = own("network_degraded") && data.network_degraded === true;
const error = own("error") && typeof data.error === "string" ? data.error : undefined;
if (status === "failed") {
// HTTP is up and the backend marked UI as usable: mount the app in
// degraded mode so interfaces/settings remain reachable for recovery.
if (data.ui_ready === true || data.network_degraded === true) {
if (uiReady || networkDegraded) {
return {
kind: "degraded",
stage: stage || "failed",
error: typeof data.error === "string" ? data.error : undefined,
error,
};
}
return {
kind: "failed",
stage: stage || "failed",
error: typeof data.error === "string" ? data.error : undefined,
error,
};
}
if (status === "ok" || data.network_ready === true) {
if (status === "ok" || networkReady) {
return { kind: "ready", stage: stage || "ready" };
}
if (status === "starting" || status === undefined) {
const resolvedStage = stage || "starting";
const label = STARTUP_STAGE_LABELS[resolvedStage] || "Starting network…";
// HTTP is bound and the shell may mount while RNS/identity finish.
if (uiReady) {
return {
kind: "ui",
stage: resolvedStage,
label,
};
}
return {
kind: "starting",
stage: resolvedStage,
label: STARTUP_STAGE_LABELS[resolvedStage] || "Starting network…",
label,
};
}
return { kind: "invalid", stage };
}
/**
* Poll /api/v1/status until the network stack is ready or degraded-but-usable.
* Poll /api/v1/status until the UI may mount (ui_ready), mesh is ready, or degraded.
* @param {{
* fetchImpl?: typeof fetch,
* now?: () => number,
@ -61,8 +77,9 @@ export function interpretStartupStatus(data) {
* onErrorState?: () => void,
* onDegraded?: (error?: string) => void,
* statusUrl?: string,
* mountOnUiReady?: boolean,
* }} [options]
* @returns {Promise<"ready" | "degraded" | false>}
* @returns {Promise<"ready" | "ui" | "degraded" | false>}
*/
export async function waitForNetworkReady(options = {}) {
const fetchImpl = options.fetchImpl || fetch;
@ -73,6 +90,7 @@ export async function waitForNetworkReady(options = {}) {
const onErrorState = options.onErrorState || (() => {});
const onDegraded = options.onDegraded || (() => {});
const statusUrl = options.statusUrl || "/api/v1/status";
const mountOnUiReady = options.mountOnUiReady !== false;
const deadline = now() + timeoutMs;
let delayMs = 200;
@ -95,7 +113,11 @@ export async function waitForNetworkReady(options = {}) {
if (interpreted.kind === "ready") {
return "ready";
}
if (interpreted.kind === "starting") {
if (interpreted.kind === "ui" && mountOnUiReady) {
onLine(interpreted.label || "Opening the app…");
return "ui";
}
if (interpreted.kind === "starting" || interpreted.kind === "ui") {
onLine(interpreted.label || "Getting things ready…");
}
}
@ -109,3 +131,25 @@ export async function waitForNetworkReady(options = {}) {
onErrorState();
return false;
}
/**
* Continue polling until mesh network_ready or degraded/failed.
* Used after an early UI mount on ui_ready.
* @param {{
* fetchImpl?: typeof fetch,
* now?: () => number,
* sleep?: (ms: number) => Promise<void>,
* timeoutMs?: number,
* onLine?: (text: string) => void,
* onErrorState?: () => void,
* onDegraded?: (error?: string) => void,
* statusUrl?: string,
* }} [options]
* @returns {Promise<"ready" | "degraded" | false>}
*/
export async function waitForMeshReady(options = {}) {
return waitForNetworkReady({
...options,
mountOnUiReady: false,
});
}

View file

@ -488,6 +488,7 @@
"rpc_key_hide": "RPC-Schlüssel verbergen",
"refresh_community_interfaces": "Von directory.rns.recipes aktualisieren",
"refresh_community_interfaces_busy": "Wird aktualisiert…",
"network_starting": "Verbinde mit dem Mesh…",
"network_degraded": "Mesh-Netzwerk nicht verfügbar. Die App läuft weiter, damit Sie Schnittstellen reparieren können, ohne Daten zu löschen.",
"recover_network": "Netzwerk erneut versuchen",
"open_interfaces": "Schnittstellen öffnen",

View file

@ -410,6 +410,7 @@
"backend_process_stopped": "Reticulum backend stopped",
"backend_reconnected": "Reconnected to backend",
"restart_backend": "Restart backend",
"network_starting": "Connecting to the mesh…",
"network_degraded": "Mesh network unavailable. The app is still running so you can fix interfaces without wiping data.",
"recover_network": "Retry network",
"open_interfaces": "Open interfaces",

View file

@ -488,6 +488,7 @@
"rpc_key_hide": "Ocultar clave RPC",
"refresh_community_interfaces": "Actualizar desde directory.rns.recipes",
"refresh_community_interfaces_busy": "Actualizando…",
"network_starting": "Conectando a la mesh…",
"network_degraded": "Red mesh no disponible. La aplicación sigue en ejecución para que pueda reparar interfaces sin borrar datos.",
"recover_network": "Reintentar red",
"open_interfaces": "Abrir interfaces",

View file

@ -488,6 +488,7 @@
"rpc_key_hide": "Piilota RPC-avain",
"refresh_community_interfaces": "Päivitä lähteestä directory.rns.recipes",
"refresh_community_interfaces_busy": "Päivitetään…",
"network_starting": "Yhdistetään mesh-verkkoon…",
"network_degraded": "Mesh-verkko ei ole käytettävissä. Sovellus pysyy käynnissä, jotta voit korjata liittymiä ilman tietojen tyhjennystä.",
"recover_network": "Yritä verkkoa uudelleen",
"open_interfaces": "Avaa liittymät",

View file

@ -488,6 +488,7 @@
"rpc_key_hide": "Masquer la clé RPC",
"refresh_community_interfaces": "Actualiser depuis directory.rns.recipes",
"refresh_community_interfaces_busy": "Actualisation…",
"network_starting": "Connexion au mesh…",
"network_degraded": "Réseau mesh indisponible. L'application reste ouverte pour réparer les interfaces sans effacer les données.",
"recover_network": "Réessayer le réseau",
"open_interfaces": "Ouvrir les interfaces",

View file

@ -488,6 +488,7 @@
"rpc_key_hide": "Nascondi chiave RPC",
"refresh_community_interfaces": "Aggiorna da directory.rns.recipes",
"refresh_community_interfaces_busy": "Aggiornamento…",
"network_starting": "Connessione alla mesh…",
"network_degraded": "Rete mesh non disponibile. L'app resta attiva così puoi riparare le interfacce senza cancellare i dati.",
"recover_network": "Riprova rete",
"open_interfaces": "Apri interfacce",

View file

@ -488,6 +488,7 @@
"rpc_key_hide": "RPC-sleutel verbergen",
"refresh_community_interfaces": "Vernieuwen vanaf directory.rns.recipes",
"refresh_community_interfaces_busy": "Bezig met vernieuwen…",
"network_starting": "Verbinden met het mesh…",
"network_degraded": "Mesh-netwerk niet beschikbaar. De app blijft draaien zodat je interfaces kunt herstellen zonder gegevens te wissen.",
"recover_network": "Netwerk opnieuw proberen",
"open_interfaces": "Interfaces openen",

View file

@ -488,6 +488,7 @@
"rpc_key_hide": "Скрыть RPC-ключ",
"refresh_community_interfaces": "Обновить из directory.rns.recipes",
"refresh_community_interfaces_busy": "Обновление…",
"network_starting": "Подключение к mesh…",
"network_degraded": "Mesh-сеть недоступна. Приложение продолжает работать, чтобы вы могли исправить интерфейсы без удаления данных.",
"recover_network": "Повторить сеть",
"open_interfaces": "Открыть интерфейсы",

View file

@ -488,6 +488,7 @@
"rpc_key_hide": "隐藏 RPC 密钥",
"refresh_community_interfaces": "从 directory.rns.recipes 刷新",
"refresh_community_interfaces_busy": "正在刷新…",
"network_starting": "正在连接 mesh…",
"network_degraded": "Mesh 网络不可用。应用仍在运行,便于修复接口而无需清除数据。",
"recover_network": "重试网络",
"open_interfaces": "打开接口",

View file

@ -26,19 +26,15 @@ installWsEventBridge();
import App from "./components/App.vue";
import ChangelogModal from "./components/ChangelogModal.vue";
import TutorialModal from "./components/TutorialModal.vue";
const localeModules = import.meta.glob("./locales/*.json", { eager: true });
const messages = {};
for (const filePath in localeModules) {
const code = filePath.match(/\/([^/]+)\.json$/)[1];
messages[code] = localeModules[filePath].default;
}
import enMessages from "./locales/en.json";
const i18n = createI18n({
legacy: false,
locale: "en",
fallbackLocale: "en",
messages,
messages: {
en: enMessages,
},
});
// init vuetify
@ -342,7 +338,7 @@ window.api = createApiClient({
},
});
import { waitForNetworkReady } from "./js/networkStartupWait.js";
import { waitForMeshReady, waitForNetworkReady } from "./js/networkStartupWait.js";
function setBootSplashLine(text) {
const splash = typeof document !== "undefined" ? document.getElementById("meshchatx-boot-splash") : null;
@ -365,11 +361,21 @@ const networkReady = await waitForNetworkReady({
onDegraded: (error) => {
GlobalState.networkDegraded = true;
GlobalState.networkDegradedError = error || "Mesh network unavailable";
GlobalState.networkStarting = false;
GlobalState.networkReady = false;
},
});
if (networkReady) {
if (networkReady === "degraded") {
GlobalState.networkDegraded = true;
GlobalState.networkStarting = false;
GlobalState.networkReady = false;
} else if (networkReady === "ui") {
GlobalState.networkStarting = true;
GlobalState.networkReady = false;
} else {
GlobalState.networkStarting = false;
GlobalState.networkReady = true;
}
try {
await fetchCsrfToken(window.api);
@ -477,8 +483,38 @@ if (networkReady) {
});
});
preloadCriticalRouteChunks();
void startCodec2ScriptsBackgroundLoad();
void loadPluginsIfEnabled();
if (GlobalState.networkReady) {
void startCodec2ScriptsBackgroundLoad();
void loadPluginsIfEnabled();
} else if (GlobalState.networkStarting) {
void waitForMeshReady({
onLine: () => {},
onDegraded: (error) => {
GlobalState.networkDegraded = true;
GlobalState.networkDegradedError = error || "Mesh network unavailable";
GlobalState.networkStarting = false;
GlobalState.networkReady = false;
},
}).then((meshState) => {
if (meshState === "ready") {
GlobalState.networkStarting = false;
GlobalState.networkReady = true;
GlobalState.networkDegraded = false;
GlobalState.networkDegradedError = null;
void startCodec2ScriptsBackgroundLoad();
void loadPluginsIfEnabled();
} else if (meshState === "degraded") {
GlobalState.networkStarting = false;
GlobalState.networkReady = false;
} else {
GlobalState.networkStarting = false;
GlobalState.networkReady = false;
GlobalState.networkDegraded = true;
GlobalState.networkDegradedError =
GlobalState.networkDegradedError || "Mesh network startup timed out";
}
});
}
if (GlobalState.networkDegraded) {
try {
router.replace({ name: "interfaces" });

View file

@ -37,6 +37,8 @@ Voicemail events surface as `new_voicemail` on the WebSocket.
The **Call** area keeps history of placed, received, and missed calls. You can record calls when the feature is enabled and policy allows storage on your device.
Unread missed calls show as a red count on the Calls sidebar icon and the header phone button. Opening the Call page clears that count. Desktop and Android still show a one-shot missed-call notification when the event happens.
## Ringtones
Upload custom ringtones and assign them per contact. Default sounds are used when no override exists.
@ -79,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

View file

@ -0,0 +1,155 @@
#!/usr/bin/env python3
# SPDX-License-Identifier: 0BSD
"""Make LXST Codec2 import optional so missing libcodec2 does not break telephony.
Upstream LXST 0.5.x imports Codec2 unconditionally in Codecs/__init__.py.
When pycodec2/libcodec2 is missing, that raises and prevents Opus profiles
and the whole Telephone stack from loading. Android wheels already soft-import
Codec2. This patch applies the same guard to a site-packages install.
Idempotent. Safe to run after every uv sync / pip install.
"""
from __future__ import annotations
import importlib.metadata
import sys
from pathlib import Path
_CODECS_MARKER = "# meshchatx-lxst-codec2-optional (do not remove)\n"
_CODECS_INIT = """# Copyright 2024-2026, Mark Qvist
# meshchatx-lxst-codec2-optional (do not remove)
from .Codec import CodecError as CodecError
from .Codec import Codec as Codec
from .Codec import Null as Null
from .Raw import Raw as Raw
from .Opus import Opus as Opus
_CODEC2_IMPORT_ERROR = None
try:
from .Codec2 import Codec2 as Codec2
except Exception as _codec2_exc:
Codec2 = None
_CODEC2_IMPORT_ERROR = _codec2_exc
NULL = 0xFF
RAW = 0x00
OPUS = 0x01
CODEC2 = 0x02
def _raise_codec2_unavailable():
if _CODEC2_IMPORT_ERROR is not None:
raise CodecError(f"Codec2 backend unavailable: {_CODEC2_IMPORT_ERROR}")
raise CodecError("Codec2 backend unavailable")
def codec_header_byte(codec):
if codec == Raw:
return RAW.to_bytes()
elif codec == Opus:
return OPUS.to_bytes()
elif Codec2 is not None and codec == Codec2:
return CODEC2.to_bytes()
raise TypeError(f"No header mapping for codec type {codec}")
def codec_type(header_byte):
if header_byte == RAW:
return Raw
elif header_byte == OPUS:
return Opus
elif header_byte == CODEC2:
if Codec2 is None:
_raise_codec2_unavailable()
return Codec2
"""
_OLD_GET_CODEC = """ @staticmethod
def get_codec(profile):
if profile == Profiles.BANDWIDTH_ULTRA_LOW: return Codec2(mode=Codec2.CODEC2_700C)
elif profile == Profiles.BANDWIDTH_VERY_LOW: return Codec2(mode=Codec2.CODEC2_1600)
elif profile == Profiles.BANDWIDTH_LOW: return Codec2(mode=Codec2.CODEC2_3200)
elif profile == Profiles.QUALITY_MEDIUM: return Opus(profile=Opus.PROFILE_VOICE_MEDIUM)
elif profile == Profiles.QUALITY_HIGH: return Opus(profile=Opus.PROFILE_VOICE_HIGH)
elif profile == Profiles.QUALITY_MAX: return Opus(profile=Opus.PROFILE_VOICE_MAX)
elif profile == Profiles.LATENCY_LOW: return Opus(profile=Opus.PROFILE_VOICE_MEDIUM)
elif profile == Profiles.LATENCY_ULTRA_LOW: return Opus(profile=Opus.PROFILE_VOICE_MEDIUM)
else: return Opus(profile=Opus.PROFILE_VOICE_MEDIUM)
"""
_NEW_GET_CODEC = """ @staticmethod
def get_codec(profile):
if Codec2 is not None:
if profile == Profiles.BANDWIDTH_ULTRA_LOW: return Codec2(mode=Codec2.CODEC2_700C)
elif profile == Profiles.BANDWIDTH_VERY_LOW: return Codec2(mode=Codec2.CODEC2_1600)
elif profile == Profiles.BANDWIDTH_LOW: return Codec2(mode=Codec2.CODEC2_3200)
if profile == Profiles.QUALITY_MEDIUM: return Opus(profile=Opus.PROFILE_VOICE_MEDIUM)
elif profile == Profiles.QUALITY_HIGH: return Opus(profile=Opus.PROFILE_VOICE_HIGH)
elif profile == Profiles.QUALITY_MAX: return Opus(profile=Opus.PROFILE_VOICE_MAX)
elif profile == Profiles.LATENCY_LOW: return Opus(profile=Opus.PROFILE_VOICE_MEDIUM)
elif profile == Profiles.LATENCY_ULTRA_LOW: return Opus(profile=Opus.PROFILE_VOICE_MEDIUM)
else: return Opus(profile=Opus.PROFILE_VOICE_MEDIUM)
"""
def _lxst_file(*parts: str) -> Path | None:
try:
dist = importlib.metadata.distribution("lxst")
except importlib.metadata.PackageNotFoundError:
return None
for rel in dist.files or ():
if tuple(rel.parts[-len(parts) :]) == parts:
return Path(dist.locate_file(rel))
return None
def main() -> int:
codecs_init = _lxst_file("Codecs", "__init__.py")
telephony = _lxst_file("Primitives", "Telephony.py")
if codecs_init is None:
print(
"patch_lxst_codec2_optional: lxst not installed, nothing to do",
file=sys.stderr,
)
return 0
codecs_text = codecs_init.read_text(encoding="utf-8")
if _CODECS_MARKER not in codecs_text:
codecs_init.write_text(_CODECS_INIT, encoding="utf-8")
print(f"patch_lxst_codec2_optional: patched Codecs/__init__.py ({codecs_init})")
else:
print(f"patch_lxst_codec2_optional: Codecs already patched ({codecs_init})")
if telephony is None:
print(
"patch_lxst_codec2_optional: Telephony.py not found",
file=sys.stderr,
)
return 1
tel_text = telephony.read_text(encoding="utf-8")
if "if Codec2 is not None:" in tel_text and "BANDWIDTH_ULTRA_LOW" in tel_text:
print(
f"patch_lxst_codec2_optional: Telephony get_codec already guarded ({telephony})"
)
return 0
if _OLD_GET_CODEC not in tel_text:
print(
"patch_lxst_codec2_optional: unexpected Telephony get_codec layout; "
f"manual check required ({telephony})",
file=sys.stderr,
)
return 1
telephony.write_text(
tel_text.replace(_OLD_GET_CODEC, _NEW_GET_CODEC, 1),
encoding="utf-8",
)
print(f"patch_lxst_codec2_optional: patched Telephony get_codec ({telephony})")
return 0
if __name__ == "__main__":
raise SystemExit(main())

View file

@ -98,9 +98,7 @@ def _bind_resend_app(db):
ctx = MagicMock()
ctx.identity.hash.hex.return_value = "aa" * 16
ctx.database = db
ctx.config.allow_auto_resending_failed_messages_with_attachments.get.return_value = (
True
)
ctx.config.allow_auto_resending_failed_messages_with_attachments.get.return_value = True
return app, ctx
@ -169,9 +167,7 @@ async def test_resend_skips_attachments_before_claim(db):
)
_insert_failed(db, msg_hash=msg, peer=peer, content="pic", fields=fields)
app, ctx = _bind_resend_app(db)
ctx.config.allow_auto_resending_failed_messages_with_attachments.get.return_value = (
False
)
ctx.config.allow_auto_resending_failed_messages_with_attachments.get.return_value = False
await app.resend_failed_messages_for_destination(peer, context=ctx)
app.send_message.assert_not_called()
row = db.provider.fetchone(
@ -257,10 +253,13 @@ async def test_resend_does_not_delete_old_when_send_returns_none(db):
app, ctx = _bind_resend_app(db)
app.send_message.return_value = None
await app.resend_failed_messages_for_destination(peer, context=ctx)
assert db.provider.fetchone(
"SELECT state FROM lxmf_messages WHERE hash = ?",
(old_hash,),
)["state"] == "failed"
assert (
db.provider.fetchone(
"SELECT state FROM lxmf_messages WHERE hash = ?",
(old_hash,),
)["state"]
== "failed"
)
# Cooldown claimed, attempt counted.
row = db.provider.fetchone(
"SELECT fields, next_delivery_attempt_at FROM lxmf_messages WHERE hash = ?",

View file

@ -71,6 +71,7 @@ def test_deferred_init_skips_reticulum_until_background_setup(mock_identity, tem
payload = app._startup_status_payload()
assert payload["status"] == "starting"
assert payload["network_ready"] is False
assert payload["ui_ready"] is True
assert payload["stage"] == "http"
assert_matches_schema(payload, API_V1_STATUS_SCHEMA)
@ -266,6 +267,7 @@ def test_startup_status_payload_stages(mock_identity, temp_dir):
assert payload["status"] == "starting"
assert payload["stage"] == stage
assert payload["network_ready"] is False
assert payload["ui_ready"] is True
assert_matches_schema(payload, API_V1_STATUS_SCHEMA)

View file

@ -78,12 +78,33 @@ def test_filter_discovered_interfaces_long_names_and_scripts():
max_size=8,
),
)
def test_discovery_filter_candidates_never_raises(name, typ, host, port, extra):
def test_discovery_filter_candidates_field_oracle(name, typ, host, port, extra):
iface = {"name": name, "type": typ, "reachable_on": host, "port": port, **extra}
c = ReticulumMeshChat.discovery_filter_candidates(iface)
assert isinstance(c, list)
for x in c:
assert isinstance(x, str)
for key in (
"name",
"type",
"reachable_on",
"target_host",
"remote",
"listen_ip",
"port",
"target_port",
"listen_port",
"discovery_hash",
"transport_id",
"network_id",
"network_name",
"ifac_netname",
):
value = iface.get(key)
if value is not None and value != "":
assert str(value) in c
if host and port:
assert f"{host}:{port}" in c
@settings(
@ -115,13 +136,19 @@ def test_discovery_filter_candidates_never_raises(name, typ, host, port, extra):
def test_matches_discovery_pattern_fuzzing(wl, bl, iface):
patterns_wl = ",".join(wl) if wl else ""
patterns_bl = ",".join(bl) if bl else ""
ReticulumMeshChat.matches_discovery_pattern(
ReticulumMeshChat.sanitize_discovery_patterns(patterns_wl),
iface,
assert isinstance(
ReticulumMeshChat.matches_discovery_pattern(
ReticulumMeshChat.sanitize_discovery_patterns(patterns_wl),
iface,
),
bool,
)
ReticulumMeshChat.matches_discovery_pattern(
ReticulumMeshChat.sanitize_discovery_patterns(patterns_bl),
iface,
assert isinstance(
ReticulumMeshChat.matches_discovery_pattern(
ReticulumMeshChat.sanitize_discovery_patterns(patterns_bl),
iface,
),
bool,
)

View file

@ -95,6 +95,9 @@ def test_emergency_mode_startup_logic(mock_rns, temp_dir):
patch(
"meshchatx.src.backend.identity_context.IdentityContext.start_background_threads",
),
patch(
"meshchatx.src.backend.identity_context.IdentityContext.setup_deferred_services",
),
):
# Initialize app in emergency mode
app = ReticulumMeshChat(
@ -144,6 +147,9 @@ def test_emergency_mode_env_var(mock_rns, temp_dir):
patch(
"meshchatx.src.backend.identity_context.IdentityContext.start_background_threads",
),
patch(
"meshchatx.src.backend.identity_context.IdentityContext.setup_deferred_services",
),
):
# We need to simulate the argparse processing that happens in main()
# but since we are testing ReticulumMeshChat directly, we check if it respects the flag
@ -208,14 +214,15 @@ def test_normal_mode_startup_logic(mock_rns, temp_dir):
assert db_path_arg != ":memory:"
assert db_path_arg.endswith("database.db")
# Verify IntegrityManager.check_integrity WAS called
assert mock_integrity_instance.check_integrity.call_count == 1
# Critical integrity at core setup, full walk during deferred services
assert mock_integrity_instance.check_integrity.call_count >= 1
mock_integrity_instance.check_integrity.assert_any_call(critical_only=True)
# Verify TelephoneManager.init_telephone WAS called
mock_tel_instance = mock_tel_class.return_value
assert mock_tel_instance.init_telephone.call_count == 1
# Verify IntegrityManager.save_manifest WAS called
# Manifest is saved after deferred integrity pass
assert mock_integrity_instance.save_manifest.call_count == 1

View file

@ -158,6 +158,18 @@ def test_build_export_document_shape():
assert doc["exported_at"] == "2026-01-01T00:00:00Z"
_GIF_REJECT_REASONS = frozenset(
{
"invalid_image_bytes",
"empty_image",
"image_too_large",
"invalid_image_type",
"invalid_image_signature",
"magic_type_mismatch",
},
)
@settings(max_examples=200, deadline=None)
@given(
raw=st.binary(min_size=0, max_size=4096),
@ -167,16 +179,35 @@ def test_build_export_document_shape():
st.sampled_from(["gif", "webp", "image/gif", "image/webp", "png", ""]),
),
)
def test_validate_gif_payload_fuzz_never_raises_unexpected(raw, typ):
"""Fuzz: validation either succeeds or raises ValueError with known reasons."""
def test_validate_gif_payload_accept_reject_oracle(raw, typ):
"""Accept only when type and magic agree and size is in range."""
nt = gif_utils.normalize_image_type(typ)
detected = gif_utils.detect_image_format_from_magic(raw)
expect_ok = (
bool(raw)
and len(raw) <= gif_utils.MAX_GIF_BYTES
and nt is not None
and detected is not None
and detected == nt
)
try:
gif_utils.validate_gif_payload(raw, typ)
except ValueError:
pass
out_type, out_hash = gif_utils.validate_gif_payload(raw, typ)
except ValueError as exc:
assert not expect_ok
assert str(exc) in _GIF_REJECT_REASONS
return
assert expect_ok
assert out_type == detected
assert len(out_hash) == 64
assert all(c in "0123456789abcdef" for c in out_hash)
@settings(max_examples=500, deadline=None)
@given(raw=st.binary(min_size=0, max_size=4096))
def test_detect_image_format_from_magic_fuzz_never_raises(raw):
def test_detect_image_format_from_magic_closed_set(raw):
out = gif_utils.detect_image_format_from_magic(raw)
assert out is None or out in {"gif", "webp"}
if raw.startswith((b"GIF87a", b"GIF89a")):
assert out == "gif"
elif len(raw) >= 12 and raw.startswith(b"RIFF") and raw[8:12] == b"WEBP":
assert out == "webp"

View file

@ -39,14 +39,30 @@ def test_hex_identifier_to_bytes_invalid_returns_none():
assert hex_identifier_to_bytes("abc") is None
@given(s=st.text())
def test_normalize_hex_identifier_never_raises(s):
normalize_hex_identifier(s)
@given(s=st.one_of(st.none(), st.text(), st.binary(), st.integers()))
def test_normalize_hex_identifier_oracle(s):
"""Output is only lowercase hex digits, empty for non-str or empty input."""
out = normalize_hex_identifier(s) # type: ignore[arg-type]
if not isinstance(s, str) or not s:
assert out == ""
return
assert isinstance(out, str)
assert all(c in "0123456789abcdef" for c in out)
assert out == "".join(c for c in s.strip().lower() if c in "0123456789abcdef")
@given(s=st.text())
def test_hex_identifier_to_bytes_never_raises(s):
hex_identifier_to_bytes(s)
@given(s=st.one_of(st.none(), st.text(max_size=400)))
def test_hex_identifier_to_bytes_oracle(s):
"""Accept only even-length normalized hex. Reject odd or empty."""
n = normalize_hex_identifier(s)
b = hex_identifier_to_bytes(s)
if not n or len(n) % 2:
assert b is None
return
assert b is not None
assert isinstance(b, bytes)
assert len(b) == len(n) // 2
assert b.hex() == n
@given(h=st.from_regex(r"[0-9a-fA-F]{0,200}"))

View file

@ -0,0 +1,73 @@
# SPDX-License-Identifier: 0BSD
"""Extra integrity edge cases for critical-only startup checks."""
from __future__ import annotations
import shutil
import tempfile
import unittest
from pathlib import Path
from meshchatx.src.backend.integrity_manager import (
IntegrityManager,
select_critical_integrity_issues,
)
class TestCriticalOnlyIntegrity(unittest.TestCase):
def setUp(self):
self.test_dir = tempfile.mkdtemp()
self.db_path = Path(self.test_dir) / "database.db"
self.db_path.write_bytes(b"db-bytes")
self.identity_path = Path(self.test_dir) / "identity"
self.identity_path.write_bytes(b"identity-bytes")
self.manager = IntegrityManager(
self.test_dir,
str(self.db_path),
identity_hash="hash-a",
)
self.manager.save_manifest()
def tearDown(self):
shutil.rmtree(self.test_dir)
def test_critical_only_passes_unchanged_tree(self):
is_ok, issues = self.manager.check_integrity(critical_only=True)
self.assertTrue(is_ok)
self.assertEqual(select_critical_integrity_issues(issues), [])
def test_critical_only_detects_missing_identity(self):
self.identity_path.unlink()
is_ok, issues = self.manager.check_integrity(critical_only=True)
self.assertFalse(is_ok)
self.assertTrue(
select_critical_integrity_issues(issues)
or any("missing" in i.lower() for i in issues)
)
def test_critical_only_skips_non_critical_size_drift(self):
notes = Path(self.test_dir) / "notes.bin"
notes.write_bytes(b"aaaa")
self.manager.save_manifest()
notes.write_bytes(b"bbbbbbbb")
is_ok_critical, issues_critical = self.manager.check_integrity(
critical_only=True
)
self.assertTrue(
is_ok_critical or not select_critical_integrity_issues(issues_critical),
)
is_ok_full, issues_full = self.manager.check_integrity(critical_only=False)
self.assertFalse(is_ok_full)
self.assertTrue(any("notes.bin" in issue for issue in issues_full))
def test_select_critical_markers_cover_identity_and_db(self):
issues = [
"File signature mismatch: notes.bin",
"Critical security component integrity compromised: identity",
"Database structural issue: corrupt",
"Identity mismatch! Manifest belongs to: other",
]
critical = select_critical_integrity_issues(issues)
self.assertEqual(len(critical), 3)
self.assertNotIn("File signature mismatch: notes.bin", critical)

View file

@ -0,0 +1,333 @@
# SPDX-License-Identifier: 0BSD
"""Adversarial, property, exploratory, and oracle tests for LXST telephony / web audio.
Oracles (invariants that must always hold):
1. Hostless LineSource/LineSink construct without PulseAudio and never raise on start/stop.
2. WebAudioSource never raises on arbitrary PCM and drops oversized frames.
3. When web_audio is required, bridge.config_enabled is True even if config is False.
4. Codec2-unavailable profile resolution never returns a Codec2 bandwidth profile.
5. install_hostless_lxst_audio is idempotent.
"""
from __future__ import annotations
import os
from unittest.mock import MagicMock, patch
import numpy as np
import pytest
from hypothesis import given, settings
from hypothesis import strategies as st
pytest.importorskip("LXST")
from LXST.Primitives.Telephony import Profiles
from meshchatx.src.backend.telephone_manager import TelephoneManager
from meshchatx.src.backend.web_audio_bridge import (
HostlessAudioSink,
HostlessAudioSource,
WebAudioBridge,
WebAudioSource,
hostless_lxst_audio_installed,
install_hostless_lxst_audio,
reset_hostless_lxst_audio_for_tests,
)
CODEC2_PROFILES = {
Profiles.BANDWIDTH_ULTRA_LOW,
Profiles.BANDWIDTH_VERY_LOW,
Profiles.BANDWIDTH_LOW,
}
@pytest.fixture(autouse=True)
def _reset_hostless():
reset_hostless_lxst_audio_for_tests()
yield
reset_hostless_lxst_audio_for_tests()
# ---------------------------------------------------------------------------
# Oracles: hostless backends
# ---------------------------------------------------------------------------
def test_oracle_hostless_source_matches_linesource_ctor_shape():
sink = MagicMock()
sink.can_receive.return_value = True
src = HostlessAudioSource(
preferred_device="default",
target_frame_ms=60,
codec=None,
sink=sink,
filters=[],
gain=-3.0,
ease_in=0.1,
skip=0.05,
)
src.start()
src.stop()
src.handle_frame(None, None)
assert src.samplerate == 48000
assert src.channels == 1
assert src.can_receive() is True
def test_oracle_hostless_sink_never_needs_pulse():
sink = HostlessAudioSink(preferred_device=None)
sink.start()
sink.handle_frame(np.zeros((160, 1), dtype=np.float32), None)
sink.enable_low_latency()
sink.stop()
assert sink.can_receive() is True
@given(pcm=st.binary(min_size=0, max_size=WebAudioSource.MAX_PCM_BYTES + 4096))
@settings(max_examples=40, deadline=None)
def test_exploratory_hostless_source_push_pcm_never_raises(pcm):
sink = MagicMock()
sink.can_receive.return_value = True
src = HostlessAudioSource(target_frame_ms=60, sink=sink)
src.push_pcm(pcm)
def test_oracle_install_hostless_is_idempotent():
assert install_hostless_lxst_audio() is True
assert hostless_lxst_audio_installed() is True
assert install_hostless_lxst_audio() is True
from LXST.Primitives import Telephony as T
assert T.LineSource is HostlessAudioSource
assert T.LineSink is HostlessAudioSink
def test_oracle_hostless_linesource_constructs_without_soundcard():
assert install_hostless_lxst_audio() is True
from LXST.Primitives import Telephony as T
src = T.LineSource(target_frame_ms=60, codec=None, sink=MagicMock())
assert isinstance(src, HostlessAudioSource)
sink = T.LineSink()
assert isinstance(sink, HostlessAudioSink)
# ---------------------------------------------------------------------------
# Oracles: web audio required / force_enabled
# ---------------------------------------------------------------------------
def test_oracle_force_enabled_overrides_config_false():
cfg = MagicMock()
cfg.telephone_web_audio_enabled.get.return_value = False
bridge = WebAudioBridge(None, cfg, force_enabled=True)
assert bridge.config_enabled() is True
assert bridge.allow_fallback() is False
diag = bridge.get_diagnostics()
assert diag["force_enabled"] is True
assert diag["config_enabled"] is True
def test_oracle_force_disabled_follows_config():
cfg = MagicMock()
cfg.telephone_web_audio_enabled.get.return_value = False
bridge = WebAudioBridge(None, cfg, force_enabled=False)
assert bridge.config_enabled() is False
@given(extra=st.integers(min_value=1, max_value=8192))
@settings(max_examples=20, deadline=None)
def test_oracle_web_audio_drops_oversized_pcm(extra):
sink = MagicMock()
sink.can_receive.return_value = True
src = WebAudioSource(target_frame_ms=60, sink=sink)
src.push_pcm(b"\x00" * (WebAudioSource.MAX_PCM_BYTES + extra))
sink.handle_frame.assert_not_called()
# ---------------------------------------------------------------------------
# Oracles: Codec2 profile fallback
# ---------------------------------------------------------------------------
@given(
pid=st.sampled_from(
list(Profiles.available_profiles())
+ [0, 1, 2, 99, -1, 255, None, "64", "nope"],
),
)
@settings(max_examples=40, deadline=None)
def test_property_resolve_profile_never_returns_codec2_when_unavailable(pid):
tm = TelephoneManager(identity=MagicMock())
with patch.object(TelephoneManager, "codec2_available", return_value=False):
resolved = tm.resolve_audio_profile_id(pid)
assert resolved in Profiles.available_profiles()
assert resolved not in CODEC2_PROFILES
def test_oracle_codec2_available_false_on_android_probe_fail():
tm = TelephoneManager(identity=MagicMock())
with (
patch("meshchatx.android_codec2._is_chaquopy_android", return_value=True),
patch(
"meshchatx.android_codec2.probe_pycodec2", return_value=(False, "missing")
),
):
assert tm.codec2_available() is False
# ---------------------------------------------------------------------------
# Adversarial: telephone init with web_audio_required
# ---------------------------------------------------------------------------
def test_adversarial_init_telephone_installs_hostless_when_required(tmp_path):
cfg = MagicMock()
cfg.telephone_enabled.get.return_value = True
cfg.telephone_audio_profile_id.get.return_value = Profiles.DEFAULT_PROFILE
identity = MagicMock()
identity.hash = b"\x11" * 16
with patch("meshchatx.src.backend.telephone_manager.Telephone") as telephone_cls:
telephone = telephone_cls.return_value
tm = TelephoneManager(
identity=identity,
config_manager=cfg,
storage_dir=str(tmp_path),
)
tm.web_audio_required = True
tm.init_telephone()
assert hostless_lxst_audio_installed() is True
telephone.set_busy_tone_time.assert_called()
telephone.set_connect_timeout.assert_called()
def test_adversarial_init_telephone_skips_hostless_when_not_required(tmp_path):
cfg = MagicMock()
cfg.telephone_enabled.get.return_value = True
cfg.telephone_audio_profile_id.get.return_value = Profiles.DEFAULT_PROFILE
identity = MagicMock()
identity.hash = b"\x22" * 16
with patch("meshchatx.src.backend.telephone_manager.Telephone"):
tm = TelephoneManager(
identity=identity,
config_manager=cfg,
storage_dir=str(tmp_path),
)
tm.web_audio_required = False
tm.init_telephone()
assert hostless_lxst_audio_installed() is False
# ---------------------------------------------------------------------------
# MeshChatX web_audio_required helper
# ---------------------------------------------------------------------------
def test_oracle_meshchat_web_audio_required_host_audio_unavailable():
from meshchatx.meshchat import ReticulumMeshChat
app = ReticulumMeshChat.__new__(ReticulumMeshChat)
app._headless = True
app._host_audio_unavailable_cached = None
with (
patch("meshchatx.meshchat._is_chaquopy_android", return_value=False),
patch.object(
ReticulumMeshChat,
"_probe_host_audio_unavailable",
return_value=True,
),
):
assert app.web_audio_required() is True
def test_oracle_meshchat_web_audio_required_env(monkeypatch):
from meshchatx.meshchat import ReticulumMeshChat
app = ReticulumMeshChat.__new__(ReticulumMeshChat)
app._headless = False
app._host_audio_unavailable_cached = False
monkeypatch.setenv("MESHCHAT_FORCE_WEB_AUDIO", "1")
with patch("meshchatx.meshchat._is_chaquopy_android", return_value=False):
assert app.web_audio_required() is True
monkeypatch.delenv("MESHCHAT_FORCE_WEB_AUDIO", raising=False)
def test_oracle_meshchat_web_audio_not_required_when_host_audio_ok():
from meshchatx.meshchat import ReticulumMeshChat
app = ReticulumMeshChat.__new__(ReticulumMeshChat)
app._headless = True
app._host_audio_unavailable_cached = False
os.environ.pop("MESHCHAT_FORCE_WEB_AUDIO", None)
with patch("meshchatx.meshchat._is_chaquopy_android", return_value=False):
assert app.web_audio_required() is False
def test_oracle_headless_alone_does_not_force_web_audio():
"""Frozen Electron uses --headless but still has host speakers/mic."""
from meshchatx.meshchat import ReticulumMeshChat
app = ReticulumMeshChat.__new__(ReticulumMeshChat)
app._headless = True
app._host_audio_unavailable_cached = None
os.environ.pop("MESHCHAT_FORCE_WEB_AUDIO", None)
with (
patch("meshchatx.meshchat._is_chaquopy_android", return_value=False),
patch.object(
ReticulumMeshChat,
"_probe_host_audio_unavailable",
return_value=False,
),
):
assert app.web_audio_required() is False
# ---------------------------------------------------------------------------
# Android codec2 candidate discovery
# ---------------------------------------------------------------------------
def test_android_codec2_honours_explicit_path(tmp_path, monkeypatch):
from meshchatx import android_codec2
lib = tmp_path / "libcodec2.so"
lib.write_bytes(b"\x7fELF")
monkeypatch.setenv("MESHCHAT_LIBCODEC2_PATH", str(lib))
candidates = android_codec2._libcodec2_candidates()
assert lib.resolve() in [c.resolve() for c in candidates]
def test_android_codec2_native_lib_dir(tmp_path, monkeypatch):
from meshchatx import android_codec2
native = tmp_path / "arm64-v8a"
native.mkdir()
lib = native / "libcodec2.so"
lib.write_bytes(b"\x7fELF")
monkeypatch.setenv("MESHCHAT_NATIVE_LIB_DIR", str(native))
candidates = android_codec2._libcodec2_candidates()
assert lib.resolve() in [c.resolve() for c in candidates]
# ---------------------------------------------------------------------------
# Bridge push through hostless audio_input before WebAudioSource swap
# ---------------------------------------------------------------------------
def test_push_client_frame_uses_hostless_audio_input_before_swap():
tele = MagicMock()
tele.active_call = object()
hostless = HostlessAudioSource(target_frame_ms=60, sink=MagicMock())
hostless.sink.can_receive.return_value = True
tele.audio_input = hostless
tele_mgr = MagicMock()
tele_mgr.telephone = tele
tele_mgr.is_voicemail_session_active = False
bridge = WebAudioBridge(tele_mgr, MagicMock(), force_enabled=True)
bridge.tx_source = None
pcm = np.zeros(80, dtype=np.int16).tobytes()
bridge.push_client_frame(pcm)
hostless.sink.handle_frame.assert_called()

View file

@ -0,0 +1,413 @@
# SPDX-License-Identifier: 0BSD
"""Guards, races, and adversarial cases for phased / early-UI startup."""
from __future__ import annotations
import json
import shutil
import tempfile
import threading
import time
from pathlib import Path
from unittest.mock import MagicMock, patch
import pytest
import RNS
from hypothesis import given, settings
from hypothesis import strategies as st
from meshchatx.meshchat import ReticulumMeshChat
from meshchatx.src.backend.identity_context import IdentityContext
from meshchatx.src.backend.integrity_manager import (
IntegrityManager,
select_critical_integrity_issues,
)
from tests.backend.api_json_contract_schemas import (
API_V1_STATUS_SCHEMA,
assert_matches_schema,
)
@pytest.fixture
def temp_dir():
dir_path = tempfile.mkdtemp()
yield dir_path
shutil.rmtree(dir_path)
@pytest.fixture
def mock_identity():
identity = MagicMock(spec=RNS.Identity)
identity.hash = b"test_hash_32_bytes_long_01234567"
identity.hexhash = identity.hash.hex()
return identity
def _make_deferred_app(mock_identity, temp_dir, **kwargs):
with (
patch("meshchatx.meshchat.AsyncUtils.ensure_background_loop"),
patch.object(ReticulumMeshChat, "setup_identity"),
):
return ReticulumMeshChat(
identity=mock_identity,
storage_dir=temp_dir,
reticulum_config_dir=temp_dir,
defer_network_setup=True,
**kwargs,
)
def _status_oracle(payload: dict) -> None:
"""Invariant oracle for /api/v1/status style payloads."""
assert_matches_schema(payload, API_V1_STATUS_SCHEMA)
assert payload["network_ready"] is False or payload["status"] == "ok"
if payload["status"] == "ok":
assert payload["network_ready"] is True
assert payload["ui_ready"] is True
assert payload["network_degraded"] is False
if payload["status"] == "failed":
assert payload["network_ready"] is False
assert payload["ui_ready"] is True
assert payload["network_degraded"] is True
if payload["status"] == "starting":
assert payload["network_ready"] is False
assert payload["ui_ready"] is True
def test_deferred_init_ui_ready_before_network(mock_identity, temp_dir):
app = _make_deferred_app(mock_identity, temp_dir)
payload = app._startup_status_payload()
_status_oracle(payload)
assert payload["ui_ready"] is True
assert payload["network_ready"] is False
def test_status_oracle_across_stage_transitions(mock_identity, temp_dir):
app = _make_deferred_app(mock_identity, temp_dir)
for stage in ("http", "starting", "rns", "identity"):
app._set_startup_stage(stage)
_status_oracle(app._startup_status_payload())
app._mark_network_degraded("boom")
_status_oracle(app._startup_status_payload())
context = MagicMock()
context.running = True
app.current_context = context
app._mark_network_ready()
_status_oracle(app._startup_status_payload())
def test_finish_deferred_calls_context_and_reticulum_secondary(mock_identity, temp_dir):
app = _make_deferred_app(mock_identity, temp_dir)
ctx = MagicMock()
ctx.running = True
app.current_context = ctx
app._reticulum_secondary_started = False
with patch.object(app, "_start_deferred_reticulum_services") as start_rns:
app._finish_deferred_startup_services()
ctx.setup_deferred_services.assert_called_once_with()
start_rns.assert_called_once_with()
def test_require_rns_tool_handler_503_while_deferred_pending(mock_identity, temp_dir):
app = _make_deferred_app(mock_identity, temp_dir)
ctx = MagicMock()
ctx.running = True
app.current_context = ctx
app._mark_network_ready()
response = app._require_rns_tool_handler(None, "RNCP")
assert response is not None
assert response.status == 503
body = json.loads(response.body)
assert "RNCP" in body["message"]
assert body["network_ready"] is True
def test_deferred_setup_idempotent_under_concurrent_calls(temp_dir):
app = MagicMock()
app.emergency = False
app.storage_dir = temp_dir
app.reticulum_config_dir = temp_dir
app.get_public_path = MagicMock(return_value=temp_dir)
app.integrity_issues = []
app.database_health_issues = []
identity = MagicMock()
identity.hash = b"abcdef0123456789abcdef0123456789"
ctx = IdentityContext.__new__(IdentityContext)
ctx.app = app
ctx.identity = identity
ctx.identity_hash = identity.hash.hex()
ctx.storage_path = temp_dir
ctx.database_path = str(Path(temp_dir) / "database.db")
ctx.running = True
ctx._deferred_setup_done = False
ctx._deferred_setup_lock = threading.Lock()
ctx._deferred_setup_in_progress = False
ctx._deferred_setup_finished = threading.Event()
ctx._deferred_setup_finished.set()
ctx.config = MagicMock()
ctx.config.rrc_enabled.get.return_value = False
ctx.config.libretranslate_url.get.return_value = ""
ctx.config.libretranslate_api_key.get.return_value = ""
ctx.config.translator_argos_enabled.get.return_value = False
ctx.config.translator_libretranslate_enabled.get.return_value = False
ctx.database = MagicMock()
ctx.docs_manager = MagicMock()
ctx.integrity_manager = MagicMock()
ctx.integrity_manager.check_integrity.return_value = (True, [])
calls = []
gate = threading.Event()
entered = threading.Event()
def slow_body():
calls.append("body")
entered.set()
gate.wait(timeout=2)
ctx._run_deferred_services_body = slow_body
threads = [threading.Thread(target=ctx.setup_deferred_services) for _ in range(8)]
for thread in threads:
thread.start()
assert entered.wait(timeout=2)
gate.set()
for thread in threads:
thread.join(timeout=2)
assert len(calls) == 1
assert ctx._deferred_setup_done is True
assert ctx._deferred_setup_in_progress is False
def test_deferred_setup_aborts_when_torn_down_mid_flight(temp_dir):
app = MagicMock()
app.emergency = True
app.storage_dir = temp_dir
app.get_public_path = MagicMock(return_value=temp_dir)
identity = MagicMock()
identity.hash = b"abcdef0123456789abcdef0123456789"
ctx = IdentityContext.__new__(IdentityContext)
ctx.app = app
ctx.identity = identity
ctx.identity_hash = identity.hash.hex()
ctx.storage_path = temp_dir
ctx.running = True
ctx._deferred_setup_done = False
ctx._deferred_setup_lock = threading.Lock()
ctx._deferred_setup_in_progress = False
ctx._deferred_setup_finished = threading.Event()
ctx._deferred_setup_finished.set()
ctx.rncp_handler = None
started = threading.Event()
release = threading.Event()
def body():
started.set()
release.wait(timeout=2)
if not ctx.running:
return
ctx.rncp_handler = MagicMock(name="zombie-handler")
ctx._run_deferred_services_body = body
worker = threading.Thread(target=ctx.setup_deferred_services)
worker.start()
assert started.wait(timeout=2)
ctx.running = False
release.set()
worker.join(timeout=2)
assert ctx._deferred_setup_done is False
assert ctx.rncp_handler is None
def test_teardown_waits_for_in_flight_deferred_setup(temp_dir):
ctx = IdentityContext.__new__(IdentityContext)
ctx.identity_hash = "abc"
ctx.running = True
ctx._deferred_setup_done = False
ctx._deferred_setup_lock = threading.Lock()
ctx._deferred_setup_in_progress = False
ctx._deferred_setup_finished = threading.Event()
ctx._deferred_setup_finished.set()
started = threading.Event()
release = threading.Event()
def body():
started.set()
release.wait(timeout=2)
ctx._run_deferred_services_body = body
worker = threading.Thread(target=ctx.setup_deferred_services)
worker.start()
assert started.wait(timeout=2)
assert ctx._deferred_setup_finished.is_set() is False
waiter_done = threading.Event()
def wait_like_teardown():
ctx.running = False
assert ctx._deferred_setup_finished.wait(timeout=2)
waiter_done.set()
waiter = threading.Thread(target=wait_like_teardown)
waiter.start()
time.sleep(0.05)
assert waiter_done.is_set() is False
release.set()
waiter.join(timeout=2)
worker.join(timeout=2)
assert waiter_done.is_set()
assert ctx._deferred_setup_in_progress is False
assert ctx._deferred_setup_done is False
def test_critical_integrity_blocks_on_identity_file_tamper(temp_dir):
storage = Path(temp_dir)
db_path = storage / "database.db"
db_path.write_bytes(b"ok-db")
identity_path = storage / "identity"
identity_path.write_bytes(b"secret-key-material")
manager = IntegrityManager(str(storage), str(db_path), identity_hash="abc123")
manager.save_manifest()
identity_path.write_bytes(b"TAMPERED")
is_ok, issues = manager.check_integrity(critical_only=True)
assert is_ok is False
critical = select_critical_integrity_issues(issues)
assert critical
assert any("identity" in issue.lower() for issue in critical)
def test_critical_integrity_ignores_non_critical_new_files(temp_dir):
storage = Path(temp_dir)
db_path = storage / "database.db"
db_path.write_bytes(b"ok-db")
(storage / "identity").write_bytes(b"secret")
manager = IntegrityManager(str(storage), str(db_path), identity_hash="abc123")
manager.save_manifest()
(storage / "notes.txt").write_text("benign", encoding="utf-8")
is_ok_critical, issues_critical = manager.check_integrity(critical_only=True)
assert is_ok_critical is True or not select_critical_integrity_issues(
issues_critical
)
is_ok_full, issues_full = manager.check_integrity(critical_only=False)
assert is_ok_full is False
assert any("New file detected" in issue for issue in issues_full)
def test_critical_integrity_identity_mismatch_still_raises_marker(temp_dir):
storage = Path(temp_dir)
db_path = storage / "database.db"
db_path.write_bytes(b"ok-db")
(storage / "identity").write_bytes(b"secret")
manager = IntegrityManager(str(storage), str(db_path), identity_hash="original")
manager.save_manifest()
other = IntegrityManager(str(storage), str(db_path), identity_hash="other-hash")
is_ok, issues = other.check_integrity(critical_only=True)
assert is_ok is False
assert select_critical_integrity_issues(issues)
@given(
stage=st.sampled_from(["http", "starting", "rns", "identity", "ready"]),
ui_ready=st.booleans(),
network_ready=st.booleans(),
degraded=st.booleans(),
)
@settings(max_examples=40, deadline=None)
def test_status_payload_never_claims_ready_without_context(
stage,
ui_ready,
network_ready,
degraded,
):
dir_path = tempfile.mkdtemp()
try:
identity = MagicMock(spec=RNS.Identity)
identity.hash = b"test_hash_32_bytes_long_01234567"
identity.hexhash = identity.hash.hex()
app = _make_deferred_app(identity, dir_path)
app._startup_stage = stage
app._ui_ready = ui_ready
app._network_ready = network_ready
app._network_degraded = degraded
if degraded:
app._startup_error = "x"
app._startup_stage = "failed"
payload = app._startup_status_payload()
if payload["status"] == "ok":
assert app.current_context is not None and app.current_context.running
assert payload["network_ready"] is True
if payload["network_ready"] is True:
assert payload["status"] == "ok"
assert_matches_schema(payload, API_V1_STATUS_SCHEMA)
finally:
shutil.rmtree(dir_path)
def test_background_setup_marks_ready_before_deferred_finishes(mock_identity, temp_dir):
ready_seen = threading.Event()
deferred_started = threading.Event()
release_deferred = threading.Event()
def fake_setup(self, identity):
context = MagicMock()
context.running = True
context.config = MagicMock()
context.config.auth_session_secret.set = MagicMock()
def deferred():
deferred_started.set()
release_deferred.wait(timeout=2)
context.setup_deferred_services = deferred
self.current_context = context
ready_seen.set()
with (
patch("meshchatx.meshchat.AsyncUtils.ensure_background_loop"),
patch("meshchatx.meshchat.AsyncUtils.run_async"),
patch.object(ReticulumMeshChat, "setup_identity", fake_setup),
patch.object(ReticulumMeshChat, "_start_deferred_reticulum_services"),
):
app = ReticulumMeshChat(
identity=mock_identity,
storage_dir=temp_dir,
reticulum_config_dir=temp_dir,
defer_network_setup=True,
)
app.session_secret_key = "secret"
app.start_network_setup_in_background()
assert ready_seen.wait(timeout=5)
assert app.wait_until_network_ready(timeout=5)
payload = app._startup_status_payload()
assert payload["network_ready"] is True
assert deferred_started.wait(timeout=2)
# Mesh is already advertised ready while deferred is still blocked.
assert release_deferred.is_set() is False
release_deferred.set()
def test_reticulum_secondary_idempotent(mock_identity, temp_dir):
app = _make_deferred_app(mock_identity, temp_dir)
app.reticulum = MagicMock()
app.plugins_enabled = False
app.page_node_manager = MagicMock()
app.page_node_manager.nodes = {}
app.sideband_plugin_loader = MagicMock()
app._start_deferred_reticulum_services()
app._start_deferred_reticulum_services()
app.page_node_manager.start_all.assert_called_once()

View file

@ -5,6 +5,8 @@ import shutil
from unittest.mock import MagicMock, patch
import pytest
from hypothesis import HealthCheck, given, settings
from hypothesis import strategies as st
from meshchatx.src.backend.rncp_handler import RNCPHandler
@ -289,6 +291,33 @@ def test_fetch_request_jail_serves_file_inside_jail(rncp_handler, tmp_path):
assert _CapturingResource.served_path == str(shared)
def test_fetch_request_jail_blocks_null_byte(rncp_handler, tmp_path):
"""Null bytes in fetch paths are denied, not raised."""
jail = tmp_path / "share"
jail.mkdir()
rncp_handler.fetch_jail = str(jail)
_CapturingResource.served_path = None
link = _FakeLink(link_id=b"link-null")
with (
patch("meshchatx.src.backend.rncp_handler.RNS.Transport") as transport,
patch(
"meshchatx.src.backend.rncp_handler.RNS.Resource",
_CapturingResource,
),
):
transport.active_links = [link]
result = rncp_handler._fetch_request(
path="fetch_file",
data="\0../x",
request_id=None,
link_id=link.link_id,
remote_identity=None,
requested_at=0,
)
assert result == RNCPHandler.REQ_FETCH_NOT_ALLOWED
assert _CapturingResource.served_path is None
@patch("meshchatx.src.backend.rncp_handler.RNS.Identity")
@patch("meshchatx.src.backend.rncp_handler.RNS.Destination")
@patch("meshchatx.src.backend.rncp_handler.RNS.Reticulum")
@ -363,3 +392,106 @@ def test_fetch_resource_concluded_sets_resolved_on_save_error(rncp_handler, tmp_
assert resource_status["value"] == "error"
assert save_error["value"]
os.chmod(effective_save_path, 0o700)
_TRAVERSAL_PATHS = (
"../etc/passwd",
"../../host_secret.key",
"/etc/passwd",
"....//....//evil",
"share/../../outside.txt",
"\0../x",
)
@settings(
max_examples=80,
deadline=None,
suppress_health_check=[HealthCheck.function_scoped_fixture],
)
@given(
data=st.one_of(
st.sampled_from(_TRAVERSAL_PATHS),
st.text(min_size=0, max_size=200),
),
)
def test_fetch_request_path_oracle(rncp_handler, tmp_path, data):
"""Fetch never serves outside the jail. Success only for files inside it."""
jail = tmp_path / "share"
jail.mkdir(exist_ok=True)
inside = jail / "ok.txt"
if not inside.exists():
inside.write_text("ok")
outside = tmp_path / "host_secret.key"
if not outside.exists():
outside.write_text("PRIVATE")
rncp_handler.fetch_jail = str(jail)
_CapturingResource.served_path = None
link = _FakeLink(link_id=b"link-oracle")
with (
patch("meshchatx.src.backend.rncp_handler.RNS.Transport") as transport,
patch(
"meshchatx.src.backend.rncp_handler.RNS.Resource",
_CapturingResource,
),
):
transport.active_links = [link]
result = rncp_handler._fetch_request(
path="fetch_file",
data=data,
request_id=None,
link_id=link.link_id,
remote_identity=None,
requested_at=0,
)
assert result in (
True,
False,
None,
RNCPHandler.REQ_FETCH_NOT_ALLOWED,
)
jail_real = os.path.realpath(str(jail))
outside_real = os.path.realpath(str(outside))
if result is True:
served = _CapturingResource.served_path
assert served is not None
served_real = os.path.realpath(served)
assert served_real == jail_real or served_real.startswith(jail_real + os.sep)
assert served_real != outside_real
else:
assert _CapturingResource.served_path is None
@settings(
max_examples=80,
deadline=None,
suppress_health_check=[HealthCheck.function_scoped_fixture],
)
@given(
file_path=st.one_of(
st.sampled_from(_TRAVERSAL_PATHS + ("identity", "identity.bak", "")),
st.text(min_size=0, max_size=200),
),
)
def test_resolve_send_path_oracle(rncp_handler, file_path):
"""Resolved send paths stay under storage or home and never expose identity keys."""
storage = os.path.realpath(rncp_handler.storage_dir)
home = os.path.expanduser("~")
home_real = os.path.realpath(home) if home and home != "~" else None
try:
real = rncp_handler._resolve_send_path(file_path)
except (ValueError, PermissionError, FileNotFoundError, OSError, TypeError):
return
assert isinstance(real, str)
real = os.path.realpath(real)
under_storage = real == storage or real.startswith(storage + os.sep)
under_home = home_real is not None and (
real == home_real or real.startswith(home_real + os.sep)
)
assert under_storage or under_home
assert os.path.basename(real) not in {"identity", "identity.bak"}
parts = {part for part in real.split(os.sep) if part}
assert not (parts & {".ssh", ".gnupg"})
assert os.path.isfile(real)

View file

@ -36,11 +36,12 @@ def test_default_status_not_running(handler, mock_identity):
def test_update_settings_persists_sync_directory(handler):
result = handler.update_settings(sync_directory="/tmp/filesync-alt", monitor=False)
nested = f"{handler.storage_dir}/filesync/alt"
result = handler.update_settings(sync_directory=nested, monitor=False)
assert result["ok"] is True
assert result["monitor"] is False
status = handler.get_status()
assert status["sync_directory"] == "/tmp/filesync-alt"
assert status["sync_directory"] == nested
assert status["monitor"] is False

View file

@ -1,6 +1,6 @@
# SPDX-License-Identifier: 0BSD
"""Adversarial and Hypothesis fuzz coverage for RNS FileSync handler."""
"""Adversarial regression and Hypothesis fuzz coverage for RNS FileSync."""
from __future__ import annotations
@ -14,7 +14,6 @@ from hypothesis import strategies as st
from meshchatx.src.backend.rns_filesync_handler import RnsFilesyncHandler
from rns_filesync.paths import PathJailError, normalize_relpath
from rns_filesync.permissions import PermissionStore
_TRAVERSAL_PAYLOADS = (
"../etc/passwd",
@ -45,6 +44,7 @@ _BAD_HASHES = (
"' OR '1'='1",
"../../identity",
"а" * 32,
"all",
)
@ -66,16 +66,22 @@ def test_path_traversal_payloads_rejected_by_normalize():
normalize_relpath(payload)
def test_download_rejects_path_traversal(handler):
handler.service = MagicMock()
handler.service.download_file.side_effect = lambda peer, path: {
"ok": False,
"error": f"path jail: {path}",
}
def test_download_rejects_path_traversal_before_service(handler):
"""Handler must jail paths itself, not wait for peer connectivity."""
service = MagicMock()
handler.service = service
for payload in _TRAVERSAL_PAYLOADS:
if not str(payload).strip():
result = handler.download_file("bb" * 16, payload)
assert result["ok"] is False
assert result["error"] == "path is required"
service.download_file.assert_not_called()
continue
service.reset_mock()
result = handler.download_file("bb" * 16, payload)
assert result["ok"] is False
assert result["ok"] is False, payload
assert "error" in result
service.download_file.assert_not_called()
def test_download_and_browse_require_running(handler):
@ -85,63 +91,89 @@ def test_download_and_browse_require_running(handler):
@pytest.mark.parametrize("bad_hash", _BAD_HASHES)
def test_connect_rejects_or_handles_bad_hashes(handler, bad_hash):
def test_connect_rejects_bad_hashes(handler, bad_hash):
handler.service = MagicMock()
handler.service.connect_peer.return_value = {"ok": False, "error": "bad peer"}
result = handler.connect_peer(bad_hash)
assert isinstance(result, dict)
assert "ok" in result
if not str(bad_hash).strip():
assert result["ok"] is False
assert result["ok"] is False
assert "invalid" in result["error"] or "required" in result["error"]
handler.service.connect_peer.assert_not_called()
@pytest.mark.parametrize("bad_hash", _BAD_HASHES)
def test_acl_grant_handles_bad_hashes(handler, bad_hash):
@pytest.mark.parametrize("bad_hash", [h for h in _BAD_HASHES if h != "all"])
def test_acl_grant_rejects_bad_hashes(handler, bad_hash):
result = handler.update_acl(
identity_hash=bad_hash,
perms=["read"],
enforce=True,
)
assert isinstance(result, dict)
assert "ok" in result
def test_acl_enforce_denies_stranger_by_default_rules(handler):
peer = "cc" * 16
stranger = "dd" * 16
handler.update_acl(identity_hash=peer, perms=["read"], enforce=True)
assert result["ok"] is False
assert "invalid" in result["error"]
acl = handler.get_acl()
assert acl["enforce"] is True
perms = PermissionStore()
for rule_perm, targets in acl["rules"].items():
for target in targets:
short = {"read": "r", "write": "w", "delete": "d"}[rule_perm]
perms.add_rule(f"{short}:{target}")
assert perms.check(peer, "read") is True
assert perms.check(stranger, "read") is False
assert perms.check(stranger, "write") is False
assert bad_hash not in acl["rules"].get("read", [])
assert "../escape" not in acl["rules"].get("read", [])
def test_acl_rules_text_replace_does_not_leak_old_rules(handler):
peer_a = "11" * 16
peer_b = "22" * 16
handler.update_acl(identity_hash=peer_a, perms=["read", "write"], enforce=True)
handler.update_acl(
rules_text=f"r:{peer_b}",
replace=True,
enforce=True,
)
acl = handler.get_acl()
assert peer_a not in acl["rules"].get("read", [])
assert peer_b in acl["rules"].get("read", [])
assert peer_a not in acl["rules"].get("write", [])
def test_oversized_rules_text_does_not_raise(handler):
huge = ("r:" + ("ab" * 16) + "\n") * 5000
result = handler.update_acl(rules_text=huge, replace=True, enforce=True)
def test_acl_grant_accepts_all_alias(handler):
result = handler.update_acl(identity_hash="all", perms=["read"], enforce=True)
assert result["ok"] is True
assert isinstance(result["rules"], dict)
assert "all" in result["rules"]["read"]
assert handler.get_acl()["rules"]["read"] == ["all"]
def test_acl_enforce_false_persists_across_reload(tmp_path):
storage = tmp_path / "id"
storage.mkdir()
identity = SimpleNamespace(hash=b"\xaa" * 16)
peer = "cc" * 16
first = RnsFilesyncHandler(MagicMock(), identity, str(storage))
first.update_acl(identity_hash=peer, perms=["read"], enforce=True)
disabled = first.update_acl(enforce=False)
assert disabled["ok"] is True
assert disabled["enforce"] is False
assert first.get_acl()["enforce"] is False
assert peer in first.get_acl()["rules"]["read"]
second = RnsFilesyncHandler(MagicMock(), identity, str(storage))
acl = second.get_acl()
assert acl["enforce"] is False
assert peer in acl["rules"]["read"]
def test_acl_get_matches_update_result(handler):
peer = "dd" * 16
updated = handler.update_acl(
identity_hash=peer, perms=["read", "write"], enforce=True
)
fetched = handler.get_acl()
assert updated["enforce"] is True
assert fetched["enforce"] is True
assert fetched["rules"] == updated["rules"]
assert peer in fetched["rules"]["read"]
assert peer in fetched["rules"]["write"]
def test_sync_directory_cannot_escape_identity_storage(handler, tmp_path):
outside = tmp_path / "other_identity" / "filesync" / "sync"
outside.mkdir(parents=True)
result = handler.update_settings(sync_directory=str(outside))
assert result["ok"] is False
assert "identity storage" in result["error"]
nested = handler.storage_dir + "/filesync/custom"
ok = handler.update_settings(sync_directory=nested)
assert ok["ok"] is True
assert ok["sync_directory"] == nested or ok["sync_directory"].endswith(
"/filesync/custom",
)
def test_start_rejects_escaped_sync_directory(handler, tmp_path):
outside = tmp_path / "escape_me"
outside.mkdir()
with patch("meshchatx.src.backend.rns_filesync_handler.FileSyncService") as mocked:
result = handler.start(sync_directory=str(outside))
assert result["ok"] is False
mocked.assert_not_called()
def test_teardown_clears_service_and_isolates_storage(tmp_path):
@ -164,14 +196,11 @@ def test_teardown_clears_service_and_isolates_storage(tmp_path):
assert ha.service is None
svc.stop.assert_called()
assert "read" in ha.get_acl()["rules"]
assert peer in ha.get_acl()["rules"]["read"]
assert "write" in hb.get_acl()["rules"]
assert peer in hb.get_acl()["rules"]["write"]
assert peer not in ha.get_acl()["rules"].get("write", [])
assert (storage_a / "filesync" / "acl.txt").is_file()
assert (storage_b / "filesync" / "acl.txt").is_file()
assert storage_a.resolve() != storage_b.resolve()
def test_concurrent_acl_mutations_stable(handler):
@ -187,8 +216,8 @@ def test_concurrent_acl_mutations_stable(handler):
enforce=True,
)
acl = handler.get_acl()
assert isinstance(acl["rules"], dict)
assert acl["enforce"] is True
assert peer in acl["rules"]["read"] or peer in acl["rules"]["write"]
except BaseException as exc:
errors.append(exc)
@ -198,34 +227,33 @@ def test_concurrent_acl_mutations_stable(handler):
for t in threads:
t.join(timeout=10)
assert not errors
final = handler.get_acl()
assert final["enforce"] is True
@settings(
max_examples=40,
max_examples=50,
deadline=None,
suppress_health_check=[HealthCheck.too_slow, HealthCheck.function_scoped_fixture],
)
@given(
path=st.text(
alphabet=st.characters(
whitelist_categories=("L", "N", "P", "S", "Z", "Cc"),
),
min_size=0,
max_size=400,
),
)
def test_download_path_fuzz_never_raises(handler, path):
handler.service = MagicMock()
handler.service.download_file.side_effect = lambda peer, p: {
"ok": False,
"error": "denied",
"path": p,
}
@given(path=st.sampled_from(list(_TRAVERSAL_PAYLOADS) + ["ok.txt", "dir/file.bin"]))
def test_download_path_oracle(handler, path):
service = MagicMock()
service.download_file.return_value = {"ok": True, "path": path}
handler.service = service
result = handler.download_file("aa" * 16, path)
assert isinstance(result, dict)
assert "ok" in result
if not str(path).strip():
assert result["ok"] is False
service.download_file.assert_not_called()
return
try:
normalize_relpath(path)
except PathJailError:
assert result["ok"] is False
service.download_file.assert_not_called()
else:
assert result["ok"] is True
service.download_file.assert_called_once()
@settings(
@ -235,35 +263,36 @@ def test_download_path_fuzz_never_raises(handler, path):
)
@given(
identity_hash=st.one_of(
st.text(min_size=0, max_size=80),
st.binary(min_size=0, max_size=40).map(lambda b: b.hex()),
st.sampled_from(list(_BAD_HASHES)),
st.from_regex(r"[0-9a-f]{32}", fullmatch=True),
st.text(min_size=0, max_size=40),
),
perms=st.lists(
st.sampled_from(["read", "write", "delete", "admin", "nope", ""]),
max_size=6,
),
enforce=st.booleans(),
rules_text=st.one_of(st.none(), st.text(max_size=500)),
replace=st.booleans(),
)
def test_acl_update_fuzz_never_raises(
handler,
identity_hash,
perms,
enforce,
rules_text,
replace,
):
def test_acl_hash_oracle(handler, identity_hash, perms):
effective_perms = perms if perms else ["read"]
result = handler.update_acl(
identity_hash=identity_hash,
perms=perms,
enforce=enforce,
rules_text=rules_text,
replace=replace,
perms=effective_perms,
enforce=True,
)
assert isinstance(result, dict)
assert "ok" in result
cleaned = str(identity_hash or "").strip().lower().replace(":", "")
valid = cleaned == "all" or (
len(cleaned) == 32 and all(c in "0123456789abcdef" for c in cleaned)
)
if not valid:
assert result["ok"] is False
return
if not any(p in ("read", "write", "delete") for p in effective_perms):
assert result["ok"] is False
return
assert result["ok"] is True
assert result["rules"] == handler.get_acl()["rules"]
@settings(
@ -272,39 +301,26 @@ def test_acl_update_fuzz_never_raises(
suppress_health_check=[HealthCheck.too_slow, HealthCheck.function_scoped_fixture],
)
@given(
sync_directory=st.one_of(
st.none(),
st.text(min_size=0, max_size=200),
st.sampled_from(["", " ", "../escape", "/tmp/x", "~/.ssh"]),
),
monitor=st.one_of(st.none(), st.booleans()),
announce_interval=st.one_of(
st.none(),
st.integers(min_value=-10, max_value=10_000),
st.text(max_size=20),
sync_directory=st.sampled_from(
[
"",
" ",
"../escape",
"/tmp/x",
"~/.ssh",
"filesync/nested",
"filesync/../filesync/ok",
],
),
)
def test_settings_fuzz_never_raises(handler, sync_directory, monitor, announce_interval):
result = handler.update_settings(
sync_directory=sync_directory,
monitor=monitor,
announce_interval=announce_interval,
)
def test_settings_path_oracle(handler, sync_directory):
before = handler.get_status()["sync_directory"]
result = handler.update_settings(sync_directory=sync_directory)
assert isinstance(result, dict)
assert "ok" in result
@settings(
max_examples=25,
deadline=None,
suppress_health_check=[HealthCheck.too_slow, HealthCheck.function_scoped_fixture],
)
@given(peer_id=st.text(min_size=0, max_size=100))
def test_disconnect_fuzz_never_raises(handler, peer_id):
handler.service = MagicMock()
result = handler.disconnect_peer(peer_id)
assert isinstance(result, dict)
assert "ok" in result
if result["ok"]:
assert result["sync_directory"].startswith(handler.storage_dir)
else:
assert handler.get_status()["sync_directory"] == before
@patch("meshchatx.src.backend.rns_filesync_handler.FileSyncService")

View file

@ -32,13 +32,19 @@ def _envelope_dict_strategy():
@given(data=st.binary(min_size=0, max_size=4096))
@settings(max_examples=200, deadline=None)
def test_decode_random_bytes_never_raises_unexpected(data):
def test_decode_random_bytes_accept_reject_oracle(data):
try:
obj = proto.decode(data)
except Exception as exc:
assert isinstance(exc, (cbor2.CBORDecodeError, ValueError, TypeError, EOFError))
return
assert obj is not None or obj is None
# Successful decode yields a value. Prefer round-trip when CBOR can encode it.
try:
encoded = cbor2.dumps(obj)
except cbor2.CBOREncodeError:
return
assert isinstance(encoded, (bytes, bytearray))
assert proto.decode(encoded) == obj
@given(env=_envelope_dict_strategy())
@ -58,7 +64,7 @@ def test_encode_decode_roundtrip_dict(env):
nick=st.one_of(st.none(), st.text(max_size=64)),
)
@settings(max_examples=120, deadline=None)
def test_make_envelope_encode_decode_never_raises(msg_type, src, room, body, nick):
def test_make_envelope_encode_decode_roundtrip(msg_type, src, room, body, nick):
env = proto.make_envelope(msg_type, src, room=room, body=body, nick=nick)
decoded = proto.decode(proto.encode(env))
assert decoded[proto.K_T] == int(msg_type)
@ -67,12 +73,15 @@ def test_make_envelope_encode_decode_never_raises(msg_type, src, room, body, nic
@given(nick=st.text(max_size=128))
@settings(max_examples=100, deadline=None)
def test_normalize_nick_never_raises(nick):
def test_normalize_nick_shape_oracle(nick):
result = proto.normalize_nick(nick)
if result is not None:
assert isinstance(result, str)
assert result.strip() == result
assert len(result.encode("utf-8")) <= proto.DEFAULT_MAX_NICK_BYTES
stripped = (nick or "").strip()
if not stripped:
assert result is None
@given(nick=st.text(max_size=128), max_bytes=st.integers(min_value=1, max_value=256))
@ -96,14 +105,17 @@ def test_normalize_room_only_raises_on_empty_or_whitespace(room):
@given(text=st.text(max_size=256), nick=st.text(max_size=64))
@settings(max_examples=120, deadline=None)
def test_text_mentions_never_raises(text, nick):
def test_text_mentions_bool_oracle(text, nick):
result = proto.text_mentions(text, nick)
assert isinstance(result, bool)
n = proto.normalize_nick(nick)
if n is None:
assert result is False
@given(text=st.one_of(st.none(), st.integers(), st.binary(), st.text(max_size=256)))
@settings(max_examples=80, deadline=None)
def test_parse_who_notice_never_raises(text):
def test_parse_who_notice_shape_oracle(text):
result = proto.parse_who_notice(text)
if result is not None:
room, entries = result
@ -114,7 +126,7 @@ def test_parse_who_notice_never_raises(text):
@given(text=st.one_of(st.none(), st.integers(), st.binary(), st.text(max_size=512)))
@settings(max_examples=80, deadline=None)
def test_parse_room_list_notice_never_raises(text):
def test_parse_room_list_notice_shape_oracle(text):
result = proto.parse_room_list_notice(text)
if result is not None:
assert isinstance(result, dict)
@ -133,19 +145,18 @@ def test_parse_room_list_notice_never_raises(text):
ts=st.one_of(st.integers(), st.floats(allow_nan=False), st.text(max_size=16)),
)
@settings(max_examples=100, deadline=None)
def test_rrc_message_to_dict_never_raises(kind, room, src, nick, text, ts):
def test_rrc_message_to_dict_shape_oracle(kind, room, src, nick, text, ts):
msg = proto.RRCMessage(kind, room, src, nick, text, ts)
d = msg.to_dict()
assert isinstance(d, dict)
assert "kind" in d
assert "text" in d
assert d["kind"] == kind
assert isinstance(d["text"], str)
assert isinstance(d["ts"], int)
@given(app_data=st.one_of(st.none(), st.text(max_size=256), st.binary(max_size=128)))
@settings(max_examples=100, deadline=None)
def test_display_name_from_hub_app_data_never_raises(app_data):
def test_display_name_from_hub_app_data_shape_oracle(app_data):
if isinstance(app_data, bytes):
b64 = base64.b64encode(app_data).decode("ascii")
elif isinstance(app_data, str):

View file

@ -3,7 +3,6 @@
import base64
import math
import os
import time
from contextlib import ExitStack
from unittest.mock import MagicMock, patch
@ -1116,63 +1115,16 @@ def test_archive_page_content_fuzzing(mock_app, destination_hash, page_path, con
mock_app.archiver_manager.get_archived_page_versions(destination_hash, page_path)
@settings(suppress_health_check=[HealthCheck.function_scoped_fixture], deadline=None)
@given(
file_path=st.text(min_size=0, max_size=1000),
)
def test_rncp_file_path_traversal_fuzzing(mock_app, file_path):
"""Fuzz RNCP file path handling for directory traversal."""
try:
import asyncio
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
loop.run_until_complete(
mock_app.rncp_handler.send_file(
os.urandom(16),
file_path,
timeout=1.0,
),
)
except Exception:
pass
@settings(suppress_health_check=[HealthCheck.function_scoped_fixture], deadline=None)
@given(
path=st.text(min_size=0, max_size=1000),
data=st.one_of(st.text(), st.binary()),
request_id=st.one_of(st.integers(), st.text()),
)
def test_rncp_fetch_request_path_fuzzing(mock_app, path, data, request_id):
"""Fuzz RNCP fetch request path handling."""
try:
mock_identity = MagicMock()
mock_identity.hash = os.urandom(16)
mock_app.rncp_handler._fetch_request(
path,
data,
request_id,
os.urandom(16),
mock_identity,
time.time(),
)
except Exception:
pass
@settings(suppress_health_check=[HealthCheck.function_scoped_fixture], deadline=None)
@given(
app_data_base64=st.text(min_size=0, max_size=10000),
)
def test_parse_lxmf_stamp_cost_fuzzing(mock_app, app_data_base64):
"""Fuzz LXMF stamp cost parsing from base64 app_data."""
try:
from meshchatx.src.backend.meshchat_utils import parse_lxmf_stamp_cost
def test_parse_lxmf_stamp_cost_oracle(mock_app, app_data_base64):
"""Stamp cost parse returns None or a non-negative number. Never raises."""
from meshchatx.src.backend.meshchat_utils import parse_lxmf_stamp_cost
parse_lxmf_stamp_cost(app_data_base64)
except Exception:
pass
cost = parse_lxmf_stamp_cost(app_data_base64)
assert cost is None or (isinstance(cost, (int, float)) and cost >= 0)
@settings(suppress_health_check=[HealthCheck.function_scoped_fixture], deadline=None)

View file

@ -147,6 +147,40 @@ def test_mime_for_image_type():
assert sticker_utils.mime_for_image_type("unknown") == "application/octet-stream"
_STICKER_REJECT_REASONS = frozenset(
{
"invalid_image_bytes",
"empty_image",
"invalid_image_type",
"invalid_image_signature",
"magic_type_mismatch",
"image_too_large",
"animated_too_large",
"animated_dimensions_invalid",
"animated_fps_out_of_range",
"animated_duration_too_long",
"video_too_large",
"video_has_audio",
"video_codec_not_vp9",
"video_fps_too_high",
"video_duration_too_long",
"static_too_large",
"static_dimensions_unknown",
"strict_format_not_supported",
},
)
_STICKER_EXPORT_PREFIXES = (
"invalid_document",
"invalid_format",
"unsupported_version",
"invalid_stickers_array",
"invalid_sticker_at_",
"missing_image_bytes_at_",
"invalid_base64_at_",
)
@settings(max_examples=200, deadline=None)
@given(
raw=st.binary(min_size=0, max_size=sticker_utils.MAX_STICKER_BYTES + 1),
@ -158,19 +192,44 @@ def test_mime_for_image_type():
),
),
)
def test_validate_sticker_payload_fuzz_never_raises_unexpected(raw, typ):
"""Fuzz: validation either succeeds or raises ValueError with known reasons."""
def test_validate_sticker_payload_accept_reject_oracle(raw, typ):
"""Accept only when type, magic, and size agree. Else known ValueError reason."""
nt = sticker_utils.normalize_image_type(typ)
detected = sticker_utils.detect_image_format_from_magic(raw)
expect_ok = (
bool(raw)
and nt is not None
and detected is not None
and detected == nt
and nt in {"png", "jpeg", "jpg", "gif", "webp", "bmp"}
and len(raw) <= sticker_utils.MAX_STICKER_BYTES
)
try:
sticker_utils.validate_sticker_payload(raw, typ)
except ValueError:
pass
out_type, out_hash = sticker_utils.validate_sticker_payload(raw, typ)
except ValueError as exc:
assert not expect_ok
assert str(exc) in _STICKER_REJECT_REASONS
return
assert expect_ok
assert out_type == detected
assert len(out_hash) == 64
assert all(c in "0123456789abcdef" for c in out_hash)
@settings(max_examples=500, deadline=None)
@given(raw=st.binary(min_size=0, max_size=4096))
def test_detect_image_format_from_magic_fuzz_never_raises(raw):
def test_detect_image_format_from_magic_closed_set(raw):
out = sticker_utils.detect_image_format_from_magic(raw)
assert out is None or out in {"png", "jpeg", "gif", "webp", "bmp", "tgs", "webm"}
if len(raw) < 4:
assert out is None
return
if raw.startswith(b"\x89PNG\r\n\x1a\n"):
assert out == "png"
elif raw.startswith(b"\xff\xd8\xff"):
assert out == "jpeg"
elif raw.startswith((b"GIF87a", b"GIF89a")):
assert out == "gif"
@settings(max_examples=100, deadline=None)
@ -192,11 +251,22 @@ def test_detect_image_format_from_magic_fuzz_never_raises(raw):
max_size=8,
),
)
def test_validate_export_document_fuzz_never_raises_unexpected(doc):
def test_validate_export_document_accept_reject_oracle(doc):
try:
sticker_utils.validate_export_document(doc)
except ValueError:
pass
items = sticker_utils.validate_export_document(doc)
except ValueError as exc:
reason = str(exc)
assert any(
reason == p or reason.startswith(p) for p in _STICKER_EXPORT_PREFIXES
)
return
assert isinstance(items, list)
assert doc.get("format") == "meshchatx-stickers"
assert int(doc["version"]) == 1
assert isinstance(doc.get("stickers"), list)
for item in items:
assert isinstance(item["image_bytes_b64"], str)
assert item["image_bytes_b64"]
def _build_tgs(

View file

@ -261,7 +261,9 @@ async def test_send_status_uses_tele_frame_ms():
client = AsyncMock()
await bridge.send_status(client)
payload = json.loads(client.send_str.await_args.args[0])
assert payload == {"type": "web_audio.ready", "frame_ms": 52}
assert payload["type"] == "web_audio.ready"
assert payload["frame_ms"] == 52
assert payload["required"] is False
@pytest.mark.asyncio
@ -283,7 +285,19 @@ async def test_send_status_defaults_when_no_telephone():
client = AsyncMock()
await bridge.send_status(client)
payload = json.loads(client.send_str.await_args.args[0])
assert payload == {"type": "web_audio.ready", "frame_ms": 60}
assert payload["type"] == "web_audio.ready"
assert payload["frame_ms"] == 60
assert payload["required"] is False
@pytest.mark.asyncio
async def test_send_status_marks_required_when_force_enabled():
bridge = WebAudioBridge(_TeleMgrNoTelephone(), MagicMock(), force_enabled=True)
client = AsyncMock()
await bridge.send_status(client)
payload = json.loads(client.send_str.await_args.args[0])
assert payload["required"] is True
assert bridge.get_diagnostics()["force_enabled"] is True
@patch("meshchatx.src.backend.web_audio_bridge.Pipeline")

View file

@ -757,4 +757,21 @@ describe("CallPage.vue", () => {
expect(wrapper.vm.callMinimized).toBe(false);
});
});
describe("web audio required (headless / Android)", () => {
it("oracle: required status forces webAudioBridgeEnabled and blocks disable", async () => {
const wrapper = mountCallPage();
await flushPromises();
wrapper.vm.config = { telephone_enabled: true, telephone_web_audio_enabled: false };
await wrapper.vm.ensureWebAudio({
enabled: true,
required: true,
frame_ms: 60,
});
expect(wrapper.vm.webAudioBridgeRequired).toBe(true);
expect(wrapper.vm.webAudioBridgeEnabled).toBe(true);
await wrapper.vm.onToggleWebAudio(false);
expect(wrapper.vm.webAudioBridgeEnabled).toBe(true);
});
});
});

View file

@ -96,7 +96,7 @@ describe("RnsFilesyncPage.vue", () => {
expect.objectContaining({
sync_directory: "/tmp/sync",
monitor: true,
}),
})
);
expect(ToastUtils.success).toHaveBeenCalledWith("rns_filesync.started");
});

View file

@ -271,6 +271,58 @@ describe("behavior contracts: plugin install permissions", () => {
});
});
describe("behavior contracts: phased startup and early UI mount", () => {
it("frontend mounts on ui_ready and continues polling for mesh ready", () => {
const wait = readSource("meshchatx/src/frontend/js/networkStartupWait.js");
expect(wait).toContain('kind: "ui"');
expect(wait).toContain("mountOnUiReady");
expect(wait).toContain("waitForMeshReady");
expect(wait).toContain("hasOwnProperty.call");
const main = readSource("meshchatx/src/frontend/main.js");
expect(main).toContain("waitForMeshReady");
expect(main).toContain('networkReady === "ui"');
expect(main).toContain("networkStarting");
expect(main).toContain('from "./locales/en.json"');
expect(main).not.toMatch(/import\.meta\.glob\(\s*["'].*locales.*["']\s*,\s*\{\s*eager:\s*true/);
});
it("App gates shell until mesh ready and shows starting banner", () => {
const app = readSource("meshchatx/src/frontend/components/App.vue");
expect(app).toContain("waitForMeshThenStartShell");
expect(app).toContain("showNetworkStartingBanner");
expect(app).toContain("networkStarting");
expect(app).toContain("network_starting");
const banners = readSource("meshchatx/src/frontend/components/layout/AppShellBanners.vue");
expect(banners).toContain("showNetworkStarting");
expect(banners).toContain("networkStartingLabel");
});
it("backend publishes ui_ready early and defers secondary identity services", () => {
const mesh = readSource("meshchatx/meshchat.py");
expect(mesh).toContain("self._ui_ready = True");
expect(mesh).toContain("_finish_deferred_startup_services");
expect(mesh).toContain("_start_deferred_reticulum_services");
const identity = readSource("meshchatx/src/backend/identity_context.py");
expect(identity).toContain("def setup_deferred_services");
expect(identity).toContain("_deferred_setup_in_progress");
expect(identity).toContain("_deferred_setup_finished");
expect(identity).toContain("critical_only=True");
expect(identity).toContain("quick=True");
expect(identity).toContain("populate=False");
});
it("integrity critical path and docs populate helpers stay available", () => {
const integrity = readSource("meshchatx/src/backend/integrity_manager.py");
expect(integrity).toContain("critical_only");
const docs = readSource("meshchatx/src/backend/docs_manager.py");
expect(docs).toContain("ensure_meshchatx_docs_populated");
expect(docs).toContain("populate: bool = True");
const database = readSource("meshchatx/src/backend/database/__init__.py");
expect(database).toContain("quick: bool = False");
expect(database).toContain("quick_check");
});
});
describe("behavior contracts: network visualiser performance", () => {
it("keeps lean physics and edge-hide options for large meshes", () => {
const src = readSource("meshchatx/src/frontend/components/network-visualiser/NetworkVisualiser.vue");

View file

@ -0,0 +1,65 @@
// SPDX-License-Identifier: 0BSD
import { describe, expect, it, vi } from "vitest";
import { createI18n } from "vue-i18n";
import { ensureLocaleMessages, listLocaleCodes, setLocale } from "../../meshchatx/src/frontend/js/localeLoader.js";
describe("localeLoader", () => {
it("lists locale codes with english first", () => {
const codes = listLocaleCodes();
expect(codes[0]).toBe("en");
expect(codes).toEqual(expect.arrayContaining(["de", "es", "fr", "ru", "zh"]));
expect(new Set(codes).size).toBe(codes.length);
});
it("ensureLocaleMessages rejects bad codes without throwing", async () => {
const i18n = createI18n({ legacy: false, locale: "en", messages: { en: { hi: "hi" } } });
for (const bad of [null, undefined, "", 12, {}, [], "nope-xx"]) {
await expect(ensureLocaleMessages(i18n, bad)).resolves.toBe(false);
}
});
it("ensureLocaleMessages is idempotent for already-loaded locales", async () => {
const i18n = createI18n({ legacy: false, locale: "en", messages: { en: { hi: "hi" } } });
expect(await ensureLocaleMessages(i18n, "en")).toBe(true);
expect(await ensureLocaleMessages(i18n, "en")).toBe(true);
});
it("loads a non-english locale into the composer", async () => {
const i18n = createI18n({ legacy: false, locale: "en", messages: { en: { hi: "hi" } } });
expect(i18n.global.availableLocales).not.toContain("de");
expect(await ensureLocaleMessages(i18n, "de")).toBe(true);
expect(i18n.global.availableLocales).toContain("de");
expect(i18n.global.getLocaleMessage("de")._languageName).toBeTruthy();
});
it("setLocale updates locale.value for composition i18n", async () => {
const i18n = createI18n({ legacy: false, locale: "en", messages: { en: { hi: "hi" } } });
expect(await setLocale(i18n, "fr")).toBe(true);
expect(i18n.global.locale.value).toBe("fr");
});
it("setLocale works when given the composer directly", async () => {
const i18n = createI18n({ legacy: false, locale: "en", messages: { en: { hi: "hi" } } });
expect(await setLocale(i18n.global, "es")).toBe(true);
expect(i18n.global.locale.value).toBe("es");
});
it("ensureLocaleMessages returns false when setLocaleMessage is missing", async () => {
const fake = { availableLocales: [], setLocaleMessage: undefined };
expect(await ensureLocaleMessages(fake, "de")).toBe(false);
});
it("adversarial: ensureLocaleMessages(null) is false", async () => {
expect(await ensureLocaleMessages(null, "en")).toBe(false);
expect(await setLocale(undefined, "en")).toBe(false);
});
it("fuzz: random codes never throw", async () => {
const i18n = createI18n({ legacy: false, locale: "en", messages: { en: {} } });
const junk = ["", "en", "de", "../en", "EN", "en.json", "🚀", "a".repeat(200), "zh-CN", "pt"];
for (const code of junk) {
await expect(ensureLocaleMessages(i18n, code)).resolves.toBeTypeOf("boolean");
}
});
});

View file

@ -0,0 +1,289 @@
// SPDX-License-Identifier: 0BSD
/**
* Adversarial fuzzing and oracles for early-UI / mesh-ready boot gating.
*/
import { afterEach, describe, expect, it, vi } from "vitest";
import {
STARTUP_STAGE_LABELS,
interpretStartupStatus,
waitForMeshReady,
waitForNetworkReady,
} from "../../meshchatx/src/frontend/js/networkStartupWait.js";
function assertInterpretOracle(result, data) {
expect(result).toBeTruthy();
expect(typeof result.kind).toBe("string");
expect(["ready", "ui", "degraded", "failed", "starting", "invalid"]).toContain(result.kind);
const own = (key) => Object.prototype.hasOwnProperty.call(data, key);
const networkReady = own("network_ready") && data.network_ready === true;
const uiReady = own("ui_ready") && data.ui_ready === true;
const networkDegraded = own("network_degraded") && data.network_degraded === true;
const status = own("status") ? data.status : undefined;
if (result.kind === "ready") {
expect(status === "ok" || networkReady).toBe(true);
}
if (result.kind === "ui") {
expect(uiReady).toBe(true);
expect(status === "starting" || status === undefined).toBe(true);
expect(networkReady).toBe(false);
}
if (result.kind === "degraded") {
expect(status).toBe("failed");
expect(uiReady || networkDegraded).toBe(true);
}
if (result.kind === "failed") {
expect(status).toBe("failed");
expect(uiReady).toBe(false);
expect(networkDegraded).toBe(false);
}
if (result.kind === "starting") {
expect(uiReady).toBe(false);
}
}
function mulberry32(seed) {
let t = seed >>> 0;
return () => {
t += 0x6d2b79f5;
let r = Math.imul(t ^ (t >>> 15), 1 | t);
r ^= r + Math.imul(r ^ (r >>> 7), 61 | r);
return ((r ^ (r >>> 14)) >>> 0) / 4294967296;
};
}
describe("networkStartupWait adversarial / oracle", () => {
afterEach(() => {
vi.useRealTimers();
});
it("oracle: ready beats starting labels when network_ready is true", () => {
const data = { status: "starting", stage: "identity", network_ready: true, ui_ready: true };
const result = interpretStartupStatus(data);
expect(result.kind).toBe("ready");
assertInterpretOracle(result, data);
});
it("oracle: failed is checked before network_ready spoof", () => {
const data = {
status: "failed",
network_ready: true,
ui_ready: false,
network_degraded: false,
error: "spoof",
};
const result = interpretStartupStatus(data);
expect(result.kind).toBe("failed");
assertInterpretOracle(result, data);
});
it("oracle: only strict boolean true mounts UI early", () => {
for (const fake of [1, "true", "yes", {}, [], "1"]) {
const data = { status: "starting", stage: "rns", ui_ready: fake, network_ready: false };
const result = interpretStartupStatus(data);
expect(result.kind).toBe("starting");
assertInterpretOracle(result, data);
}
const ok = interpretStartupStatus({
status: "starting",
stage: "rns",
ui_ready: true,
network_ready: false,
});
expect(ok.kind).toBe("ui");
});
it("oracle: degraded requires failed + recovery flag", () => {
expect(
interpretStartupStatus({
status: "failed",
ui_ready: true,
error: "x",
}).kind
).toBe("degraded");
expect(
interpretStartupStatus({
status: "failed",
network_degraded: true,
error: "y",
}).kind
).toBe("degraded");
expect(interpretStartupStatus({ status: "failed", error: "z" }).kind).toBe("failed");
});
it("fuzz: random status payloads never throw and obey oracle", () => {
const rand = mulberry32(0x51a7);
const statuses = ["ok", "starting", "failed", undefined, null, "nope", 0, "", "OK"];
const stages = [...Object.keys(STARTUP_STAGE_LABELS), undefined, null, "???", 12];
for (let i = 0; i < 400; i++) {
const data = {
status: statuses[Math.floor(rand() * statuses.length)],
stage: stages[Math.floor(rand() * stages.length)],
network_ready: rand() < 0.3 ? true : rand() < 0.5 ? false : rand() < 0.5 ? 1 : "true",
ui_ready: rand() < 0.3 ? true : rand() < 0.5 ? false : rand() < 0.5 ? 1 : "yes",
network_degraded: rand() < 0.2,
error: rand() < 0.2 ? "err" : rand() < 0.1 ? 123 : undefined,
};
let result;
expect(() => {
result = interpretStartupStatus(data);
}).not.toThrow();
if (data && typeof data === "object" && !Array.isArray(data)) {
assertInterpretOracle(result, data);
} else {
expect(result.kind).toBe("invalid");
}
}
});
it("fuzz: interpretStartupStatus rejects non-objects without throwing", () => {
for (const junk of [null, undefined, 0, 1, "", "starting", true, false, [], () => {}, Symbol("x")]) {
expect(() => interpretStartupStatus(junk)).not.toThrow();
expect(interpretStartupStatus(junk).kind).toBe("invalid");
}
});
it("waitForMeshReady never returns ui even when ui_ready is set", async () => {
let calls = 0;
const result = await waitForMeshReady({
fetchImpl: async () => {
calls += 1;
if (calls < 3) {
return {
ok: true,
json: async () => ({
status: "starting",
stage: "identity",
ui_ready: true,
network_ready: false,
}),
};
}
return {
ok: true,
json: async () => ({ status: "ok", network_ready: true, ui_ready: true }),
};
},
sleep: async () => {},
timeoutMs: 5000,
});
expect(result).toBe("ready");
expect(calls).toBeGreaterThanOrEqual(3);
});
it("waitForNetworkReady prefers ready over ui on the same payload", async () => {
const result = await waitForNetworkReady({
fetchImpl: async () => ({
ok: true,
json: async () => ({
status: "ok",
network_ready: true,
ui_ready: true,
stage: "ready",
}),
}),
sleep: async () => {},
timeoutMs: 1000,
});
expect(result).toBe("ready");
});
it("waitForNetworkReady ignores non-ok HTTP and keeps polling", async () => {
let calls = 0;
const result = await waitForNetworkReady({
fetchImpl: async () => {
calls += 1;
if (calls === 1) {
return { ok: false, status: 503, json: async () => ({}) };
}
if (calls === 2) {
return {
ok: true,
json: async () => ({
status: "starting",
ui_ready: true,
stage: "http",
}),
};
}
return { ok: true, json: async () => ({ status: "ok", network_ready: true }) };
},
sleep: async () => {},
timeoutMs: 5000,
mountOnUiReady: true,
});
expect(result).toBe("ui");
});
it("waitForNetworkReady: malformed JSON is treated as still starting", async () => {
let calls = 0;
const lines = [];
const result = await waitForNetworkReady({
fetchImpl: async () => {
calls += 1;
if (calls < 2) {
return {
ok: true,
json: async () => {
throw new Error("bad json");
},
};
}
return {
ok: true,
json: async () => ({ status: "ok", network_ready: true }),
};
},
sleep: async () => {},
timeoutMs: 5000,
onLine: (text) => lines.push(text),
mountOnUiReady: false,
});
expect(result).toBe("ready");
expect(lines).toContain("Still starting…");
});
it("adversarial: prototype pollution cannot spoof network_ready or ui_ready", () => {
const polluted = { status: "starting", stage: "rns" };
Object.setPrototypeOf(polluted, {
network_ready: true,
ui_ready: true,
network_degraded: true,
});
const result = interpretStartupStatus(polluted);
expect(result.kind).toBe("starting");
assertInterpretOracle(result, polluted);
const ownUi = { status: "starting", stage: "rns", ui_ready: true };
Object.setPrototypeOf(ownUi, { network_ready: true });
expect(interpretStartupStatus(ownUi).kind).toBe("ui");
});
it("adversarial: arrays are invalid even with status-like indexes", () => {
const arr = ["starting"];
arr.status = "starting";
arr.ui_ready = true;
expect(interpretStartupStatus(arr).kind).toBe("invalid");
});
it("priority matrix: status/network_ready/ui_ready combinations", () => {
const matrix = [
[{ status: "ok", network_ready: false, ui_ready: false }, "ready"],
[{ status: "starting", network_ready: true, ui_ready: false }, "ready"],
[{ status: "starting", network_ready: false, ui_ready: true }, "ui"],
[{ status: "starting", network_ready: false, ui_ready: false }, "starting"],
[{ status: "failed", ui_ready: true }, "degraded"],
[{ status: "failed", network_degraded: true }, "degraded"],
[{ status: "failed" }, "failed"],
[{ status: "wat" }, "invalid"],
];
for (const [data, kind] of matrix) {
const result = interpretStartupStatus(data);
expect(result.kind).toBe(kind);
assertInterpretOracle(result, data);
}
});
});

View file

@ -48,12 +48,27 @@ describe("networkStartupWait", () => {
});
});
it("interpretStartupStatus marks ui when ui_ready during starting", () => {
expect(
interpretStartupStatus({
status: "starting",
stage: "rns",
network_ready: false,
ui_ready: true,
})
).toEqual({
kind: "ui",
stage: "rns",
label: STARTUP_STAGE_LABELS.rns,
});
});
it("interpretStartupStatus maps starting stages to labels", () => {
for (const stage of Object.keys(STARTUP_STAGE_LABELS)) {
if (stage === "ready" || stage === "failed") {
continue;
}
const result = interpretStartupStatus({ status: "starting", stage });
const result = interpretStartupStatus({ status: "starting", stage, ui_ready: false });
expect(result.kind).toBe("starting");
expect(result.label).toBe(STARTUP_STAGE_LABELS[stage]);
}
@ -73,7 +88,12 @@ describe("networkStartupWait", () => {
if (calls < 3) {
return {
ok: true,
json: async () => ({ status: "starting", stage: "rns", network_ready: false }),
json: async () => ({
status: "starting",
stage: "rns",
network_ready: false,
ui_ready: false,
}),
};
}
return {
@ -86,12 +106,33 @@ describe("networkStartupWait", () => {
sleep: async () => {},
timeoutMs: 5000,
onLine: (text) => lines.push(text),
mountOnUiReady: false,
});
expect(ready).toBe("ready");
expect(lines).toContain(STARTUP_STAGE_LABELS.rns);
expect(fetchImpl).toHaveBeenCalled();
});
it("waitForNetworkReady mounts early when ui_ready", async () => {
const lines = [];
const ready = await waitForNetworkReady({
fetchImpl: async () => ({
ok: true,
json: async () => ({
status: "starting",
stage: "identity",
network_ready: false,
ui_ready: true,
}),
}),
sleep: async () => {},
timeoutMs: 1000,
onLine: (text) => lines.push(text),
});
expect(ready).toBe("ui");
expect(lines).toContain(STARTUP_STAGE_LABELS.identity);
});
it("waitForNetworkReady returns false on failed status", async () => {
const errors = [];
const ready = await waitForNetworkReady({
@ -158,7 +199,12 @@ describe("networkStartupWait", () => {
const ready = await waitForNetworkReady({
fetchImpl: async () => ({
ok: true,
json: async () => ({ status: "starting", stage: "identity", network_ready: false }),
json: async () => ({
status: "starting",
stage: "identity",
network_ready: false,
ui_ready: false,
}),
}),
now: () => now,
sleep: async () => {
@ -167,6 +213,7 @@ describe("networkStartupWait", () => {
timeoutMs: 1000,
onLine: (text) => lines.push(text),
onErrorState: () => errors.push("error"),
mountOnUiReady: false,
});
expect(ready).toBe(false);
expect(errors).toEqual(["error"]);