mirror of
https://github.com/Quad4-Software/MeshChatX.git
synced 2026-08-18 09:49:09 -04:00
feat: improve audio handling in Docker and headless environments with hostless LXST audio backends and web audio bridge integration
This commit is contained in:
parent
9f71f3420c
commit
ec74a2651e
59 changed files with 3069 additions and 530 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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 ---
|
||||
|
|
|
|||
|
|
@ -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`.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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`.
|
||||
|
|
|
|||
BIN
meshchatx.rsm
BIN
meshchatx.rsm
Binary file not shown.
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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}")
|
||||
|
||||
|
|
|
|||
|
|
@ -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}"
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
84
meshchatx/src/frontend/js/localeLoader.js
Normal file
84
meshchatx/src/frontend/js/localeLoader.js
Normal 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;
|
||||
}
|
||||
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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": "Открыть интерфейсы",
|
||||
|
|
|
|||
|
|
@ -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": "打开接口",
|
||||
|
|
|
|||
|
|
@ -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" });
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
155
scripts/patch_lxst_codec2_optional.py
Normal file
155
scripts/patch_lxst_codec2_optional.py
Normal 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())
|
||||
|
|
@ -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 = ?",
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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}"))
|
||||
|
|
|
|||
73
tests/backend/test_integrity_critical_only.py
Normal file
73
tests/backend/test_integrity_critical_only.py
Normal 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)
|
||||
333
tests/backend/test_lxst_telephony_adversarial.py
Normal file
333
tests/backend/test_lxst_telephony_adversarial.py
Normal 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()
|
||||
413
tests/backend/test_phased_startup_guards.py
Normal file
413
tests/backend/test_phased_startup_guards.py
Normal 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()
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -96,7 +96,7 @@ describe("RnsFilesyncPage.vue", () => {
|
|||
expect.objectContaining({
|
||||
sync_directory: "/tmp/sync",
|
||||
monitor: true,
|
||||
}),
|
||||
})
|
||||
);
|
||||
expect(ToastUtils.success).toHaveBeenCalledWith("rns_filesync.started");
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
65
tests/frontend/localeLoader.test.js
Normal file
65
tests/frontend/localeLoader.test.js
Normal 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");
|
||||
}
|
||||
});
|
||||
});
|
||||
289
tests/frontend/networkStartupWait.adversarial.test.js
Normal file
289
tests/frontend/networkStartupWait.adversarial.test.js
Normal 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);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -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"]);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue