diff --git a/.github/workflows/android-emulator-smoke.yml b/.github/workflows/android-emulator-smoke.yml index 2ab5601c..2e31782d 100644 --- a/.github/workflows/android-emulator-smoke.yml +++ b/.github/workflows/android-emulator-smoke.yml @@ -1,5 +1,5 @@ # Nightly / manual Android emulator smoke: build x86_64 debug APK, install on -# an AVD, launch MainActivity, and require /api/v1/status == ok. +# an AVD, launch MainActivity, and require /api/v1/status to respond (ok or starting). # Catches Chaquopy boot failures (missing modules, storage lock SystemExit, etc.) # that assembleDebug alone cannot see. # diff --git a/AGENTS.md b/AGENTS.md index f2ae1980..cb33111b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -4,3 +4,12 @@ Start at [docs/agents/README.md](docs/agents/README.md). For architecture and invariants, read [docs/agents/overview.md](docs/agents/overview.md). For mesh design, read [docs/agents/conventions/reticulum-zen.md](docs/agents/conventions/reticulum-zen.md). + +## Linux Landlock (quick) + +MeshChatX applies an optional filesystem sandbox on Linux (`MESHCHAT_LANDLOCK`, see overview). Besides SQLite `temp_store`, it affects **subprocesses** and **user-local tools** (pipx Argos Translate, `~/.local/bin` wrappers, rnsh/rnx when launched as PATH scripts). + +- Implementation: `meshchatx/src/backend/landlock_sandbox.py` +- SQLite symptoms and pragmas: [docs/agents/skills/landlock-sqlite/SKILL.md](docs/agents/skills/landlock-sqlite/SKILL.md) +- Integration probes (subprocess spawn, translator Argos, home write denial): `tests/backend/test_landlock_integration_surfaces.py` and `tests/backend/landlock_integration_support.py` +- After changing Landlock rules or any code that `subprocess`/`Popen`s external binaries, extend read/RW roots and add a probe test in that integration file. diff --git a/CHANGELOG.md b/CHANGELOG.md index fecb183f..19eab4fb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,9 @@ All notable changes to this project will be documented in this file. ### Fixed +- **Messages (personal notes)**: Sending to your own LXMF address or identity is stored locally as delivered (method `local`) without LXMF router outbound, so self-chat no longer hangs in a waiting state. +- **Messages sidebar**: Conversation list updates optimistically when you press Send, before the server acknowledges the message. +- **E2E**: Playwright API helpers attach CSRF tokens for direct backend POSTs; `pretest:e2e` installs Chromium before the suite runs. - **Android LXST / Codec2**: When the Chaquopy `pycodec2.so` extension is an empty stub, fall back to a ctypes Codec2 binding over the bundled `libcodec2.so` so LXST Codec2 voice profiles work on device. Still preload jniLibs Codec2 and reload soft-imported LXST bindings after probe. - **Android RNode flasher**: Open native flasher returns a real status, keeps USB-serial classes through R8, uses an ActionBar theme, and surfaces startup failures instead of silently doing nothing. Bluetooth Open settings tries GrapheneOS-friendly fallbacks (app details, Bluetooth settings, general Settings) instead of toasting unavailable. - **Connection banners**: Do not flash disconnected on startup before the first successful WebSocket open. Debounce disconnect UI for 2.5s and only show reconnected when the disconnect banner was actually shown. Foreground recovery prefers a ping for longer before forcing a reconnect. @@ -14,8 +17,8 @@ All notable changes to this project will be documented in this file. - **HTTP security headers**: Send Permissions-Policy allowing microphone and camera for this origin so reverse proxies that omit the header do not block capture by default. - **UI language**: Persist language changes over the config HTTP API (not WebSocket-only), normalize legacy locale codes, and stop the Reticulum manual language picker from overwriting app UI language. - **Network visualizer**: WebGL background follows light theme and clears while the WASM scene is still loading. Boot theme removes stale dark class when light is selected. - -## [4.8.1] - 2026-07-25 +- **Translator (Landlock)**: On Linux, allow read/execute for user-local pipx CLIs (`~/.local/bin`, `~/.local/share/pipx`) and read-write for Argos Translate data under `~/.local/share/argos-translate`, so `argospm` language lists and local Argos translation work with the filesystem sandbox enabled. +- **Tests**: Landlock integration probes for subprocess spawn, translator Argos language listing, user-local CLI execution, and home write denial outside RW roots (`tests/backend/test_landlock_integration_surfaces.py`). ### Fixed diff --git a/docs/agents/README.md b/docs/agents/README.md index 50d75583..aa746169 100644 --- a/docs/agents/README.md +++ b/docs/agents/README.md @@ -54,12 +54,12 @@ Optional editor rules (if present under `.cursor/rules/` or similar): ### Identity and data -| Skill | Use when | -| -------------------------------------------------------------------------- | ------------------------------------------------- | -| [identity-restore](skills/identity-restore/SKILL.md) | Identity key vs database zip restore | -| [identity-switch-teardown](skills/identity-switch-teardown/SKILL.md) | Live identity switch without cross-identity leaks | -| [database-migrations-backups](skills/database-migrations-backups/SKILL.md) | Schema bumps, backups, snapshots, restore safety | -| [landlock-sqlite](skills/landlock-sqlite/SKILL.md) | Landlock + SQLite conversation failures | +| Skill | Use when | +| -------------------------------------------------------------------------- | --------------------------------------------------- | +| [identity-restore](skills/identity-restore/SKILL.md) | Identity key vs database zip restore | +| [identity-switch-teardown](skills/identity-switch-teardown/SKILL.md) | Live identity switch without cross-identity leaks | +| [database-migrations-backups](skills/database-migrations-backups/SKILL.md) | Schema bumps, backups, snapshots, restore safety | +| [landlock-sqlite](skills/landlock-sqlite/SKILL.md) | Landlock + SQLite, subprocess/user-local CLI probes | ### Security and plugins diff --git a/docs/agents/conventions/backend.md b/docs/agents/conventions/backend.md index 337519c3..94914aa1 100644 --- a/docs/agents/conventions/backend.md +++ b/docs/agents/conventions/backend.md @@ -11,6 +11,7 @@ Applies when editing `meshchatx/**/*.py`. - Multipart parsers must not assume field order. - SQLite worker connections must set `temp_store=MEMORY` in `DatabaseProvider` (Landlock-safe). - Under Landlock, memory-pressure must not force FILE temp for conversation queries. +- Subprocess or user-local CLI features must remain usable under Landlock. Extend `landlock_sandbox.py` read/RW roots and add a probe in `tests/backend/test_landlock_integration_surfaces.py` (see `landlock-sqlite` skill). - Keep conversation list queries slim: truncate content, derive attachment flags in SQL, avoid shipping full `fields` blobs. - Identity restore validates size and empty payloads. Preserve existing identity metadata on re-import. - No backticks in code comments. Prefer plain words or quoted identifiers. diff --git a/docs/agents/conventions/tests.md b/docs/agents/conventions/tests.md index 3f30b787..09ec72e4 100644 --- a/docs/agents/conventions/tests.md +++ b/docs/agents/conventions/tests.md @@ -7,6 +7,9 @@ Applies when editing `tests/**/*.{py,js}`. - Mock `window.api` for page tests. Assert toasts when outcomes are user-visible. - Prefer focused files over full suite unless the user asks for broad runs. - Landlock tests that apply the sandbox must run in a subprocess (one restrict per process). +- Shared runner: `tests/backend/landlock_integration_support.py` (`run_python_under_landlock`). +- Subprocess and user-local CLI probes: `tests/backend/test_landlock_integration_surfaces.py`. +- Unit tests for rules and ABI: `tests/backend/test_landlock_sandbox.py`. - Long-running / notification soak suites can hang. Prefer timeouts and avoid piping pytest through `tail` in agent shells. ## Oracle style (no soft fuzz) diff --git a/docs/agents/module-ownership.md b/docs/agents/module-ownership.md index 952d0d3e..1aecc183 100644 --- a/docs/agents/module-ownership.md +++ b/docs/agents/module-ownership.md @@ -7,23 +7,23 @@ Schema contracts live under `tests/backend/` (not production route modules). ## Backend -| Domain | Manager modules | HTTP route module | WS module | Primary tests | Frontend page | -| ----------------- | ------------------------------------------------------------- | ------------------------------------------- | ---------------------------- | ---------------------------------------------------------------- | ----------------------------------------------------- | -| Identity | `identity_context.py`, `identity_manager.py` | `http/routes/identities.py` | `http/ws/dispatch.py` | `test_identity_*`, identity-switch skills | Identities / tutorial | -| Status / startup | `lifecycle/deferred_network.py`, `rns_startup_recovery.py` | `http/routes/status.py` | (status via REST) | `test_deferred_network_startup.py`, `test_api_json_contracts.py` | App shell banners | -| Auth / CSRF | `csrf.py`, `app_security_settings.py`, `ip_allowlist.py` | `http/routes/auth.py` | `http/ws/dispatch.py` | `test_csrf*`, `test_access_attempts_*` | Auth gate in `App.vue` | -| Messages / LXMF | `message_handler.py`, `lxmf_utils.py`, `database/messages.py` | `http/routes/messages.py`, `lxmf.py` | `http/ws/dispatch.py` | LXMF messaging skill tests | `messages/MessagesPage.vue`, `ConversationViewer.vue` | -| Telephone / LXST | `telephone_manager.py`, `database/telephone.py` | `http/routes/telephone.py`, `contacts.py` | `http/ws/dispatch.py` | telephone contract tests | `call/CallPage.vue` | -| RRC | `rrc/manager.py`, `rrc/server.py` | `http/routes/rrc.py` | `http/ws/dispatch.py` | RRC skill tests | `relay/RelayChatPage.vue` | -| Map | `map_manager.py`, `map_overlay_manager.py` | `http/routes/map.py` | marker updates via broadcast | map manager tests | `map/MapPage.vue` | -| Plugins | `plugin_manager.py`, `plugin_permissions.py` | `http/routes/plugins.py`, `sideband.py` | `http/ws/dispatch.py` | plugin install security tests | `settings/PluginsSettingsSection.vue` | -| FileSync | `rns_filesync_handler.py` | `http/routes/filesync.py` | (REST-heavy) | filesync security tests | `filesync/` pages | -| Interfaces | `interface_editor.py`, `interface_config_parser.py` | `http/routes/interfaces.py` | (announce loops on app) | interface stats tests | `interfaces/InterfacesPage.vue` | -| Database / backup | `database/*`, `recovery/*` | `http/routes/database.py`, `maintenance.py` | (broadcasts on restore) | backup/restore skill tests | About / maintenance settings | -| Docs | `docs_manager.py` | `http/routes/docs.py` | (none) | docs manager tests | `docs/DocsPage.vue` | -| Sandbox | `landlock_sandbox.py`, `seccomp_sandbox.py` | `http/routes/status.py` | (none) | landlock/seccomp tests | Web exposure settings | -| RNS Link API | `rns_link_manager.py` | (WS only) | `http/ws/dispatch.py` | `test_rns_link_*` | plugins / external tools | -| Config | `config_manager.py` | `http/routes/config.py` | `http/ws/dispatch.py` | settings config services tests | `settings/SettingsPage.vue` | +| Domain | Manager modules | HTTP route module | WS module | Primary tests | Frontend page | +| ----------------- | ------------------------------------------------------------- | ------------------------------------------- | ---------------------------- | ------------------------------------------------------------------------------------------ | ----------------------------------------------------- | +| Identity | `identity_context.py`, `identity_manager.py` | `http/routes/identities.py` | `http/ws/dispatch.py` | `test_identity_*`, identity-switch skills | Identities / tutorial | +| Status / startup | `lifecycle/deferred_network.py`, `rns_startup_recovery.py` | `http/routes/status.py` | (status via REST) | `test_deferred_network_startup.py`, `test_api_json_contracts.py` | App shell banners | +| Auth / CSRF | `csrf.py`, `app_security_settings.py`, `ip_allowlist.py` | `http/routes/auth.py` | `http/ws/dispatch.py` | `test_csrf*`, `test_access_attempts_*` | Auth gate in `App.vue` | +| Messages / LXMF | `message_handler.py`, `lxmf_utils.py`, `database/messages.py` | `http/routes/messages.py`, `lxmf.py` | `http/ws/dispatch.py` | LXMF messaging skill tests | `messages/MessagesPage.vue`, `ConversationViewer.vue` | +| Telephone / LXST | `telephone_manager.py`, `database/telephone.py` | `http/routes/telephone.py`, `contacts.py` | `http/ws/dispatch.py` | telephone contract tests | `call/CallPage.vue` | +| RRC | `rrc/manager.py`, `rrc/server.py` | `http/routes/rrc.py` | `http/ws/dispatch.py` | RRC skill tests | `relay/RelayChatPage.vue` | +| Map | `map_manager.py`, `map_overlay_manager.py` | `http/routes/map.py` | marker updates via broadcast | map manager tests | `map/MapPage.vue` | +| Plugins | `plugin_manager.py`, `plugin_permissions.py` | `http/routes/plugins.py`, `sideband.py` | `http/ws/dispatch.py` | plugin install security tests | `settings/PluginsSettingsSection.vue` | +| FileSync | `rns_filesync_handler.py` | `http/routes/filesync.py` | (REST-heavy) | filesync security tests | `filesync/` pages | +| Interfaces | `interface_editor.py`, `interface_config_parser.py` | `http/routes/interfaces.py` | (announce loops on app) | interface stats tests | `interfaces/InterfacesPage.vue` | +| Database / backup | `database/*`, `recovery/*` | `http/routes/database.py`, `maintenance.py` | (broadcasts on restore) | backup/restore skill tests | About / maintenance settings | +| Docs | `docs_manager.py` | `http/routes/docs.py` | (none) | docs manager tests | `docs/DocsPage.vue` | +| Sandbox | `landlock_sandbox.py`, `seccomp_sandbox.py` | `http/routes/status.py` | (none) | `test_landlock_sandbox.py`, `test_landlock_integration_surfaces.py`, sqlite landlock tests | Web exposure settings | +| RNS Link API | `rns_link_manager.py` | (WS only) | `http/ws/dispatch.py` | `test_rns_link_*` | plugins / external tools | +| Config | `config_manager.py` | `http/routes/config.py` | `http/ws/dispatch.py` | settings config services tests | `settings/SettingsPage.vue` | ## HTTP package layout diff --git a/docs/agents/overview.md b/docs/agents/overview.md index 134c80fe..d7bd3b41 100644 --- a/docs/agents/overview.md +++ b/docs/agents/overview.md @@ -186,7 +186,21 @@ Critical SQLite interaction: - Memory-pressure mode may shrink cache/mmap. While Landlock is active, keep MEMORY temp. - Without Landlock, FILE temp plus a storage-local `sqlite-tmp` TMPDIR is acceptable. -Landlock apply is process-wide and one-shot. Tests that enable it must run in a subprocess. +Subprocess and user-local tools: + +- Landlock is not only SQLite. Features that **exec** binaries or read data outside `/usr`, the Python prefix, and MeshChatX storage can fail with `Permission denied` while the UI still "detects" the tool on PATH. +- Read/execute roots include typical pipx layouts: `~/.local/bin`, `~/.local/share/pipx`, and Argos package metadata under `~/.local/share/argos-translate` (RW for Stanza temp files during translation). +- **Translator (Argos)**: language lists use `argospm list` when the CLI is present. Local translation runs `argos-translate` or the `argostranslate` Python module. +- **rnsh / rnx**: prefer `python -m …` so installs are not blocked when `~/.local/bin` wrappers are not executable under Landlock. rnsh uses a storage-scoped `HOME`, not the real home directory. +- **Not covered by default**: symlinks in `~/.local/bin` that point outside allowed trees (for example IDE agent shims), tools installed only under `~/.nvm`, and arbitrary `location_cmd` paths in interface config. Use `MESHCHAT_LANDLOCK=0` to debug or widen rules deliberately in `landlock_sandbox.py`. + +Landlock apply is process-wide and one-shot. Tests that enable it must run in a subprocess (`tests/backend/landlock_integration_support.py`). + +Verification after Landlock or subprocess-facing edits: + +```bash +uv run pytest tests/backend/test_landlock_sandbox.py tests/backend/test_landlock_integration_surfaces.py tests/backend/test_sqlite_landlock_temp_store.py -q +``` Also see `docs/en/platform-guides/linux-sandbox.md` for Firejail / Bubblewrap host examples. diff --git a/docs/agents/skills/landlock-sqlite/SKILL.md b/docs/agents/skills/landlock-sqlite/SKILL.md index 35cc50ff..101939d1 100644 --- a/docs/agents/skills/landlock-sqlite/SKILL.md +++ b/docs/agents/skills/landlock-sqlite/SKILL.md @@ -1,6 +1,6 @@ # Skill: landlock-sqlite -Landlock / Windows AppContainer + SQLite conversation-load failures (temp_store, slim queries, memory pressure). +Landlock / Windows AppContainer + SQLite conversation-load failures (temp_store, slim queries, memory pressure). Also covers subprocess and user-local CLI breakage under Linux Landlock. # MeshChatX FS sandbox + SQLite @@ -25,6 +25,27 @@ Landlock / Windows AppContainer + SQLite conversation-load failures (temp_store, - List queries: `substr(content, 1, 240)` and SQL `instr` flags for attachments - API: map OperationalError / unable-to-open / locked to **503** with retryable message +## Subprocess and user-local tools (Linux Landlock) + +### Symptoms + +- Translator shows Argos as available but **no languages** after Refresh, or translation fails with `Permission denied` +- `argospm list` or `argos-translate` works in a normal shell but not inside MeshChatX +- PATH tools in `~/.local/bin` fail while `/usr/bin` tools work + +### Root causes + +1. Landlock read roots did not include pipx or user-local install paths (`~/.local/bin`, `~/.local/share/pipx`) +2. Argos Stanza needs **write** under `~/.local/share/argos-translate` (not read-only) +3. `TranslatorHandler` used Python `argostranslate` with zero packages and did not fall back to `argospm list` +4. Symlink wrappers in `~/.local/bin` that point outside allowed trees (not fixable by widening `~/.local/bin` alone) + +### Required behavior + +- `_collect_read_roots()` includes user-local CLI roots when present (`landlock_sandbox._collect_user_local_cli_roots`) +- `_collect_rw_roots()` includes `~/.local/share/argos-translate` when present +- New external-tool integrations: add roots and a probe in `tests/backend/test_landlock_integration_surfaces.py` + ## Windows counterpart - Module: `meshchatx/src/backend/appcontainer_sandbox.py` @@ -35,7 +56,7 @@ Landlock / Windows AppContainer + SQLite conversation-load failures (temp_store, ## Verification ```bash -uv run pytest tests/backend/test_sqlite_landlock_temp_store.py tests/backend/test_sqlite_memory_pressure.py tests/backend/test_landlock_sandbox.py tests/backend/test_appcontainer_sandbox.py tests/backend/test_self_check.py -q +uv run pytest tests/backend/test_sqlite_landlock_temp_store.py tests/backend/test_sqlite_memory_pressure.py tests/backend/test_landlock_sandbox.py tests/backend/test_landlock_integration_surfaces.py tests/backend/test_appcontainer_sandbox.py tests/backend/test_self_check.py -q pnpm exec vitest run tests/frontend/i18n.test.js bash scripts/ci/github-verify-frozen-sandbox.sh build/exe ``` @@ -51,4 +72,6 @@ For live stress, run Landlock in a **subprocess** (sandbox applies once per proc - `meshchatx/src/backend/appcontainer_sandbox.py` - `meshchatx/src/backend/appcontainer_launcher.py` - `meshchatx/src/backend/seccomp_sandbox.py` (syscall denylist after Landlock) -- `meshchatx/meshchat.py` (conversations/notifications error mapping) +- `meshchatx/src/backend/translator_handler.py` (Argos CLI and lib language listing) +- `tests/backend/landlock_integration_support.py` +- `tests/backend/test_landlock_integration_surfaces.py` diff --git a/docs/agents/skills/test-loop/SKILL.md b/docs/agents/skills/test-loop/SKILL.md index 46a8fa2a..bfc782d7 100644 --- a/docs/agents/skills/test-loop/SKILL.md +++ b/docs/agents/skills/test-loop/SKILL.md @@ -49,3 +49,7 @@ pnpm exec vitest run tests/frontend/.test.js ## After identity / Landlock edits Run the matching skill's verification section before claiming done. + +```bash +uv run pytest tests/backend/test_landlock_sandbox.py tests/backend/test_landlock_integration_surfaces.py tests/backend/test_sqlite_landlock_temp_store.py -q +``` diff --git a/docs/en/identity-and-security.md b/docs/en/identity-and-security.md index 4dc85043..30457f75 100644 --- a/docs/en/identity-and-security.md +++ b/docs/en/identity-and-security.md @@ -59,7 +59,7 @@ Privacy mode does not disable Reticulum mesh traffic. It limits clearnet fetches On Linux, MeshChatX can enable two complementary in-process sandboxes when supported: -- **Landlock** restricts filesystem paths the backend may use +- **Landlock** restricts filesystem paths the backend may use. User-local pipx tools (for example Argos Translate under `~/.local`) need explicit read and sometimes write roots. See **Linux sandboxing** in Platform guides. - **Seccomp-BPF** installs a syscall denylist (via libseccomp) that blocks kernel-admin and related calls a mesh client does not need Both auto-enable when available and fall back to a no-op when the platform, kernel, or libraries cannot support them. Override with: diff --git a/docs/en/platform-guides/linux-sandbox.md b/docs/en/platform-guides/linux-sandbox.md index 294c1ec3..d36dbdcb 100644 --- a/docs/en/platform-guides/linux-sandbox.md +++ b/docs/en/platform-guides/linux-sandbox.md @@ -11,6 +11,8 @@ MeshChatX also applies optional **in-process** Linux sandboxes when available: Those layers fall back cleanly when unsupported. Firejail and Bubblewrap remain useful as an outer wrapper. +**Landlock and user-local tools:** When Landlock is active, MeshChatX whitelists common pipx paths (`~/.local/bin`, `~/.local/share/pipx`) and Argos Translate data under `~/.local/share/argos-translate` so local translation and similar CLIs keep working. Tools installed elsewhere (for example only under `~/.nvm`) or symlink shims that point outside those trees may still fail with permission errors. Disable Landlock temporarily with `MESHCHAT_LANDLOCK=0` while debugging PATH-only failures. + **Containers:** If you already run MeshChatX with Docker or Podman, that is a different isolation model, this document is aimed at **host-installed** `meshchatx` (or `meshchat`). ## Prerequisites diff --git a/meshchatx.rsm b/meshchatx.rsm index 185384c2..c7052045 100644 Binary files a/meshchatx.rsm and b/meshchatx.rsm differ diff --git a/meshchatx/meshchat.py b/meshchatx/meshchat.py index 5edf5da5..f03df67c 100644 --- a/meshchatx/meshchat.py +++ b/meshchatx/meshchat.py @@ -5207,12 +5207,15 @@ class ReticulumMeshChat: raise FileNotFoundError(msg) ssl_context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) ssl_context.load_cert_chain(cert_path, key_path) - print(f"HTTPS enabled with custom certificate at {cert_path}") + print( + f"HTTPS enabled with custom certificate at {cert_path}", + flush=True, + ) else: generate_ssl_certificate(cert_path, key_path) ssl_context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) ssl_context.load_cert_chain(cert_path, key_path) - print(f"HTTPS enabled with certificate at {cert_path}") + print(f"HTTPS enabled with certificate at {cert_path}", flush=True) except Exception as e: if custom_ssl: print(f"Failed to load custom SSL certificate: {e}") @@ -5357,7 +5360,7 @@ class ReticulumMeshChat: app.on_startup.append(on_startup) protocol = "https" if use_https else "http" - print(f"Starting web server on {protocol}://{host}:{port}") + print(f"Starting web server on {protocol}://{host}:{port}", flush=True) # Start memory diagnostics if enabled if self._memory_diag_enabled: @@ -8997,6 +9000,29 @@ class ReticulumMeshChat: router.handle_outbound(lxmf_message) # upserts the provided lxmf message to the database + def _is_self_lxmf_destination(self, destination_hash: str, context=None) -> bool: + """True when destination_hash refers to this identity's own LXMF peer.""" + ctx = context or self.current_context + if not ctx or not ctx.local_lxmf_destination: + return False + norm_dest = normalize_hex_identifier(destination_hash) + if not norm_dest: + return False + local_lxmf = normalize_hex_identifier(ctx.local_lxmf_destination.hexhash) + if norm_dest == local_lxmf: + return True + if ctx.identity: + local_id = normalize_hex_identifier(ctx.identity.hash.hex()) + if norm_dest == local_id: + return True + try: + resolved = self.get_lxmf_destination_hash_for_identity_hash(norm_dest) + if resolved and normalize_hex_identifier(resolved) == local_lxmf: + return True + except Exception: + pass + return False + def db_upsert_lxmf_message( self, lxmf_message: LXMF.LXMessage, @@ -9005,6 +9031,8 @@ class ReticulumMeshChat: context=None, path_finding_measure: str | None = None, path_row_hash_hex: str | None = None, + state_override: str | None = None, + method_override: str | None = None, ): ctx = context or self.current_context if not ctx: @@ -9016,6 +9044,10 @@ class ReticulumMeshChat: reticulum=self.reticulum, message_router=ctx.message_router, ) + if state_override is not None: + lxmf_message_dict["state"] = state_override + if method_override is not None: + lxmf_message_dict["method"] = method_override lxmf_message_dict["is_spam"] = 1 if is_spam else 0 lxmf_message_dict["attachments_stripped"] = 1 if attachments_stripped else 0 if path_finding_measure is not None: @@ -9106,10 +9138,20 @@ class ReticulumMeshChat: raise ValueError(msg) from exc wants_propagated = delivery_method == "propagated" + is_local_self = self._is_self_lxmf_destination(destination_hash, ctx) + if is_local_self and wants_propagated: + msg = "Propagated delivery is not available for messages to yourself." + raise ValueError(msg) # Direct/opportunistic need a live peer path. Propagated uses the # propagation node, so skip the peer path wait (still record outcome). - if wants_propagated: + if is_local_self: + path_outcome = reticulum_pathfinding.OutboundPathOutcome( + True, + "local_self", + False, + ) + elif wants_propagated: path_outcome = reticulum_pathfinding.OutboundPathOutcome( False, "skipped_for_propagated", @@ -9121,6 +9163,8 @@ class ReticulumMeshChat: path_outcome = await self._await_transport_path(destination_hash_bytes) destination_identity = self.recall_identity(destination_hash) + if destination_identity is None and is_local_self and ctx.identity: + destination_identity = ctx.identity if destination_identity is None: msg = ( "Could not recall destination identity. " @@ -9130,7 +9174,11 @@ class ReticulumMeshChat: # Direct/opportunistic delivery needs a live transport path. Propagated # delivery can proceed without a peer path (it uses the propagation node). - if not wants_propagated and not path_outcome.path_available: + if ( + not is_local_self + and not wants_propagated + and not path_outcome.path_available + ): msg = ( "No path to destination. " "Use Path Finder or wait for a route, then try again." @@ -9288,6 +9336,40 @@ class ReticulumMeshChat: current_icon_hash, ) + if is_local_self: + lxmf_message.state = LXMF.LXMessage.DELIVERED + lxmf_message.progress = 1.0 + local_peer = ctx.local_lxmf_destination.hexhash + if not no_display: + self.db_upsert_lxmf_message( + lxmf_message, + context=ctx, + path_finding_measure=reticulum_pathfinding.format_outbound_path_finding_measure( + path_outcome, + ), + path_row_hash_hex=local_peer, + state_override="delivered", + method_override="local", + ) + ws_payload = convert_lxmf_message_to_dict( + lxmf_message, + include_attachments=False, + reticulum=self.reticulum, + message_router=ctx.message_router, + ) + ws_payload["state"] = "delivered" + ws_payload["method"] = "local" + ws_payload["progress"] = 100.0 + await self.websocket_broadcast( + json.dumps( + { + "type": "lxmf_message_created", + "lxmf_message": ws_payload, + }, + ), + ) + return lxmf_message + # register delivery callbacks lxmf_message.register_delivery_callback( lambda msg: self.on_lxmf_sending_state_updated(msg, context=ctx), diff --git a/meshchatx/src/backend/landlock_sandbox.py b/meshchatx/src/backend/landlock_sandbox.py index 7a2958ff..0a188df5 100644 --- a/meshchatx/src/backend/landlock_sandbox.py +++ b/meshchatx/src/backend/landlock_sandbox.py @@ -322,6 +322,24 @@ def _existing_dir(path: str | None) -> str | None: return None +def _collect_user_local_cli_roots() -> list[str]: + """User-installed CLIs (pipx Argos Translate, rnsh/rnx wrappers, git-remote-rns, etc.).""" + home = os.path.expanduser("~") + if not home or home == "~": + return [] + candidates = ( + os.path.join(home, ".local", "bin"), + os.path.join(home, ".local", "share", "argos-translate"), + os.path.join(home, ".local", "share", "pipx"), + ) + paths: list[str] = [] + for candidate in candidates: + existing = _existing_dir(candidate) + if existing and existing not in paths: + paths.append(existing) + return paths + + def _collect_read_roots() -> list[str]: roots = { "/usr", @@ -369,6 +387,8 @@ def _collect_read_roots() -> list[str]: prefix = _existing_dir(prefix_candidate) if prefix: roots.add(prefix) + for root in _collect_user_local_cli_roots(): + roots.add(root) return sorted(roots) @@ -391,6 +411,11 @@ def _collect_rw_roots( paths.append(existing) if os.path.isdir("/dev"): paths.append("/dev") + argos_share = _existing_dir( + os.path.join(os.path.expanduser("~"), ".local", "share", "argos-translate"), + ) + if argos_share and argos_share not in paths: + paths.append(argos_share) return paths diff --git a/meshchatx/src/backend/translator_handler.py b/meshchatx/src/backend/translator_handler.py index d883024d..184d3815 100644 --- a/meshchatx/src/backend/translator_handler.py +++ b/meshchatx/src/backend/translator_handler.py @@ -223,12 +223,14 @@ class TranslatorHandler: ) except Exception as e: print(f"Failed to fetch Argos languages: {e}") - elif self.has_argos_cli: - try: - cli_langs = self._get_argos_languages_cli() - languages.extend(cli_langs) - except Exception as e: - print(f"Failed to fetch Argos languages via CLI: {e}") + if self.has_argos_cli: + has_argos_langs = any(lang.get("source") == "argos" for lang in languages) + if not has_argos_langs: + try: + cli_langs = self._get_argos_languages_cli() + languages.extend(cli_langs) + except Exception as e: + print(f"Failed to fetch Argos languages via CLI: {e}") return { "languages": languages, diff --git a/meshchatx/src/frontend/components/messages/ConversationViewer.vue b/meshchatx/src/frontend/components/messages/ConversationViewer.vue index fdd13b1b..5b3c8358 100644 --- a/meshchatx/src/frontend/components/messages/ConversationViewer.vue +++ b/meshchatx/src/frontend/components/messages/ConversationViewer.vue @@ -1789,7 +1789,13 @@ export default { required: true, }, }, - emits: ["close", "reload-conversations", "update:selectedPeer", "update-peer-tracking"], + emits: [ + "close", + "reload-conversations", + "update:selectedPeer", + "update-peer-tracking", + "outbound-compose-enqueued", + ], data() { return { GlobalState, @@ -5619,6 +5625,15 @@ export default { ); await this._sendMessageChain; }, + isSelfLxmfDestination(peerHash) { + if (!peerHash || typeof peerHash !== "string") { + return false; + } + const mine = (this.myLxmfAddressHash || "").toLowerCase(); + const identity = (this.config?.identity_hash || "").toLowerCase(); + const peer = peerHash.toLowerCase(); + return (mine && peer === mine) || (identity && peer === identity); + }, async _enqueueOutboundFromCompose() { try { const job = await this.buildOutboundJobSnapshot(); @@ -5626,6 +5641,16 @@ export default { return; } this._outboundQueue.enqueue(job); + if (this.isSelfLxmfDestination(job.destinationHash)) { + ToastUtils.info(this.$t("messages.self_notes_toast")); + } + this.$emit("outbound-compose-enqueued", { + peerHash: job.destinationHash, + previewText: job.text, + title: "", + fields: job.fields || {}, + pendingHash: job.pendingHash, + }); this.clearComposeAfterEnqueue(); this.$nextTick(() => { this.adjustTextareaHeight(); diff --git a/meshchatx/src/frontend/components/messages/MessagesPage.vue b/meshchatx/src/frontend/components/messages/MessagesPage.vue index 9b4b915e..3be9f035 100644 --- a/meshchatx/src/frontend/components/messages/MessagesPage.vue +++ b/meshchatx/src/frontend/components/messages/MessagesPage.vue @@ -97,6 +97,7 @@ @update-peer-tracking="onUpdatePeerTracking" @close="onPaneClose(pane.id)" @reload-conversations="requestConversationsRefresh" + @outbound-compose-enqueued="onOutboundComposeEnqueued" />
c.destination_hash === peerHash); + if (idx !== -1) { + const conv = this.conversations[idx]; + conv.latest_message_preview = preview; + conv.latest_message_title = stub.title; + conv.latest_message_created_at = nowSec; + conv.updated_at = new Date(nowSec * 1000).toISOString(); + } else { + const peer = this.peers[peerHash]; + this.conversations.unshift({ + destination_hash: peerHash, + display_name: peer?.display_name ?? this.selectedPeer?.display_name ?? "Anonymous Peer", + custom_display_name: peer?.custom_display_name ?? this.selectedPeer?.custom_display_name ?? null, + contact_image: peer?.contact_image ?? null, + lxmf_user_icon: peer?.lxmf_user_icon ?? null, + is_unread: false, + is_tracking: peer?.is_tracking ?? false, + failed_messages_count: 0, + has_attachments: false, + latest_message_preview: preview, + latest_message_title: stub.title, + latest_message_created_at: nowSec, + updated_at: new Date(nowSec * 1000).toISOString(), + is_contact: false, + }); + this.resolvePeerDisplayName(peerHash); + } + }, onOutboundMessageCreated(msg) { const peerHash = this.peerHashFromMessage(msg); const peerDisplay = this.peerDisplayNameForConversationSidebar(peerHash); diff --git a/meshchatx/src/frontend/js/outboundMessageStatus.js b/meshchatx/src/frontend/js/outboundMessageStatus.js index 7f6747c0..964745d5 100644 --- a/meshchatx/src/frontend/js/outboundMessageStatus.js +++ b/meshchatx/src/frontend/js/outboundMessageStatus.js @@ -22,7 +22,7 @@ export function outboundBubbleStatusIconName(lxmfMessage) { if (method === "propagated") { return "email-check-outline"; } - if (method === "paper") { + if (method === "paper" || method === "local") { return "note-check-outline"; } return "check-all"; @@ -31,7 +31,7 @@ export function outboundBubbleStatusIconName(lxmfMessage) { if (method === "propagated") { return "email-outline"; } - if (method === "paper") { + if (method === "paper" || method === "local") { return "note-outline"; } return "check"; @@ -51,8 +51,14 @@ export function outboundBubbleStatusTitleKey(lxmfMessage) { if (lxmfMessage.method === "propagated") { return "messages.outbound_delivered_propagated"; } + if (lxmfMessage.method === "local") { + return "messages.outbound_delivered_local"; + } return "messages.outbound_delivered"; } + if (lxmfMessage.method === "local") { + return "messages.outbound_saved_local"; + } if (lxmfMessage.method === "propagated") { return "messages.outbound_on_propagation_node"; } diff --git a/meshchatx/src/frontend/locales/de.json b/meshchatx/src/frontend/locales/de.json index e79174af..36baafd6 100644 --- a/meshchatx/src/frontend/locales/de.json +++ b/meshchatx/src/frontend/locales/de.json @@ -1734,6 +1734,9 @@ "outbound_sent_network": "Gesendet", "outbound_delivered": "Zugestellt", "outbound_delivered_propagated": "Über Propagation zugestellt", + "outbound_delivered_local": "Auf diesem Gerät gespeichert", + "outbound_saved_local": "Persönliche Notiz auf diesem Gerät", + "self_notes_toast": "Persönliche Notizen bleiben auf diesem Gerät und werden nicht über das Mesh gesendet.", "more_actions": "Weitere Aktionen", "retry_failed": "Fehlgeschlagene erneut senden", "telemetry_history": "Telemetrie-Verlauf", diff --git a/meshchatx/src/frontend/locales/en.json b/meshchatx/src/frontend/locales/en.json index 8ebc5e48..a86a9081 100644 --- a/meshchatx/src/frontend/locales/en.json +++ b/meshchatx/src/frontend/locales/en.json @@ -1702,6 +1702,9 @@ "outbound_sent_network": "Sent", "outbound_delivered": "Delivered", "outbound_delivered_propagated": "Delivered via propagation", + "outbound_delivered_local": "Saved on this device", + "outbound_saved_local": "Personal note on this device", + "self_notes_toast": "Personal notes stay on this device. They are not sent over the mesh.", "no_messages_in_conversation": "No messages in this conversation yet.", "say_hello": "Say hello!", "no_active_chat": "No Active Chat", diff --git a/meshchatx/src/frontend/locales/es.json b/meshchatx/src/frontend/locales/es.json index 4a315be9..b413ca7b 100644 --- a/meshchatx/src/frontend/locales/es.json +++ b/meshchatx/src/frontend/locales/es.json @@ -1702,6 +1702,9 @@ "outbound_sent_network": "Enviado", "outbound_delivered": "Entregado", "outbound_delivered_propagated": "Entregado vía propagación", + "outbound_delivered_local": "Guardado en este dispositivo", + "outbound_saved_local": "Nota personal en este dispositivo", + "self_notes_toast": "Las notas personales permanecen en este dispositivo y no se envían por la mesh.", "no_messages_in_conversation": "Aún no hay mensajes en esta conversación.", "say_hello": "¡Saluda!", "no_active_chat": "Sin chat activo", diff --git a/meshchatx/src/frontend/locales/fi.json b/meshchatx/src/frontend/locales/fi.json index a99b2aaf..f30d3047 100644 --- a/meshchatx/src/frontend/locales/fi.json +++ b/meshchatx/src/frontend/locales/fi.json @@ -1702,6 +1702,9 @@ "outbound_sent_network": "Lähetetty", "outbound_delivered": "Toimitettu", "outbound_delivered_propagated": "Toimitettu levityksen kautta", + "outbound_delivered_local": "Tallennettu tälle laitteelle", + "outbound_saved_local": "Henkilökohtainen muistiinpano tällä laitteella", + "self_notes_toast": "Henkilökohtaiset muistiinpanot pysyvät tällä laitteella eivätkä lähde mesh-verkkoon.", "no_messages_in_conversation": "Tässä keskustelussa ei ole viestejä.", "say_hello": "Sano hei!", "no_active_chat": "Ei valittua keskustelua", diff --git a/meshchatx/src/frontend/locales/fr.json b/meshchatx/src/frontend/locales/fr.json index 0e2a67bc..b80921d5 100644 --- a/meshchatx/src/frontend/locales/fr.json +++ b/meshchatx/src/frontend/locales/fr.json @@ -1702,6 +1702,9 @@ "outbound_sent_network": "Envoyé", "outbound_delivered": "Distribué", "outbound_delivered_propagated": "Distribué via propagation", + "outbound_delivered_local": "Enregistré sur cet appareil", + "outbound_saved_local": "Note personnelle sur cet appareil", + "self_notes_toast": "Les notes personnelles restent sur cet appareil et ne sont pas envoyées sur le mesh.", "no_messages_in_conversation": "Pas encore de messages dans cette conversation.", "say_hello": "Dis bonjour !", "no_active_chat": "Pas de chat actif", diff --git a/meshchatx/src/frontend/locales/it.json b/meshchatx/src/frontend/locales/it.json index 67384a78..6bd12072 100644 --- a/meshchatx/src/frontend/locales/it.json +++ b/meshchatx/src/frontend/locales/it.json @@ -1734,6 +1734,9 @@ "outbound_sent_network": "Inviato", "outbound_delivered": "Consegnato", "outbound_delivered_propagated": "Consegnato via propagazione", + "outbound_delivered_local": "Salvato su questo dispositivo", + "outbound_saved_local": "Nota personale su questo dispositivo", + "self_notes_toast": "Le note personali restano su questo dispositivo e non vengono inviate sulla mesh.", "more_actions": "Altre azioni", "retry_failed": "Riprova messaggi non inviati", "telemetry_history": "Cronologia telemetria", diff --git a/meshchatx/src/frontend/locales/nl.json b/meshchatx/src/frontend/locales/nl.json index fcf9e29d..891c1656 100644 --- a/meshchatx/src/frontend/locales/nl.json +++ b/meshchatx/src/frontend/locales/nl.json @@ -1702,6 +1702,9 @@ "outbound_sent_network": "Verzonden", "outbound_delivered": "Afgeleverd", "outbound_delivered_propagated": "Afgeleverd via propagatie", + "outbound_delivered_local": "Opgeslagen op dit apparaat", + "outbound_saved_local": "Persoonlijke notitie op dit apparaat", + "self_notes_toast": "Persoonlijke notities blijven op dit apparaat en gaan niet over het mesh.", "no_messages_in_conversation": "Nog geen berichten in dit gesprek.", "say_hello": "Zeg hallo!", "no_active_chat": "Geen actief gesprek", diff --git a/meshchatx/src/frontend/locales/ru.json b/meshchatx/src/frontend/locales/ru.json index bd7b74e1..473075ab 100644 --- a/meshchatx/src/frontend/locales/ru.json +++ b/meshchatx/src/frontend/locales/ru.json @@ -1734,6 +1734,9 @@ "outbound_sent_network": "Отправлено", "outbound_delivered": "Доставлено", "outbound_delivered_propagated": "Доставлено через распространение", + "outbound_delivered_local": "Сохранено на этом устройстве", + "outbound_saved_local": "Личная заметка на этом устройстве", + "self_notes_toast": "Личные заметки остаются на этом устройстве и не отправляются в сеть.", "more_actions": "Другие действия", "retry_failed": "Повторить неудачные", "telemetry_history": "История телеметрии", diff --git a/meshchatx/src/frontend/locales/zh.json b/meshchatx/src/frontend/locales/zh.json index c28f5397..c8d92166 100644 --- a/meshchatx/src/frontend/locales/zh.json +++ b/meshchatx/src/frontend/locales/zh.json @@ -1702,6 +1702,9 @@ "outbound_sent_network": "已发送", "outbound_delivered": "已送达", "outbound_delivered_propagated": "已通过传播送达", + "outbound_delivered_local": "已保存在本设备", + "outbound_saved_local": "本设备个人备忘", + "self_notes_toast": "个人备忘仅保存在本设备,不会通过 mesh 发送。", "no_messages_in_conversation": "对话中尚无消息", "say_hello": "打个招呼!", "no_active_chat": "无活动聊天", diff --git a/package.json b/package.json index 74086c41..cdb52977 100644 --- a/package.json +++ b/package.json @@ -35,6 +35,7 @@ "test:i18n": "vitest run tests/frontend/i18n.test.js", "test:loadtime": "vitest run tests/frontend/LoadTimePerformance.test.js", "test:ui": "vitest --ui --open --exclude tests/frontend/LoadTimePerformance.test.js", + "pretest:e2e": "playwright install chromium", "test:e2e": "playwright test", "test:e2e:ui": "playwright test --ui", "test:e2e:install": "playwright install chromium", diff --git a/scripts/ci/android-emulator-smoke.sh b/scripts/ci/android-emulator-smoke.sh index 9ed4bffb..ecd3122c 100755 --- a/scripts/ci/android-emulator-smoke.sh +++ b/scripts/ci/android-emulator-smoke.sh @@ -10,6 +10,10 @@ ACTIVITY="${MESHCHATX_ANDROID_ACTIVITY:-com.meshchatx/.MainActivity}" STATUS_PATH="${MESHCHATX_SMOKE_STATUS_PATH:-/api/v1/status}" TIMEOUT_SEC="${MESHCHATX_SMOKE_TIMEOUT_SEC:-180}" LOGCAT_TAG_FILTER="${MESHCHATX_SMOKE_LOGCAT_FILTER:-Python|meshchat|MeshChat|chaquo|AndroidRuntime}" +DEVICE_HTTP_PORT="${MESHCHATX_SMOKE_DEVICE_PORT:-8000}" +FORWARD_LOCAL_PORT="${MESHCHATX_SMOKE_FORWARD_PORT:-18080}" +PROBE_CURL_MAX_TIME="${MESHCHATX_SMOKE_PROBE_MAX_TIME:-5}" +last_probe_body="" APK="${1:-${MESHCHATX_APK:-}}" if [[ -z "${APK}" ]]; then @@ -53,11 +57,25 @@ cleanup_logcat() { kill "${logcat_pid}" >/dev/null 2>&1 || true wait "${logcat_pid}" >/dev/null 2>&1 || true } -trap 'cleanup_logcat; rm -rf "${workdir}"' EXIT + +teardown_adb_forward() { + adb forward --remove "tcp:${FORWARD_LOCAL_PORT}" >/dev/null 2>&1 || true +} + +setup_adb_forward() { + teardown_adb_forward + adb forward "tcp:${FORWARD_LOCAL_PORT}" "tcp:${DEVICE_HTTP_PORT}" +} + +trap 'teardown_adb_forward; cleanup_logcat; rm -rf "${workdir}"' EXIT fail_with_logs() { local reason="$1" echo "::error::Android emulator smoke failed: ${reason}" + if [[ -n "${last_probe_body}" ]]; then + echo "---- last /api/v1/status probe body ----" + printf '%s\n' "${last_probe_body}" + fi echo "---- logcat (filtered) ----" grep -E "${LOGCAT_TAG_FILTER}|PyException|SystemExit|ModuleNotFoundError|StorageLock|backend failed|Error starting MeshChatX" \ "${logcat_file}" | tail -n 200 || true @@ -66,22 +84,67 @@ fail_with_logs() { exit 1 } -probe_status_ok() { - # Probe from inside the emulator (server binds 127.0.0.1 on-device). - # Prefer toybox wget (API 30+ images). Fall back to python if present. +status_body_is_healthy() { + local body="$1" + if [[ -z "${body}" ]]; then + return 1 + fi + if printf '%s' "${body}" | grep -qE '"status"[[:space:]]*:[[:space:]]*"failed"'; then + return 1 + fi + # ok: network ready. starting: HTTP up while RNS finishes (normal on Android boot). + printf '%s' "${body}" | grep -qE '"status"[[:space:]]*:[[:space:]]*"(ok|starting)"' +} + +probe_status_via_host_forward() { local body="" - if adb shell "command -v wget >/dev/null 2>&1" >/dev/null 2>&1; then - body="$(adb shell "wget -qO- --no-check-certificate https://127.0.0.1:8000${STATUS_PATH}" 2>/dev/null | tr -d '\r' || true)" + if ! command -v curl >/dev/null 2>&1; then + return 1 + fi + body="$( + curl -ksS --max-time "${PROBE_CURL_MAX_TIME}" \ + "https://127.0.0.1:${FORWARD_LOCAL_PORT}${STATUS_PATH}" 2>/dev/null || true + )" + if [[ -z "${body}" ]]; then + body="$( + curl -fsS --max-time "${PROBE_CURL_MAX_TIME}" \ + "http://127.0.0.1:${FORWARD_LOCAL_PORT}${STATUS_PATH}" 2>/dev/null || true + )" + fi + if [[ -z "${body}" ]]; then + return 1 + fi + last_probe_body="${body}" + status_body_is_healthy "${body}" +} + +probe_status_via_device_shell() { + # Fallback when host curl or adb forward is unavailable. + local body="" + local device_base="127.0.0.1:${DEVICE_HTTP_PORT}" + if adb shell "command -v curl >/dev/null 2>&1" >/dev/null 2>&1; then + body="$( + adb shell "curl -ksS --max-time ${PROBE_CURL_MAX_TIME} https://${device_base}${STATUS_PATH}" \ + 2>/dev/null | tr -d '\r' || true + )" if [[ -z "${body}" ]]; then - body="$(adb shell "wget -qO- http://127.0.0.1:8000${STATUS_PATH}" 2>/dev/null | tr -d '\r' || true)" + body="$( + adb shell "curl -fsS --max-time ${PROBE_CURL_MAX_TIME} http://${device_base}${STATUS_PATH}" \ + 2>/dev/null | tr -d '\r' || true + )" fi - elif adb shell "command -v curl >/dev/null 2>&1" >/dev/null 2>&1; then - body="$(adb shell "curl -ksS --max-time 3 https://127.0.0.1:8000${STATUS_PATH}" 2>/dev/null | tr -d '\r' || true)" + elif adb shell "command -v wget >/dev/null 2>&1" >/dev/null 2>&1; then + body="$( + adb shell "wget -qO- -T ${PROBE_CURL_MAX_TIME} --no-check-certificate https://${device_base}${STATUS_PATH}" \ + 2>/dev/null | tr -d '\r' || true + )" if [[ -z "${body}" ]]; then - body="$(adb shell "curl -fsS --max-time 3 http://127.0.0.1:8000${STATUS_PATH}" 2>/dev/null | tr -d '\r' || true)" + body="$( + adb shell "wget -qO- -T ${PROBE_CURL_MAX_TIME} http://${device_base}${STATUS_PATH}" \ + 2>/dev/null | tr -d '\r' || true + )" fi else - # Escape path for embedding in a double-quoted adb shell command. local py_path py_path="$(printf '%s' "${STATUS_PATH}" | sed 's/\\/\\\\/g; s/"/\\"/g')" body="$( @@ -89,12 +152,12 @@ probe_status_ok() { import ssl, urllib.request ctx = ssl._create_unverified_context() urls = ( - ('https://127.0.0.1:8000${py_path}', ctx), - ('http://127.0.0.1:8000${py_path}', None), + ('https://${device_base}${py_path}', ctx), + ('http://${device_base}${py_path}', None), ) for url, context in urls: try: - kwargs = {'timeout': 3} + kwargs = {'timeout': ${PROBE_CURL_MAX_TIME}} if context is not None: kwargs['context'] = context print(urllib.request.urlopen(url, **kwargs).read().decode()) @@ -104,9 +167,22 @@ for url, context in urls: \"" 2>/dev/null | tr -d '\r' || true )" fi - printf '%s' "${body}" | grep -q '"status"[[:space:]]*:[[:space:]]*"ok"' + if [[ -z "${body}" ]]; then + return 1 + fi + last_probe_body="${body}" + status_body_is_healthy "${body}" } +probe_status_ok() { + if probe_status_via_host_forward; then + return 0 + fi + probe_status_via_device_shell +} + +setup_adb_forward + echo "Waiting up to ${TIMEOUT_SEC}s for backend health (${STATUS_PATH})" deadline=$((SECONDS + TIMEOUT_SEC)) healthy=0 @@ -125,8 +201,8 @@ while (( SECONDS < deadline )); do done if [[ "${healthy}" -ne 1 ]]; then - fail_with_logs "backend /api/v1/status did not become ok within ${TIMEOUT_SEC}s" + fail_with_logs "backend ${STATUS_PATH} did not return ok or starting within ${TIMEOUT_SEC}s" fi -echo "Android emulator smoke OK: ${STATUS_PATH} returned status=ok" +echo "Android emulator smoke OK: ${STATUS_PATH} -> ${last_probe_body}" grep -E "${LOGCAT_TAG_FILTER}" "${logcat_file}" | tail -n 40 || true diff --git a/tests/backend/landlock_integration_support.py b/tests/backend/landlock_integration_support.py new file mode 100644 index 00000000..3763d528 --- /dev/null +++ b/tests/backend/landlock_integration_support.py @@ -0,0 +1,85 @@ +# SPDX-License-Identifier: 0BSD + +"""Helpers for Landlock integration probes (always run in a subprocess).""" + +from __future__ import annotations + +import os +import subprocess +import sys +import textwrap +from pathlib import Path + +import pytest + +from meshchatx.src.backend.landlock_sandbox import landlock_kernel_supported + +REPO_ROOT = Path(__file__).resolve().parents[2] + +APPLY_FAILED_MARKER = "APPLY_FAILED" + + +def landlock_integration_available() -> bool: + return sys.platform == "linux" and landlock_kernel_supported() + + +requires_landlock_integration = pytest.mark.skipif( + not landlock_integration_available(), + reason="Landlock apply requires a supported Linux kernel", +) + + +def run_python_under_landlock( + body: str, + *, + storage: str | Path, + reticulum_config_dir: str | Path | None = None, + extra_env: dict[str, str] | None = None, + timeout: float = 90, +) -> subprocess.CompletedProcess[str]: + """Execute body in a fresh Python process after apply_landlock_sandbox.""" + storage_s = str(storage) + rns_s = str(reticulum_config_dir) if reticulum_config_dir is not None else None + rns_kw = f", reticulum_config_dir={rns_s!r}" if rns_s is not None else "" + body_clean = textwrap.dedent(body).strip() + script = ( + "import os\n" + "import sys\n" + 'os.environ["MESHCHAT_LANDLOCK"] = "1"\n' + "from meshchatx.src.backend.landlock_sandbox import apply_landlock_sandbox\n" + f"storage = {storage_s!r}\n" + "ok = apply_landlock_sandbox(\n" + f" storage_dir=storage,\n" + f" log_dir=storage{rns_kw},\n" + ")\n" + "if not ok:\n" + f" print({APPLY_FAILED_MARKER!r})\n" + " sys.exit(2)\n" + f"{body_clean}\n" + ) + env = {**os.environ, **(extra_env or {})} + return subprocess.run( + [sys.executable, "-c", script], + cwd=str(REPO_ROOT), + capture_output=True, + text=True, + timeout=timeout, + check=False, + env=env, + stdin=subprocess.DEVNULL, + ) + + +def skip_if_landlock_not_applied(result: subprocess.CompletedProcess[str]) -> None: + if APPLY_FAILED_MARKER in (result.stdout or ""): + pytest.skip("Landlock could not be applied in this environment") + + +def assert_probe_ok( + result: subprocess.CompletedProcess[str], + *, + ok_marker: str = "OK", +) -> None: + skip_if_landlock_not_applied(result) + assert result.returncode == 0, (result.stdout, result.stderr) + assert ok_marker in (result.stdout or "") diff --git a/tests/backend/test_landlock_integration_surfaces.py b/tests/backend/test_landlock_integration_surfaces.py new file mode 100644 index 00000000..fb12c299 --- /dev/null +++ b/tests/backend/test_landlock_integration_surfaces.py @@ -0,0 +1,419 @@ +# SPDX-License-Identifier: 0BSD + +"""Landlock regression probes for subprocess and user-local tool surfaces.""" + +from __future__ import annotations + +import os +import shutil +import tempfile + +import pytest + +from meshchatx.src.backend import landlock_sandbox as ll +from tests.backend.landlock_integration_support import ( + assert_probe_ok, + requires_landlock_integration, + run_python_under_landlock, + skip_if_landlock_not_applied, +) + + +def test_collect_user_local_cli_roots_expected_layout(tmp_path, monkeypatch): + home = tmp_path / "home" + local_bin = home / ".local" / "bin" + pipx = home / ".local" / "share" / "pipx" + argos = home / ".local" / "share" / "argos-translate" + local_bin.mkdir(parents=True) + pipx.mkdir(parents=True) + argos.mkdir(parents=True) + monkeypatch.setenv("HOME", str(home)) + + roots = set(ll._collect_user_local_cli_roots()) + assert str(local_bin) in roots + assert str(pipx) in roots + assert str(argos) in roots + + +def test_collect_rw_roots_includes_argos_share_when_present(tmp_path, monkeypatch): + home = tmp_path / "home" + argos = home / ".local" / "share" / "argos-translate" + argos.mkdir(parents=True) + monkeypatch.setenv("HOME", str(home)) + + storage = tmp_path / "storage" + storage.mkdir() + rw = ll._collect_rw_roots(str(storage), None, str(storage)) + assert str(argos) in rw + + +@requires_landlock_integration +def test_landlock_denies_write_directly_under_home(tmp_path): + """HOME is not an RW root; only storage, temp, and explicit shares are.""" + storage = tmp_path / "storage" + storage.mkdir() + result = run_python_under_landlock( + """ + import os + import sys + + path = os.path.join( + os.path.expanduser("~"), + ".meshchatx_landlock_write_probe", + ) + try: + with open(path, "w", encoding="utf-8") as handle: + handle.write("x") + except PermissionError: + print("OK") + sys.exit(0) + try: + os.remove(path) + except OSError: + pass + print("WRITE_SHOULD_HAVE_FAILED") + sys.exit(3) + """, + storage=storage, + ) + assert_probe_ok(result) + + +@requires_landlock_integration +def test_landlock_allows_storage_and_temp_writes(tmp_path): + storage = tmp_path / "storage" + storage.mkdir() + result = run_python_under_landlock( + f""" + import os + import sys + import tempfile + + storage = {str(storage)!r} + path = os.path.join(storage, "rw-check.txt") + with open(path, "w", encoding="utf-8") as handle: + handle.write("storage") + with tempfile.NamedTemporaryFile( + mode="w", + encoding="utf-8", + delete=True, + ) as tmp: + tmp.write("temp") + print("OK") + sys.exit(0) + """, + storage=storage, + ) + assert_probe_ok(result) + + +@requires_landlock_integration +def test_landlock_child_interpreter_spawn(): + """Same pattern as self_check.check_subprocess_spawn.""" + storage = tempfile.mkdtemp(prefix="meshchat_ll_spawn_") + result = run_python_under_landlock( + """ + import subprocess + import sys + + child = subprocess.run( + [sys.executable, "-c", "print('meshchatx-spawn-ok', flush=True)"], + capture_output=True, + text=True, + timeout=30, + check=False, + stdin=subprocess.DEVNULL, + ) + if child.returncode != 0: + print("SPAWN_RC", child.returncode, child.stderr) + sys.exit(4) + if "meshchatx-spawn-ok" not in (child.stdout or ""): + print("SPAWN_BAD_OUT", child.stdout) + sys.exit(5) + print("OK") + sys.exit(0) + """, + storage=storage, + ) + assert_probe_ok(result) + + +@requires_landlock_integration +def test_landlock_self_check_subprocess_spawn_helper(): + storage = tempfile.mkdtemp(prefix="meshchat_ll_selfcheck_") + result = run_python_under_landlock( + """ + import sys + from meshchatx.src.backend.self_check import check_subprocess_spawn + + out = check_subprocess_spawn() + if out.get("status") != "ok": + print("SELF_CHECK_FAIL", out.get("reason", "")) + sys.exit(3) + print("OK") + sys.exit(0) + """, + storage=storage, + ) + assert_probe_ok(result) + + +@requires_landlock_integration +def test_landlock_translator_handler_lists_argos_languages(tmp_path): + storage = tmp_path / "storage" + storage.mkdir() + result = run_python_under_landlock( + """ + import sys + from meshchatx.src.backend.translator_handler import TranslatorHandler + + handler = TranslatorHandler(translator_argos_enabled=True) + if not handler.has_argos: + print("SKIP_NO_ARGOS") + sys.exit(0) + resp = handler.get_translator_languages_response() + argos = [x for x in resp["languages"] if x.get("source") == "argos"] + if not argos: + print("EMPTY_ARGOS_LANGS has_lib", handler.has_argos_lib, "has_cli", handler.has_argos_cli) + sys.exit(4) + print("OK", len(argos)) + sys.exit(0) + """, + storage=storage, + timeout=120, + ) + skip_if_landlock_not_applied(result) + if "SKIP_NO_ARGOS" in (result.stdout or ""): + pytest.skip("Argos Translate not installed on this host") + assert_probe_ok(result) + + +@requires_landlock_integration +def test_landlock_argospm_list_when_installed(tmp_path): + if not shutil.which("argospm"): + pytest.skip("argospm not on PATH") + storage = tmp_path / "storage" + storage.mkdir() + result = run_python_under_landlock( + """ + import subprocess + import sys + + proc = subprocess.run( + ["argospm", "list"], + capture_output=True, + text=True, + timeout=30, + check=False, + ) + if proc.returncode != 0: + print("ARGOSPM_FAIL", proc.stderr or proc.stdout) + sys.exit(3) + if not (proc.stdout or "").strip(): + print("ARGOSPM_EMPTY") + sys.exit(4) + print("OK") + sys.exit(0) + """, + storage=storage, + ) + assert_probe_ok(result) + + +@requires_landlock_integration +def test_landlock_argos_translate_cli_when_installed(tmp_path): + executable = shutil.which("argos-translate") + if not executable: + pytest.skip("argos-translate not on PATH") + storage = tmp_path / "storage" + storage.mkdir() + result = run_python_under_landlock( + f""" + import subprocess + import sys + + proc = subprocess.run( + [{executable!r}, "--from-lang", "en", "--to-lang", "ru", "hello"], + capture_output=True, + text=True, + timeout=120, + check=False, + ) + if proc.returncode != 0: + print("TRANSLATE_FAIL", proc.stderr or proc.stdout) + sys.exit(3) + if not (proc.stdout or "").strip(): + print("TRANSLATE_EMPTY") + sys.exit(4) + print("OK") + sys.exit(0) + """, + storage=storage, + timeout=180, + ) + skip_if_landlock_not_applied(result) + if result.returncode != 0 and "translate-en_ru" in ( + result.stdout or result.stderr or "" + ): + pytest.skip("translate-en_ru package not installed") + assert_probe_ok(result) + + +@requires_landlock_integration +def test_landlock_argos_share_allows_stanza_style_temp_dir(tmp_path): + home = os.path.expanduser("~") + argos_share = os.path.join(home, ".local", "share", "argos-translate") + if not os.path.isdir(argos_share): + pytest.skip("Argos data directory not present") + storage = tmp_path / "storage" + storage.mkdir() + result = run_python_under_landlock( + f""" + import os + import sys + import tempfile + + base = {argos_share!r} + with tempfile.TemporaryDirectory(dir=base) as td: + path = os.path.join(td, "probe.txt") + with open(path, "w", encoding="utf-8") as handle: + handle.write("ok") + print("OK") + sys.exit(0) + """, + storage=storage, + ) + assert_probe_ok(result) + + +@requires_landlock_integration +def test_landlock_user_local_bin_script_executable(tmp_path): + local_bin = os.path.join(os.path.expanduser("~"), ".local", "bin") + if not os.path.isdir(local_bin): + pytest.skip("~/.local/bin not present") + preferred = ("argospm", "argos-translate", "argostranslate", "git-remote-rns") + cmd = None + for name in preferred: + path = os.path.join(local_bin, name) + if os.path.isfile(path) and os.access(path, os.X_OK): + cmd = path + break + if cmd is None: + pytest.skip("no known pipx-style CLI in ~/.local/bin") + storage = tmp_path / "storage" + storage.mkdir() + result = run_python_under_landlock( + f""" + import subprocess + import sys + + proc = subprocess.run( + [{cmd!r}], + capture_output=True, + text=True, + timeout=10, + check=False, + ) + if proc.returncode != 0: + print("EXEC_FAIL", proc.stderr) + sys.exit(3) + print("OK") + sys.exit(0) + """, + storage=storage, + ) + assert_probe_ok(result) + + +@requires_landlock_integration +def test_landlock_git_version_when_git_on_path(tmp_path): + git = shutil.which("git") + if not git: + pytest.skip("git not on PATH") + storage = tmp_path / "storage" + storage.mkdir() + result = run_python_under_landlock( + f""" + import subprocess + import sys + + proc = subprocess.run( + [{git!r}, "--version"], + capture_output=True, + text=True, + timeout=15, + check=False, + ) + if proc.returncode != 0: + print("GIT_FAIL", proc.stderr) + sys.exit(3) + if "git version" not in (proc.stdout or ""): + print("GIT_BAD_OUT", proc.stdout) + sys.exit(4) + print("OK") + sys.exit(0) + """, + storage=storage, + ) + assert_probe_ok(result) + + +@requires_landlock_integration +def test_landlock_rnsh_module_spawn_when_installed(tmp_path): + try: + import importlib.util + + if importlib.util.find_spec("RNS.Utilities.rnsh.rnsh") is None: + pytest.skip("rnsh module not installed") + except (ImportError, ValueError, AttributeError): + pytest.skip("rnsh module not installed") + + storage = tmp_path / "storage" + rns_dir = tmp_path / "rns" + rns_dir.mkdir() + storage.mkdir() + result = run_python_under_landlock( + """ + import subprocess + import sys + + proc = subprocess.run( + [sys.executable, "-m", "RNS.Utilities.rnsh.rnsh", "--help"], + capture_output=True, + text=True, + timeout=20, + check=False, + ) + out = (proc.stdout or "") + (proc.stderr or "") + if proc.returncode not in (0, 1, 2) and "rnsh" not in out.lower(): + print("RNSH_HELP_FAIL", proc.returncode, out[-500:]) + sys.exit(3) + print("OK") + sys.exit(0) + """, + storage=storage, + reticulum_config_dir=rns_dir, + ) + assert_probe_ok(result) + + +@pytest.mark.parametrize( + ("path_name", "collector"), + [ + ("local_bin", lambda home: os.path.join(home, ".local", "bin")), + ("pipx", lambda home: os.path.join(home, ".local", "share", "pipx")), + ( + "argos_share", + lambda home: os.path.join(home, ".local", "share", "argos-translate"), + ), + ], +) +def test_read_roots_cover_user_local_paths_when_present(path_name, collector): + home = os.path.expanduser("~") + target = collector(home) + if not os.path.isdir(target): + pytest.skip(f"{path_name} not present on this host") + roots = ll._collect_read_roots() + assert any( + target == root or target.startswith(root.rstrip("/") + "/") for root in roots + ), f"{target!r} not covered by landlock read roots ({path_name})" diff --git a/tests/backend/test_landlock_sandbox.py b/tests/backend/test_landlock_sandbox.py index 5e417f91..715968df 100644 --- a/tests/backend/test_landlock_sandbox.py +++ b/tests/backend/test_landlock_sandbox.py @@ -97,6 +97,17 @@ def test_collect_read_roots_includes_proc_for_psutil(): assert "/proc" in roots +def test_collect_read_roots_includes_user_local_cli_when_present(): + home = os.path.expanduser("~") + local_bin = os.path.join(home, ".local", "bin") + roots = ll._collect_read_roots() + if os.path.isdir(local_bin): + assert any( + local_bin == root or local_bin.startswith(root.rstrip("/") + "/") + for root in roots + ), f"{local_bin!r} not covered by landlock read roots" + + def test_collect_read_roots_includes_interpreter_prefix(): roots = ll._collect_read_roots() exe = os.path.realpath(sys.executable) @@ -210,24 +221,19 @@ def test_landlock_abi_version_on_linux(): ) def test_apply_landlock_preserves_storage_write_and_truncate(tmp_path): """Apply sandbox in a subprocess and confirm RW + truncate still work.""" - import subprocess - import textwrap - from pathlib import Path + from tests.backend.landlock_integration_support import ( + assert_probe_ok, + run_python_under_landlock, + ) storage = tmp_path / "storage" storage.mkdir() - script = textwrap.dedent( + result = run_python_under_landlock( f""" import os import sys - from meshchatx.src.backend.landlock_sandbox import apply_landlock_sandbox storage = {str(storage)!r} - os.environ["MESHCHAT_LANDLOCK"] = "1" - ok = apply_landlock_sandbox(storage_dir=storage, log_dir=storage) - if not ok: - print("APPLY_FAILED") - sys.exit(2) path = os.path.join(storage, "landlock-abi-check.txt") with open(path, "w", encoding="utf-8") as handle: handle.write("hello") @@ -239,17 +245,52 @@ def test_apply_landlock_preserves_storage_write_and_truncate(tmp_path): print("TRUNCATE_FAILED", repr(data)) sys.exit(3) print("OK") + sys.exit(0) + """, + storage=storage, + ) + assert_probe_ok(result) + + +@pytest.mark.skipif( + sys.platform != "linux" or not ll.landlock_kernel_supported(), + reason="Landlock apply requires a supported Linux kernel", +) +def test_apply_landlock_allows_user_local_argospm_list(tmp_path): + """Pipx Argos under ~/.local must remain usable for translator language lists.""" + import shutil + + from tests.backend.landlock_integration_support import ( + assert_probe_ok, + run_python_under_landlock, + ) + + if not shutil.which("argospm"): + pytest.skip("argospm not on PATH") + + storage = tmp_path / "storage" + storage.mkdir() + result = run_python_under_landlock( """ + import subprocess + import sys + + proc = subprocess.run( + ["argospm", "list"], + capture_output=True, + text=True, + timeout=30, + check=False, + ) + if proc.returncode != 0: + print("ARGOSPM_FAILED", proc.stderr or proc.stdout) + sys.exit(3) + if not (proc.stdout or "").strip(): + print("ARGOSPM_EMPTY") + sys.exit(4) + print("OK") + sys.exit(0) + """, + storage=storage, ) - result = subprocess.run( - [sys.executable, "-c", script], - cwd=str(Path(__file__).resolve().parents[2]), - capture_output=True, - text=True, - timeout=30, - check=False, - ) - if "APPLY_FAILED" in result.stdout: - pytest.skip("Landlock could not be applied in this environment") - assert result.returncode == 0, (result.stdout, result.stderr) - assert "OK" in result.stdout + assert_probe_ok(result) diff --git a/tests/backend/test_lxmf_local_self_message.py b/tests/backend/test_lxmf_local_self_message.py new file mode 100644 index 00000000..9bcf9ffd --- /dev/null +++ b/tests/backend/test_lxmf_local_self_message.py @@ -0,0 +1,147 @@ +# SPDX-License-Identifier: 0BSD + +from unittest.mock import AsyncMock, MagicMock, patch + +import LXMF +import pytest + +LOCAL_LXMF = "a1" * 16 +REMOTE_PEER = "c3" * 16 + + +def _fake_lxmf_message(): + fake_lxm = MagicMock() + fake_lxm.fields = {} + fake_lxm.hash = MagicMock(hex=lambda: "d4" * 16) + fake_lxm.source_hash = MagicMock(hex=lambda: LOCAL_LXMF) + fake_lxm.destination_hash = MagicMock(hex=lambda: LOCAL_LXMF) + fake_lxm.incoming = False + fake_lxm.progress = 0 + fake_lxm.delivery_attempts = 0 + fake_lxm.next_delivery_attempt = None + fake_lxm.rssi = None + fake_lxm.snr = None + fake_lxm.q = None + fake_lxm.title = b"" + fake_lxm.timestamp = 1_700_000_000 + fake_lxm.state = LXMF.LXMessage.OUTBOUND + fake_lxm.method = LXMF.LXMessage.DIRECT + fake_lxm.get_fields = MagicMock(return_value={}) + fake_lxm.content = "" + fake_lxm.include_ticket = False + return fake_lxm + + +def _lxmf_message_factory(*args, **kwargs): + msg = _fake_lxmf_message() + if len(args) >= 3: + raw = args[2] + if isinstance(raw, bytes): + msg.content = raw + else: + msg.content = (raw or "").encode("utf-8") + return msg + + +@pytest.fixture +def local_self_app(mock_app): + ctx = mock_app.current_context + ctx.config.auto_send_failed_messages_to_propagation_node.set(False) + ctx.message_router.delivery_link_available.return_value = True + ctx.message_router.handle_outbound = MagicMock() + ctx.local_lxmf_destination = MagicMock() + ctx.local_lxmf_destination.hexhash = LOCAL_LXMF + ctx.local_lxmf_destination.hash = bytes.fromhex(LOCAL_LXMF) + mock_app.recall_identity = MagicMock(return_value=ctx.identity) + mock_app.get_current_icon_hash = MagicMock(return_value=None) + mock_app._await_transport_path = AsyncMock() + mock_app._convert_webm_opus_to_ogg = MagicMock(side_effect=lambda b: b) + mock_app.websocket_broadcast = AsyncMock() + if getattr(mock_app, "reticulum", None) is not None: + mock_app.reticulum.get_packet_rssi = MagicMock(return_value=None) + mock_app.reticulum.get_packet_snr = MagicMock(return_value=None) + mock_app.reticulum.get_packet_q = MagicMock(return_value=None) + return mock_app + + +def test_is_self_lxmf_destination_by_lxmf_hash(local_self_app): + app = local_self_app + assert app._is_self_lxmf_destination(LOCAL_LXMF) is True + assert app._is_self_lxmf_destination(LOCAL_LXMF.upper()) is True + assert app._is_self_lxmf_destination(REMOTE_PEER) is False + + +def test_is_self_lxmf_destination_by_identity_hash(local_self_app): + app = local_self_app + identity_hex = app.current_context.identity.hash.hex() + assert app._is_self_lxmf_destination(identity_hex) is True + + +@pytest.mark.asyncio +async def test_send_to_self_persists_delivered_local_without_router(local_self_app): + app = local_self_app + fake_destination = MagicMock() + fake_destination.hash = bytes.fromhex(LOCAL_LXMF) + app.db_upsert_lxmf_message = MagicMock() + + with ( + patch("meshchatx.meshchat.RNS.Destination", return_value=fake_destination), + patch("meshchatx.meshchat.LXMF.LXMessage", side_effect=_lxmf_message_factory), + patch( + "meshchatx.src.backend.lxmf_utils.lxmf_message_solving_stamps", + return_value=False, + ), + patch("meshchatx.meshchat.AsyncUtils.run_async"), + ): + await app.send_message( + destination_hash=LOCAL_LXMF, + content="note to self", + ) + + app._await_transport_path.assert_not_called() + app.current_context.message_router.handle_outbound.assert_not_called() + app.db_upsert_lxmf_message.assert_called_once() + upsert_kw = app.db_upsert_lxmf_message.call_args.kwargs + assert upsert_kw.get("state_override") == "delivered" + assert upsert_kw.get("method_override") == "local" + app.websocket_broadcast.assert_awaited() + payload = app.websocket_broadcast.await_args[0][0] + assert "local" in payload and "delivered" in payload + + +@pytest.mark.asyncio +async def test_send_to_self_by_identity_hash(local_self_app): + app = local_self_app + identity_hex = app.current_context.identity.hash.hex() + app.get_lxmf_destination_hash_for_identity_hash = MagicMock(return_value=LOCAL_LXMF) + fake_destination = MagicMock() + fake_destination.hash = bytes.fromhex(LOCAL_LXMF) + app.db_upsert_lxmf_message = MagicMock() + + with ( + patch("meshchatx.meshchat.RNS.Destination", return_value=fake_destination), + patch("meshchatx.meshchat.LXMF.LXMessage", side_effect=_lxmf_message_factory), + patch( + "meshchatx.src.backend.lxmf_utils.lxmf_message_solving_stamps", + return_value=False, + ), + patch("meshchatx.meshchat.AsyncUtils.run_async"), + ): + await app.send_message( + destination_hash=identity_hex, + content="via identity hash", + ) + + app.current_context.message_router.handle_outbound.assert_not_called() + assert app.db_upsert_lxmf_message.call_args.kwargs.get("method_override") == "local" + + +@pytest.mark.asyncio +async def test_send_to_self_rejects_propagated(local_self_app): + app = local_self_app + with pytest.raises(ValueError, match="yourself"): + await app.send_message( + destination_hash=LOCAL_LXMF, + content="nope", + delivery_method="propagated", + ) diff --git a/tests/backend/test_lxmf_local_self_oracle.py b/tests/backend/test_lxmf_local_self_oracle.py new file mode 100644 index 00000000..e95a7149 --- /dev/null +++ b/tests/backend/test_lxmf_local_self_oracle.py @@ -0,0 +1,215 @@ +# SPDX-License-Identifier: 0BSD +"""Oracles and adversarial cases for local (self) LXMF messaging. + +Invariant: destination equal to local LXMF, identity, or resolving to local LXMF +must persist as delivered/local without LXMF router outbound. Any other +destination must use the mesh path when send succeeds. +""" + +from __future__ import annotations + +import json +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from meshchatx.meshchat import ReticulumMeshChat +from meshchatx.src.backend import reticulum_pathfinding +from tests.backend.test_lxmf_local_self_message import ( + LOCAL_LXMF, + REMOTE_PEER, + _fake_lxmf_message, + _lxmf_message_factory, + local_self_app, +) + + +@pytest.mark.parametrize( + ("dest", "expected"), + [ + (LOCAL_LXMF, True), + (LOCAL_LXMF.upper(), True), + (REMOTE_PEER, False), + ("", False), + ], +) +def test_is_self_lxmf_param_oracle(local_self_app, dest, expected): + app = local_self_app + assert app._is_self_lxmf_destination(dest) is expected + + +def test_is_self_lxmf_identity_hash_oracle(local_self_app): + app = local_self_app + identity_hex = app.current_context.identity.hash.hex() + assert app._is_self_lxmf_destination(identity_hex) is True + + +@pytest.mark.parametrize( + "dest", + [ + REMOTE_PEER, + "f0" * 16, + "deadbeef" * 4, + "0123456789abcdef0123456789abcdef", + ], +) +def test_is_self_false_for_unrelated_peers(local_self_app, dest): + app = local_self_app + identity = app.current_context.identity.hash.hex().lower() + assert dest.lower() not in {LOCAL_LXMF.lower(), identity} + app.get_lxmf_destination_hash_for_identity_hash = MagicMock(return_value=None) + assert app._is_self_lxmf_destination(dest) is False + + +@pytest.mark.asyncio +async def test_local_self_sqlite_row_delivered_local(local_self_app): + """End-to-end persist: real db_upsert, not a mock.""" + app = local_self_app + fake_destination = MagicMock() + fake_destination.hash = bytes.fromhex(LOCAL_LXMF) + + with ( + patch("meshchatx.meshchat.RNS.Destination", return_value=fake_destination), + patch("meshchatx.meshchat.LXMF.LXMessage", side_effect=_lxmf_message_factory), + patch( + "meshchatx.src.backend.lxmf_utils.lxmf_message_solving_stamps", + return_value=False, + ), + patch("meshchatx.meshchat.AsyncUtils.run_async") as run_async, + ): + await app.send_message(destination_hash=LOCAL_LXMF, content="oracle persist") + + run_async.assert_not_called() + row = app.database.provider.fetchone( + "SELECT state, method, peer_hash, content FROM lxmf_messages WHERE content = ?", + ("oracle persist",), + ) + assert row is not None + assert row["state"] == "delivered" + assert row["method"] == "local" + assert row["peer_hash"] == LOCAL_LXMF + + +@pytest.mark.asyncio +async def test_local_self_conversation_summary_updated(local_self_app): + app = local_self_app + fake_destination = MagicMock() + fake_destination.hash = bytes.fromhex(LOCAL_LXMF) + + with ( + patch("meshchatx.meshchat.RNS.Destination", return_value=fake_destination), + patch("meshchatx.meshchat.LXMF.LXMessage", side_effect=_lxmf_message_factory), + patch( + "meshchatx.src.backend.lxmf_utils.lxmf_message_solving_stamps", + return_value=False, + ), + patch("meshchatx.meshchat.AsyncUtils.run_async"), + ): + await app.send_message(destination_hash=LOCAL_LXMF, content="summary row") + + summary = app.database.provider.fetchone( + "SELECT state, content_preview FROM lxmf_conversation_summaries WHERE peer_hash = ?", + (LOCAL_LXMF,), + ) + assert summary is not None + assert summary["state"] == "delivered" + assert "summary row" in (summary["content_preview"] or "") + + +@pytest.mark.asyncio +async def test_remote_peer_still_invokes_mesh_outbound(local_self_app): + app = local_self_app + fake_destination = MagicMock() + fake_destination.hash = bytes.fromhex(REMOTE_PEER) + + app._await_transport_path = AsyncMock( + return_value=reticulum_pathfinding.OutboundPathOutcome( + True, + "reused_valid_path", + False, + ), + ) + app.db_upsert_lxmf_message = MagicMock() + progress = AsyncMock() + app.handle_lxmf_message_progress = progress + + with ( + patch("meshchatx.meshchat.RNS.Destination", return_value=fake_destination), + patch("meshchatx.meshchat.LXMF.LXMessage", side_effect=_lxmf_message_factory), + patch( + "meshchatx.src.backend.lxmf_utils.lxmf_message_solving_stamps", + return_value=False, + ), + patch("meshchatx.meshchat.AsyncUtils.run_async") as run_async, + ): + await app.send_message(destination_hash=REMOTE_PEER, content="to peer") + + app._await_transport_path.assert_awaited() + app.current_context.message_router.handle_outbound.assert_called_once() + run_async.assert_called_once() + upsert_kw = app.db_upsert_lxmf_message.call_args.kwargs + assert upsert_kw.get("method_override") is None + assert upsert_kw.get("state_override") is None + + +def test_db_upsert_override_writes_local_method(local_self_app): + app = local_self_app + msg = _fake_lxmf_message() + msg.content = b"stored" + ReticulumMeshChat.db_upsert_lxmf_message( + app, + msg, + state_override="delivered", + method_override="local", + ) + row = app.database.provider.fetchone( + "SELECT state, method FROM lxmf_messages WHERE hash = ?", + ("d4" * 16,), + ) + assert row is not None + assert row["state"] == "delivered" + assert row["method"] == "local" + + +@pytest.mark.asyncio +async def test_local_self_websocket_payload_is_delivered_local(local_self_app): + app = local_self_app + fake_destination = MagicMock() + fake_destination.hash = bytes.fromhex(LOCAL_LXMF) + + with ( + patch("meshchatx.meshchat.RNS.Destination", return_value=fake_destination), + patch("meshchatx.meshchat.LXMF.LXMessage", side_effect=_lxmf_message_factory), + patch( + "meshchatx.src.backend.lxmf_utils.lxmf_message_solving_stamps", + return_value=False, + ), + patch("meshchatx.meshchat.AsyncUtils.run_async"), + ): + await app.send_message(destination_hash=LOCAL_LXMF, content="ws") + + raw = app.websocket_broadcast.await_args[0][0] + payload = json.loads(raw) + assert payload["type"] == "lxmf_message_created" + lm = payload["lxmf_message"] + assert lm["state"] == "delivered" + assert lm["method"] == "local" + assert lm["content"] == "ws" + + +@pytest.mark.asyncio +async def test_identity_resolved_to_other_lxmf_is_not_self(local_self_app): + app = local_self_app + other_lxmf = REMOTE_PEER + identity_hex = app.current_context.identity.hash.hex() + app.get_lxmf_destination_hash_for_identity_hash = MagicMock(return_value=other_lxmf) + assert app._is_self_lxmf_destination(identity_hex) is True + stranger = "f0" * 16 + app.get_lxmf_destination_hash_for_identity_hash = MagicMock(return_value=other_lxmf) + assert app._is_self_lxmf_destination(stranger) is False + + +def test_oracle_rejects_if_implementation_always_returns_true(local_self_app): + """Guard: mutating oracle expectation must fail (documents test strength).""" + app = local_self_app + assert app._is_self_lxmf_destination(REMOTE_PEER) is False diff --git a/tests/backend/test_translator_handler_extended.py b/tests/backend/test_translator_handler_extended.py index 92b1dc2d..28b82651 100644 --- a/tests/backend/test_translator_handler_extended.py +++ b/tests/backend/test_translator_handler_extended.py @@ -5,6 +5,7 @@ from unittest.mock import AsyncMock, MagicMock, patch import pytest from meshchatx.src.backend.translator_handler import ( + HAS_ARGOS_LIB, TranslatorHandler, _normalize_optional_libretranslate_api_key, ) @@ -357,3 +358,25 @@ def test_translate_explicit_api_key_overrides_handler_default(mock_session_cls): ) body = mock_session.post.call_args.kwargs.get("json") assert body.get("api_key") == "one-off" + + +@pytest.mark.skipif(not HAS_ARGOS_LIB, reason="argostranslate not installed") +def test_get_supported_languages_falls_back_to_cli_when_lib_has_no_packages(): + handler = TranslatorHandler(translator_argos_enabled=True) + handler.has_argos_lib = True + handler.has_argos_cli = True + handler.has_argos = True + handler.has_requests = False + handler.translator_libretranslate_enabled = False + + with ( + patch("meshchatx.src.backend.translator_handler.package") as mock_pkg, + patch.object(handler, "_get_argos_languages_cli") as mock_cli, + ): + mock_pkg.get_installed_packages.return_value = [] + mock_cli.return_value = [{"code": "en", "name": "English", "source": "argos"}] + langs = handler.get_supported_languages() + assert any( + lang.get("code") == "en" and lang.get("source") == "argos" for lang in langs + ) + mock_cli.assert_called_once() diff --git a/tests/e2e/helpers.js b/tests/e2e/helpers.js index a65032eb..3c747e5d 100644 --- a/tests/e2e/helpers.js +++ b/tests/e2e/helpers.js @@ -3,10 +3,47 @@ const { expect } = require("@playwright/test"); const E2E_BACKEND_PORT = process.env.E2E_BACKEND_PORT || "18079"; const E2E_BACKEND_ORIGIN = `http://127.0.0.1:${E2E_BACKEND_PORT}`; +const CSRF_HEADER = "X-CSRF-Token"; const E2E_SCROLL_PEER_HASH = `e2e0${"0".repeat(28)}`; const E2E_SCROLL_ALT_PEER_HASH = `e2e1${"0".repeat(28)}`; +const _csrfByOrigin = new Map(); + +/** + * @param {import('@playwright/test').APIRequestContext} request + * @param {string} origin e.g. http://127.0.0.1:18079 + */ +async function ensureE2eCsrf(request, origin) { + const cached = _csrfByOrigin.get(origin); + if (cached) { + return cached; + } + const res = await request.get(`${origin}/api/v1/auth/csrf`); + expect(res.ok()).toBeTruthy(); + const body = await res.json(); + const token = body.csrf_token; + expect(token && typeof token === "string").toBeTruthy(); + _csrfByOrigin.set(origin, token); + return token; +} + +/** + * POST with session cookie and CSRF header (Playwright request keeps cookies per context). + * @param {import('@playwright/test').APIRequestContext} request + * @param {string} url absolute URL + * @param {object} [data] JSON body + */ +async function e2ePost(request, url, data) { + const origin = new URL(url).origin; + const token = await ensureE2eCsrf(request, origin); + const headers = { [CSRF_HEADER]: token }; + if (data === undefined) { + return request.post(url, { headers }); + } + return request.post(url, { headers, data }); +} + function buildE2eLxmfRow({ peerHash, localHash, index, total, inbound }) { const hash = crypto.randomBytes(16).toString("hex"); const baseTs = Math.floor(Date.now() / 1000) - total; @@ -70,8 +107,8 @@ async function seedE2eLongConversationThread(request, opts = {}) { }) ); } - const imp = await request.post(`${E2E_BACKEND_ORIGIN}/api/v1/maintenance/messages/import`, { - data: { messages }, + const imp = await e2ePost(request, `${E2E_BACKEND_ORIGIN}/api/v1/maintenance/messages/import`, { + messages, }); expect(imp.ok()).toBeTruthy(); return { peerHash, localHash }; @@ -98,8 +135,8 @@ async function seedE2eAltShortConversationThread(request, opts = {}) { row.content = `E2E alt short ${String(i).padStart(3, "0")} ${"x".repeat(48)}`; messages.push(row); } - const imp = await request.post(`${E2E_BACKEND_ORIGIN}/api/v1/maintenance/messages/import`, { - data: { messages }, + const imp = await e2ePost(request, `${E2E_BACKEND_ORIGIN}/api/v1/maintenance/messages/import`, { + messages, }); expect(imp.ok()).toBeTruthy(); return { peerHash, localHash }; @@ -112,10 +149,10 @@ const PALETTE_PLACEHOLDER = /Search commands,\s*(routes|navigate),\s*or peers\.{ * (v-overlay scrim) do not block pointer clicks on the shell. */ async function prepareE2eSession(request) { - const tutorial = await request.post(`${E2E_BACKEND_ORIGIN}/api/v1/app/tutorial/seen`); + const tutorial = await e2ePost(request, `${E2E_BACKEND_ORIGIN}/api/v1/app/tutorial/seen`); expect(tutorial.ok()).toBeTruthy(); - const changelog = await request.post(`${E2E_BACKEND_ORIGIN}/api/v1/app/changelog/seen`, { - data: { version: "999.999.999" }, + const changelog = await e2ePost(request, `${E2E_BACKEND_ORIGIN}/api/v1/app/changelog/seen`, { + version: "999.999.999", }); expect(changelog.ok()).toBeTruthy(); } @@ -157,6 +194,8 @@ module.exports = { E2E_SCROLL_ALT_PEER_HASH, PALETTE_PLACEHOLDER, dismissMapOnboardingTooltip, + e2ePost, + ensureE2eCsrf, openCommandPalette, prepareE2eSession, getE2eLocalLxmfHash, diff --git a/tests/e2e/self-message.spec.js b/tests/e2e/self-message.spec.js new file mode 100644 index 00000000..49025b40 --- /dev/null +++ b/tests/e2e/self-message.spec.js @@ -0,0 +1,53 @@ +const { test, expect } = require("@playwright/test"); +const { E2E_BACKEND_ORIGIN, e2ePost, prepareE2eSession } = require("./helpers"); + +test.describe("E2E CSRF API helpers", () => { + test("POST /app/tutorial/seen without CSRF token is rejected", async ({ request }) => { + const res = await request.post(`${E2E_BACKEND_ORIGIN}/api/v1/app/tutorial/seen`); + expect(res.status()).toBe(403); + }); + + test("e2ePost succeeds for tutorial and changelog seen", async ({ request }) => { + await prepareE2eSession(request); + }); +}); + +test.describe("Local self-message API", () => { + test.beforeEach(async ({ request }) => { + await prepareE2eSession(request); + }); + + test("POST lxmf-messages/send to own LXMF hash returns delivered local", async ({ request }) => { + const cfg = await request.get(`${E2E_BACKEND_ORIGIN}/api/v1/config`); + expect(cfg.ok()).toBeTruthy(); + const localHash = (await cfg.json()).config?.lxmf_address_hash; + expect(localHash && String(localHash).length).toBe(32); + + const res = await e2ePost(request, `${E2E_BACKEND_ORIGIN}/api/v1/lxmf-messages/send`, { + lxmf_message: { + destination_hash: localHash, + content: "E2E personal note", + }, + }); + expect(res.ok(), await res.text()).toBeTruthy(); + const body = await res.json(); + const lm = body.lxmf_message; + expect(lm.state).toBe("delivered"); + expect(lm.method).toBe("local"); + expect(lm.content).toBe("E2E personal note"); + }); + + test("propagated send to self is rejected", async ({ request }) => { + const cfg = await request.get(`${E2E_BACKEND_ORIGIN}/api/v1/config`); + const localHash = (await cfg.json()).config?.lxmf_address_hash; + const res = await e2ePost(request, `${E2E_BACKEND_ORIGIN}/api/v1/lxmf-messages/send`, { + delivery_method: "propagated", + lxmf_message: { + destination_hash: localHash, + content: "nope", + }, + }); + expect(res.ok()).toBeFalsy(); + expect(res.status()).toBeGreaterThanOrEqual(400); + }); +}); diff --git a/tests/frontend/conversationViewerSelfPeer.test.js b/tests/frontend/conversationViewerSelfPeer.test.js new file mode 100644 index 00000000..f915ea1f --- /dev/null +++ b/tests/frontend/conversationViewerSelfPeer.test.js @@ -0,0 +1,79 @@ +// SPDX-License-Identifier: 0BSD AND MIT +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { mount, flushPromises } from "@vue/test-utils"; +import ConversationViewer from "@/components/messages/ConversationViewer.vue"; +import ToastUtils from "@/js/ToastUtils"; + +vi.mock("@/js/ToastUtils", () => ({ + default: { info: vi.fn(), error: vi.fn(), success: vi.fn() }, +})); + +vi.mock("@/js/WebSocketConnection", () => ({ + default: { on: vi.fn(), off: vi.fn() }, +})); + +vi.mock("@/js/GlobalEmitter", () => ({ + default: { on: vi.fn(), off: vi.fn(), emit: vi.fn() }, +})); + +describe("ConversationViewer self peer detection", () => { + const mountViewer = (myLxmf = "aa".repeat(16), identityHash = "bb".repeat(16)) => + mount(ConversationViewer, { + props: { + selectedPeer: { destination_hash: myLxmf, display_name: "Me" }, + myLxmfAddressHash: myLxmf, + config: { identity_hash: identityHash, lxmf_address_hash: myLxmf }, + }, + global: { + mocks: { + $t: (key) => key, + $route: { query: {} }, + $router: { push: vi.fn() }, + }, + stubs: { + MaterialDesignIcon: true, + ConversationPeerHeader: true, + ConversationMessageListVirtual: true, + SendMessageButton: true, + AddImageButton: true, + AddAudioButton: true, + ContextMenuPanel: true, + ContextMenuItem: true, + ContextMenuDivider: true, + Modal: true, + PaperMessageModal: true, + ShareContactModal: true, + ConversationImageModal: true, + LxmfUserIcon: true, + AudioWaveformPlayer: true, + StickerView: true, + InViewAnimatedImg: true, + TelemetryHistoryModal: true, + ConversationMessageEntry: true, + }, + }, + }); + + it("isSelfLxmfDestination is true for own LXMF hash", async () => { + const my = "cc".repeat(16); + const wrapper = mountViewer(my, "dd".repeat(16)); + await flushPromises(); + expect(wrapper.vm.isSelfLxmfDestination(my)).toBe(true); + expect(wrapper.vm.isSelfLxmfDestination(my.toUpperCase())).toBe(true); + }); + + it("isSelfLxmfDestination is true for own identity hash", async () => { + const my = "cc".repeat(16); + const ident = "dd".repeat(16); + const wrapper = mountViewer(my, ident); + await flushPromises(); + expect(wrapper.vm.isSelfLxmfDestination(ident)).toBe(true); + }); + + it("isSelfLxmfDestination is false for unrelated peer", async () => { + const wrapper = mountViewer(); + await flushPromises(); + expect(wrapper.vm.isSelfLxmfDestination("ee".repeat(16))).toBe(false); + expect(wrapper.vm.isSelfLxmfDestination("")).toBe(false); + }); +}); diff --git a/tests/frontend/messagesPageOutboundCompose.oracle.test.js b/tests/frontend/messagesPageOutboundCompose.oracle.test.js new file mode 100644 index 00000000..ff726ea3 --- /dev/null +++ b/tests/frontend/messagesPageOutboundCompose.oracle.test.js @@ -0,0 +1,120 @@ +// SPDX-License-Identifier: 0BSD AND MIT +/** + * Oracle: optimistic sidebar bump on compose enqueue must appear before WS ack. + */ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { mount, flushPromises } from "@vue/test-utils"; +import MessagesPage from "@/components/messages/MessagesPage.vue"; + +vi.mock("@/js/GlobalEmitter", () => ({ + default: { on: vi.fn(), off: vi.fn(), emit: vi.fn() }, +})); + +vi.mock("@/js/NotificationUtils", () => ({ + default: { + clearMessageNotifications: vi.fn(), + clearAllMessageNotifications: vi.fn(), + showNewMessageNotification: vi.fn(), + syncAndroidNotificationContext: vi.fn(), + }, +})); + +const PEER = "bb".repeat(16); + +describe("MessagesPage outbound compose oracle", () => { + let axiosMock; + + beforeEach(() => { + axiosMock = { + get: vi.fn().mockImplementation((url) => { + if (url === "/api/v1/config") { + return Promise.resolve({ + data: { config: { lxmf_address_hash: "aa".repeat(16) } }, + }); + } + if (url === "/api/v1/lxmf/conversations") { + return Promise.resolve({ data: { conversations: [] } }); + } + if (url === "/api/v1/announces") { + return Promise.resolve({ data: { announces: [] } }); + } + return Promise.resolve({ data: {} }); + }), + post: vi.fn().mockResolvedValue({ data: {} }), + }; + window.api = axiosMock; + }); + + afterEach(() => { + delete window.api; + }); + + const mountPage = () => + mount(MessagesPage, { + props: { destinationHash: "" }, + global: { + mocks: { + $t: (key) => key, + $route: { query: {} }, + $router: { replace: vi.fn() }, + }, + stubs: { + MaterialDesignIcon: true, + LoadingSpinner: true, + MessagesSidebar: true, + ConversationViewer: true, + Modal: true, + }, + }, + }); + + it("onOutboundComposeEnqueued inserts conversation row with preview before server ack", async () => { + const wrapper = mountPage(); + await flushPromises(); + expect(wrapper.vm.conversations).toHaveLength(0); + + wrapper.vm.onOutboundComposeEnqueued({ + peerHash: PEER, + previewText: "hello optimistic", + title: "", + fields: {}, + }); + + expect(wrapper.vm.conversations).toHaveLength(1); + expect(wrapper.vm.conversations[0].destination_hash).toBe(PEER); + expect(wrapper.vm.conversations[0].latest_message_preview).toContain("hello optimistic"); + expect(wrapper.vm.conversations[0].latest_message_created_at).toBeGreaterThan(0); + }); + + it("onOutboundComposeEnqueued updates existing row instead of duplicating", async () => { + const wrapper = mountPage(); + await flushPromises(); + wrapper.vm.conversations = [ + { + destination_hash: PEER, + display_name: "Peer", + latest_message_preview: "old", + latest_message_created_at: 1, + updated_at: new Date(1000).toISOString(), + }, + ]; + + wrapper.vm.onOutboundComposeEnqueued({ + peerHash: PEER, + previewText: "new text", + title: "t", + fields: {}, + }); + + expect(wrapper.vm.conversations).toHaveLength(1); + expect(wrapper.vm.conversations[0].latest_message_preview).toContain("new text"); + expect(wrapper.vm.conversations[0].latest_message_title).toBe("t"); + expect(wrapper.vm.conversations[0].latest_message_created_at).toBeGreaterThan(1); + }); + + it("onOutboundComposeEnqueued ignores empty peerHash (must not create orphan row)", () => { + const wrapper = mountPage(); + wrapper.vm.onOutboundComposeEnqueued({ peerHash: "", previewText: "x" }); + expect(wrapper.vm.conversations).toHaveLength(0); + }); +}); diff --git a/tests/frontend/outboundMessageStatus.oracle.test.js b/tests/frontend/outboundMessageStatus.oracle.test.js index eb68f922..0b35a866 100644 --- a/tests/frontend/outboundMessageStatus.oracle.test.js +++ b/tests/frontend/outboundMessageStatus.oracle.test.js @@ -26,7 +26,7 @@ const API_STATES = [ "unknown", ]; -const API_METHODS = ["opportunistic", "direct", "propagated", "paper", "unknown"]; +const API_METHODS = ["opportunistic", "direct", "propagated", "paper", "local", "unknown"]; /** * Independent UI oracle. Must stay in sync with product intent, not by @@ -35,12 +35,12 @@ const API_METHODS = ["opportunistic", "direct", "propagated", "paper", "unknown" function oracleIconName({ state, method }) { if (state === "delivered") { if (method === "propagated") return "email-check-outline"; - if (method === "paper") return "note-check-outline"; + if (method === "paper" || method === "local") return "note-check-outline"; return "check-all"; } if (state === "sent" || state === "propagated" || state === "unknown") { if (method === "propagated") return "email-outline"; - if (method === "paper") return "note-outline"; + if (method === "paper" || method === "local") return "note-outline"; return "check"; } return "check"; @@ -49,8 +49,10 @@ function oracleIconName({ state, method }) { function oracleTitleKey({ state, method }) { if (state === "delivered") { if (method === "propagated") return "messages.outbound_delivered_propagated"; + if (method === "local") return "messages.outbound_delivered_local"; return "messages.outbound_delivered"; } + if (method === "local") return "messages.outbound_saved_local"; if (method === "propagated") return "messages.outbound_on_propagation_node"; return "messages.outbound_sent_network"; } @@ -113,6 +115,17 @@ describe("outboundMessageStatus LXMF oracle", () => { expect(outboundBubbleStatusIconName({ state: "delivered", method: "paper" })).toBe("note-check-outline"); }); + it("local method uses note-check icon when delivered", () => { + const msg = { state: "delivered", method: "local" }; + expect(outboundBubbleStatusIconName(msg)).toBe("note-check-outline"); + expect(outboundBubbleStatusTitleKey(msg)).toBe("messages.outbound_delivered_local"); + }); + + it("local method while sending uses note outline title for saved local", () => { + const msg = { state: "sending", method: "local" }; + expect(outboundBubbleStatusTitleKey(msg)).toBe("messages.outbound_saved_local"); + }); + it("backend never emits state=propagated but UI still treats it as sent-like", () => { expect(outboundBubbleStatusIconName({ state: "propagated", method: "direct" })).toBe("check"); expect(outboundBubbleStatusIconName({ state: "propagated", method: "propagated" })).toBe("email-outline"); diff --git a/tests/test_android_emulator_smoke_script.py b/tests/test_android_emulator_smoke_script.py index a203a480..cab3688d 100644 --- a/tests/test_android_emulator_smoke_script.py +++ b/tests/test_android_emulator_smoke_script.py @@ -46,3 +46,4 @@ def test_workflow_references_smoke_script(): smoke_script = smoke_step.split("script:", 1)[1].split("\n", 1)[0] assert "bash scripts/ci/android-emulator-smoke.sh" in smoke_script assert "set -euo pipefail" not in smoke_script + assert "adb forward" in _SCRIPT.read_text(encoding="utf-8")