diff --git a/AGENTS.md b/AGENTS.md index fdd5ece3e..be198b88a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -118,7 +118,7 @@ Adding a cross-boundary feature: **Local Linux CI (optional):** Container mode — `act:ci`, `act:tests`, `act:pr`, … (needs a Docker-compatible engine + act; Podman preferred). Host mode — `act:ci:native`, `act:tests:native`, … (no container engine). See [docs/ci-cd.md](docs/ci-cd.md). macOS/Windows packaging uses native `dist:mac` / `dist:win`. **`dist:mac`** / **`dist:mac:publish`** always run **`scripts/verify-mac-packaging.mjs`** (ZIP + DMG symlink asserts, no raw `.app` CI uploads). macOS signing env (`CSC_LINK`, `CSC_KEY_PASSWORD`, `APPLE_ID`, `APPLE_APP_SPECIFIC_PASSWORD`, `APPLE_TEAM_ID`, `CSC_IDENTITY_AUTO_DISCOVERY`) is scoped to **`macos-latest`** jobs in `release.yaml` / `build.yaml`; partial-secret validation fails the release job when `CSC_LINK` is set but notarization secrets are missing. -> **Update script sync:** When adding or removing packages from `patchedDependencies` in `pnpm-workspace.yaml`, keep `WATCH_ENTRIES` in `scripts/update.sh` in sync so the script warns on version changes to every patched dependency. When adding or removing Ratspeak overlays under `reticulum-sidecar/patches/`, keep `RATSPEAK_PATCH_ENTRIES` in `scripts/update.sh` (`check_ratspeak_patches`) in sync — `pnpm run update` queries upstream PRs (rsReticulum / rsLXMF) and warns when a local overlay can be removed. Open upstream feature work (e.g. [rsReticulum#26](https://github.com/ratspeak/rsReticulum/pull/26) ReplyFile, [rsLXMF#7](https://github.com/ratspeak/rsLXMF/pull/7) multi-file attachments) is carried as overlays on floated `origin/main` — never as committed SHA pins. It also runs `check_ratspeak_upstream` (watched **published** releases for rsLXST / lrgp-rs / Ratspeak vs `reviewed-ref` pins, plus new `ratspeak` org repos) — keep `RATSPEAK_RELEASE_WATCH_ENTRIES` / `RATSPEAK_KNOWN_ORG_REPOS` in sync when adopting libs. LXMFace is not a published-release watch: its baseline is a vendored-file commit (`file:js/lxmface.js@`) compared with the latest GitHub commit that touched that file. `scripts/clone-ratspeak-stack.sh` floats **rsReticulum** / **rsLXMF** / **rsNomad** / **rsLXST** / **lrgp-rs** to `origin/main` (override with `RS_RETICULUM_REF` / `RS_LXMF_REF` / `RS_NOMAD_REF` / `RS_LXST_REF` / `RS_LRGP_REF` for bisect only); overlays must apply or the clone fails. Ratspeak release watch uses stub-kind `games-parity` to nudge Games tab review when a published release is newer than the pin (`docs/reticulum-games-parity.md`). Peer default avatars use vendored **LXMFace** (`src/renderer/lib/reticulum/lxmface.ts`). `pnpm run update` also runs `rustup update` (or Homebrew `rust` on macOS without rustup) and `cargo build` in `reticulum-sidecar/` when `cargo` is on `PATH` (full-feature build includes `nomad-core` / rsNomad). +> **Update script sync:** When adding or removing packages from `patchedDependencies` in `pnpm-workspace.yaml`, keep `WATCH_ENTRIES` in `scripts/update.sh` in sync so the script warns on version changes to every patched dependency. When adding or removing Ratspeak overlays under `reticulum-sidecar/patches/`, keep `RATSPEAK_PATCH_ENTRIES` in `scripts/update.sh` (`check_ratspeak_patches`) in sync — `pnpm run update` queries upstream PRs (rsReticulum / rsLXMF) and warns when a local overlay can be removed. Open upstream feature work (e.g. [rsReticulum#26](https://github.com/ratspeak/rsReticulum/pull/26) ReplyFile, [rsLXMF#7](https://github.com/ratspeak/rsLXMF/pull/7) multi-file attachments) is carried as overlays on floated `origin/main` — never as committed SHA pins. It also runs `check_ratspeak_upstream` (watched **published** releases for rsLXST / lrgp-rs / Ratspeak vs `reviewed-ref` pins, plus new `ratspeak` org repos) — keep `RATSPEAK_RELEASE_WATCH_ENTRIES` / `RATSPEAK_KNOWN_ORG_REPOS` in sync when adopting libs. LXMFace is not a published-release watch: its baseline is a vendored-file commit (`file:js/lxmface.js@`) compared with the latest GitHub commit that touched that file. Vendored **MECP** ([xiang-dev-1/MECP](https://github.com/xiang-dev-1/MECP)) is watched the same way via `MECP_UPSTREAM_WATCH_ENTRIES` / `check_mecp_upstream` (`file:engine/src@` and `file:languages@` vs `src/renderer/lib/mecp/`). `scripts/clone-ratspeak-stack.sh` floats **rsReticulum** / **rsLXMF** / **rsNomad** / **rsLXST** / **lrgp-rs** to `origin/main` (override with `RS_RETICULUM_REF` / `RS_LXMF_REF` / `RS_NOMAD_REF` / `RS_LXST_REF` / `RS_LRGP_REF` for bisect only); overlays must apply or the clone fails. Ratspeak release watch uses stub-kind `games-parity` to nudge Games tab review when a published release is newer than the pin (`docs/reticulum-games-parity.md`). Peer default avatars use vendored **LXMFace** (`src/renderer/lib/reticulum/lxmface.ts`). `pnpm run update` also runs `rustup update` (or Homebrew `rust` on macOS without rustup) and `cargo build` in `reticulum-sidecar/` when `cargo` is on `PATH` (full-feature build includes `nomad-core` / rsNomad). **Pre-commit hook order:** @@ -156,6 +156,7 @@ Deep, file-level subsystem detail now lives in [`docs/agents/`](docs/agents/READ | Diagnostics engines, rows, tab scoping | [`docs/agents/diagnostics.md`](docs/agents/diagnostics.md) | | i18n / localization workflow, auto-translate, language selector | [`docs/agents/i18n.md`](docs/agents/i18n.md) | | Connection panel helpers (error hints, rehydrate, storage migrations) | [`docs/agents/connection-panel.md`](docs/agents/connection-panel.md) | +| MECP emergency reports, siren, audit log, ALERT_APP, RF rebroadcast | [`docs/agents/mecp.md`](docs/agents/mecp.md) | | Symptom → where-to-check index | [`docs/agents/common-issues.md`](docs/agents/common-issues.md) | **Always-remember invariants** (details in the linked files): diff --git a/docs/agents/README.md b/docs/agents/README.md index ed2c79197..5b842c6d9 100644 --- a/docs/agents/README.md +++ b/docs/agents/README.md @@ -15,6 +15,7 @@ Deep, file-level subsystem detail for AI assistants, split out of [`AGENTS.md`]( | Diagnostics engines, rows, tab scoping | [diagnostics.md](diagnostics.md) | | i18n / localization workflow, auto-translate, language selector | [i18n.md](i18n.md) | | Connection panel helpers (error hints, rehydrate, storage migrations) | [connection-panel.md](connection-panel.md) | +| MECP emergency reports, siren alerts, audit log, ALERT_APP, RF rebroadcast | [mecp.md](mecp.md) | | Symptom → where-to-check index | [common-issues.md](common-issues.md) | For human-facing deep dives, see the top-level docs (e.g. [../reticulum.md](../reticulum.md), [../diagnostics.md](../diagnostics.md), [../meshcore-meshtastic-parity.md](../meshcore-meshtastic-parity.md), [../troubleshooting.md](../troubleshooting.md)). diff --git a/docs/agents/chat.md b/docs/agents/chat.md index eef29a87a..ae4608e89 100644 --- a/docs/agents/chat.md +++ b/docs/agents/chat.md @@ -6,7 +6,7 @@ Deep subsystem reference for AI assistants. Open this when a task touches the Ch - **Composer limits / send cadence:** `chatComposerLimits.ts` — `getMaxChunks(protocol)` via `ProtocolCapabilities.composerMaxChunks` (MeshCore = 1: no outbound `[i/N]` split; `splitChatMessage` returns `null` when text needs more than one packet), room payload via `getMeshcoreRoomPayloadLimit`, `computeComposerLimitStatus` phases (`warn` surfaces a single-packet ⓘ hint; `overMaxSingle` disables send and shows a `role="note"` callout). MeshCore also gets a **non-blocking** ~5s "sending too fast" advisory (`role="status"`, dismissible) from an app-wide clock in `meshcoreSendRateNotice.ts` (`recordMeshcoreSend` / `isMeshcoreSendTooFast`, `MESHCORE_FAST_SEND_WARN_INTERVAL_MS`). Every **live** MeshCore send advances the clock and can show the advisory — text (`handleSend`), GIF, and share-location in `ChatComposer`, plus outbox drain (`useChatOutbox.ts`). Legacy MeshCore outbox rows with `groupTotal > 1` or `[i/N]` payloads are quarantined (`blocked`) on drain instead of being transmitted. Inbound multi-part `[i/N]` merge is unchanged. i18n: `chatPanel.composeLimit.meshcoreSingleNotice.*`, `chatPanel.meshcoreFastSend.warning`, `chatPanel.outboxLegacyMultipartBlocked`. See [`meshcore-meshtastic-parity.md`](../meshcore-meshtastic-parity.md). - **Payload / links:** `ChatPayloadText.tsx` — mention highlighting, search marks, URL linkification; link previews via `chat:fetchLinkPreview` (`src/main/fetchLinkPreview.ts`): Open Graph for HTML pages; **YouTube** watch/shorts/youtu.be via oEmbed + thumbnail; **direct image URLs** (path extension via `chatDirectImageUrl.ts` or raster `Content-Type`) return `kind: 'image'` and render as inline embeds (`ChatInlineImage` / `DirectImageEmbed`); OG/YouTube use card layout. Security: DNS-pinned undici `Agent`, private/loopback blocked, magic-byte MIME sniff (`safeRasterImageMime.ts`), HTTPS-only image embeds, 10s fetch / 3s DNS, 64 KiB HTML cap, **2 MiB** image fetch cap (256 KiB cache payload cap), LRU caches, single-flight dedup (renderer map capped). Previews load even when scrolled up. LXMF attachment rasters: `chat:readReticulumAttachmentAsDataUrl` (`reticulum-attachment-image.ts`; path jail, magic-byte MIME, SVG rejected, 2 MiB, IPC rate limit) → `ReticulumAttachmentLine`. LXMF voice memos (`hasReticulumVoiceMemo`): Chat DM mic → sidecar `/api/v1/voice/memo/*` via `electronAPI.reticulum.voiceMemo.*`; playback via `chat:readReticulumAttachmentBytes` (`reticulum-attachment-audio.ts`; OggS sniff, 256 KiB) → `ReticulumVoiceMemoLine`. Reply quotes: `replyPreview.ts`. - **Storage helpers:** `src/renderer/lib/chatPanelProtocolStorage.ts` — drafts (`mesh-client:drafts:`), open DM tabs, last-read, per-view mute (`mesh-client:mutedViews:`), starred (`mesh-client:starred:`, cap 200), MeshCore flood-scope overrides per chat view (`mesh-client:floodScopeOverrides:`, channel or DM `viewKey`). -- **Notifications:** `src/renderer/lib/chatNotifications.ts` — `playMessageNotification(type)` via Web Audio: `channel` = single 880 Hz pulse (150 ms); `dm` / `reply` = dual pulse (587.33 Hz then 783.99 Hz, 50 ms each, 35 ms gap). Resumes suspended `AudioContext` when the window is hidden/minimized. Type selection in `chatUnreadCounts.ts` (`resolveChatNotificationType`, `pickAudibleNotificationType`; batch priority reply > dm > channel). **ChatPanel** plays when the user is on Chat but reading another view; **App** plays for other panels / backgrounded window (avoids double beep). Meshtastic hidden-window desktop notifications are visual-only (`silent: true` in `meshtasticRouterSideEffects.ts`); typed Web Audio from App owns sound. Global mute `mesh-client:notifMuted`; per-view mute in `mutedViews`. Main-process **tray** icon shows unread when chat or MeshCore Rooms traffic arrives while backgrounded (`src/main/index.ts` `buildTrayIcon`). +- **Notifications:** `src/renderer/lib/chatNotifications.ts` — `playMessageNotification(type)` via Web Audio: `channel` = single 880 Hz pulse (150 ms); `dm` / `reply` = dual pulse (587.33 Hz then 783.99 Hz, 50 ms each, 35 ms gap); **`mecp`** = triple ascending pulse; **`mecpSiren`** = loud multi-cycle siren for MECP severity 0–1 (mute-bypass via `useMecpAlertWatcher` / `mecpAlert.ts`, not `pickAudibleNotificationType`). Resumes suspended `AudioContext` when the window is hidden/minimized. Type selection in `chatUnreadCounts.ts` (`resolveChatNotificationType`, `pickAudibleNotificationType`; batch priority reply > dm > channel). **ChatPanel** plays when the user is on Chat but reading another view; **App** plays for other panels / backgrounded window (avoids double beep). Meshtastic hidden-window desktop notifications are visual-only (`silent: true` in `meshtasticRouterSideEffects.ts`); typed Web Audio from App owns sound. Global mute `mesh-client:notifMuted`; per-view mute in `mutedViews`. Main-process **tray** icon shows unread when chat or MeshCore Rooms traffic arrives while backgrounded (`src/main/index.ts` `buildTrayIcon`). MECP compose/export and red-dashed bubbles: see [`mecp.md`](mecp.md). - **Meshtastic dedup:** `meshtasticMessageDedup.ts` — merges delayed RF/MQTT duplicates (**10-minute** content window) in `useMeshtasticRuntime` ingest. - **Hop badges:** `MessageRecord.rxHops` / `viaStoreForward` round-trip via `storeRecordAdapters.ts` and `meshtasticDbCacheHydration.ts` (`hopCount` on `MessageRecord` bridges to `rxHops` for Meshtastic PacketRouter rows); Chat hop pills read `ChatMessage.rxHops`. **MeshCore (primary):** companion `pathLen` on events 7/8 → `meshcoreCompanionRxPathLenToHopCount` → `DomainEvent.payload.hopCount` (`MeshCoreProtocol`, `meshcoreDirectMessageDecode`); waiting-message drain also sets `rxHops` from `pathLen`. **MeshCore (fallback):** raw-log correlation via `resolveMeshcoreIngestRxHops` / `MESHCORE_CHAT_CORRELATE_WINDOW_MS` (3000ms); `rawPacketsRef` synced inside event-136 `setRawPackets` updater for same-tick ingest (`meshcoreConnSideEffects`). **Meshtastic:** `MeshtasticProtocol` uses `meshtasticComputedRfHopsAway` — omit hops for `viaMqtt` and `hopStart === 0`; else `hopStart - hopLimit` when `hopStart > 0 && hopLimit <= hopStart`. - **Relay coverage (in-memory, outgoing bubbles only):** `relayCoverageStore` keyed by `identityId:messageId` (not persisted). UI: `RelayCoverageLine` in `ChatPanel` status row via `relayCoverageMessageKey`. **MeshCore:** `openHeardRepeatWindow` from Chat `useSendMessage` channel sends and runtime channel TX; during `MESHCORE_HEARD_REPEAT_WINDOW_MS` (**120s** — large meshes often need ≥60s for multi-hop returns), every overheard `GRP_TXT` flood-path segment credits a forwarder (Repeater/Room when resolved via `pubKeyMapRef` / pubkey prefix; otherwise synthetic hex as “additional unidentified”). Chat hops are never credited. Path-invariant payload id (`meshCorePathInvariantPayloadId`: CRC lookup + full type/payload equality) binds only from **own-TX** or **empty-path** channel echo (never from the first credited hop); once bound, mismatched floods are ignored — unbound windows credit all in-window forwarders (pre-#888 best-effort). Naming is reliable on **2/3-byte** path modes; **1-byte** is best-effort (collisions common). Only forwarders whose rebroadcast reaches our radio are credited (multi-hop peers appear via accumulated path hashes). `renameMessageId` re-keys coverage + window; disconnect clears window + identity coverage. **Meshtastic:** binary heard/timeout for RF channel/broadcast via `meshtasticHeardRepeat` + `useMeshtasticRuntime` transport status (DMs/MQTT ignored). **Reticulum:** predicted route at send (`reticulumRouteCoverage` / `sendReticulumChatMessage`); hops-only vs via-first-hop i18n; pending→hash rename re-keys; `clearReticulumSessionStores` strips reticulum coverage. i18n: `chatPanel.heardBy*`, `chatPanel.route*Predicted*`. diff --git a/docs/agents/mecp.md b/docs/agents/mecp.md new file mode 100644 index 000000000..d51d58e1c --- /dev/null +++ b/docs/agents/mecp.md @@ -0,0 +1,59 @@ +# Agent reference: MECP (Mesh Emergency Communication Protocol) + +Deep subsystem reference for AI assistants. Open when a task touches MECP compose, alerts, audit log, ALERT_APP ingest, or cross-protocol RF rebroadcast. Hard rules live in [`AGENTS.md`](../../AGENTS.md). + +## Wire format + +``` +MECP// [freetext] +``` + +- Severity: `0` MAYDAY, `1` URGENT, `2` SAFETY, `3` ROUTINE +- Codes: letter + two digits (`M01`, …); drill `D01`/`D02` set `isDrill` (suppresses alerts) +- Max **200** UTF-8 bytes (`MAX_MESSAGE_BYTES`) +- Vendored engine: [`src/renderer/lib/mecp/engine/`](../../src/renderer/lib/mecp/engine/) from [xiang-dev-1/MECP](https://github.com/xiang-dev-1/MECP) (GPLv3) +- Language packs: [`src/renderer/lib/mecp/languages/`](../../src/renderer/lib/mecp/languages/) (CC BY 4.0) +- Overview / video: [mecp.radio](https://mecp.radio/) +- App wrappers: `mecpMessages.ts` (`MECP_REGEX`, `tryParseMecp`), `mecpAlert.ts`, `mecpRebroadcast.ts` + +## Receive path + +1. Messages land in `messageStore` as normal chat text (Meshtastic RF/MQTT, MeshCore, Reticulum). +2. Meshtastic **`ALERT_APP`** (port 11) is decoded like `TEXT_MESSAGE_APP` in `MeshtasticProtocol` / MQTT. +3. `useMecpAlertWatcher` (mounted once from `App.tsx`): + - Seeds a dedup set at mount (no alert/audit on hydration) + - New inbound MECP → durable audit append (`mecp:appendReceived`) + - Alerts: sev **0** `'mecpSiren'` + emergency toast; sev **1** US EAS-style 853+960 Hz `'mecpEas'` + toast (**ignore** mutes; **always** including focused chat); sev **2–3** loud repeated `'mecp'` burst when unmuted; drills never alert + - Focused Chat still alerts via `ChatPanel` → `triggerMecpAlert` (deduped with the watcher) + - Optional RF rebroadcast (§ below) + +## Durable audit log + +- File: `mecp-received.log` (+ `.1` size rotate) under Electron `userData` — **not** session `mesh-client.log` +- IPC: `mecp:appendReceived`, `mecp:exportReceivedLog` (Save dialog) +- Included in support bundles +- App → MECP section: **Export MECP log** (Save dialog) alongside RF bridge settings and a link to upstream docs + +## Send path + +- App → MECP → **Show MECP button in Chat** (default **off**) gates the Chat compose control +- When enabled: Chat **MECP** button → `MecpComposeModal` (defaults: ROUTINE + Drill category, no codes selected) → encode → existing `handleSendChunk` / `useSendMessage` (follows open DM/channel) +- Attach GPS uses the app share-location waterfall (`resolveShareLocation`), not raw `navigator.geolocation` alone +- Meshtastic outbound uses normal text (`TEXT_MESSAGE_APP`), not ALERT_APP + +## RF rebroadcast (default off) + +- App → MECP section: rules `{ enabled, bidirectional, endpointA, endpointB }` (Meshtastic/MeshCore channel indices 0–7) +- One-way **A→B** by default; **Bidirectional** toggle enables B→A +- Trigger: new inbound MECP with `receivedVia` `rf`/`both` (not mqtt-only); skip own/history/drill +- Loop guard: payload+dest fingerprint TTL +- After each successful bridge send: short follow-up notice `MECP from via ()` (not a wire MECP; channel **name**, not index) +- Implementation: `mecpRebroadcast.ts` + `sendMecpRebroadcast.ts` + +## Out of scope (follow-ups) + +- RetAlert (`!RETALERT!…`) +- SQLite `mecpParsed` column / in-memory emergency panel +- MeshCore Rooms bubble styling +- Send via `ALERT_APP` portnum +- Reticulum DM bridge endpoints diff --git a/docs/agents/meshtastic.md b/docs/agents/meshtastic.md index 24246e512..a3a4a6312 100644 --- a/docs/agents/meshtastic.md +++ b/docs/agents/meshtastic.md @@ -11,3 +11,4 @@ Deep subsystem reference for AI assistants. Open this when a task touches Meshta - **PKC remote admin (firmware 2.5+):** `meshtasticRemoteAdmin.ts` — PKI-wrapped `AdminMessage` via `MeshDevice.sendRaw()` (`pkiEncrypted: true`, channel omitted on wire); session passkeys (~300s); tab-scoped snapshot routes in `meshtasticRemoteAdminSnapshot.ts` (Channels-first LoRa load). Per-node keys: `meshtasticRemoteAdminKeyStorage.ts` (`meshtasticRemoteAdminKey:` in `app_settings`; base64 / `base64:` / 64-char hex paste). Dest public key: NodeDB hex first, stored admin-key base64 fallback. `useMeshtasticRuntime`: `configureTargetNodeNum`, `remoteConfigSnapshot`, `runRemoteAdminOp` (errors → UI + toast); serialize admin reads with S&F (`remoteAdminReadsActiveCount` in `meshtasticBacklogUtils.ts`). **Requires connected local radio** (MQTT-only cannot admin). UI: `ConfigureNodeSelector.tsx`; NodeDetailModal admin key + **Configure node remotely**; SecurityPanel **Copy** public key. Persist last target in `meshtasticConfigureTargetNodeNum`. Gate with `hasRemoteAdmin`. Legacy admin channel (PSK + `"admin"`) out of scope. - **Meshtastic last heard:** `meshtasticLastHeard.ts` — bump `last_heard` on live RF packets (not only text); `computeNodeInfoLastHeardMs` merges radio NodeDB timestamps with client-side values (max wins). **Configure replay guard** must apply in `nodeStore` (`upsertNode`, `updatePosition`, `meshtasticLastHeardPatch`) via `meshtasticConfigurePhase.ts`, not only in `meshtasticNodeSideEffects` — PacketRouter updates the store before side effects run. During `device.configure()`, the Meshtastic SDK replays NodeDB as `node_info` frames and may emit synthetic `onUserPacket` / `onPositionPacket` with `rxTime = now`; guards skip those bumps. UserPacket path uses `mergeMeshtasticUserPacketLastHeard` (ms); NodeDB path uses `computeNodeInfoLastHeardMs` (sec). **Protocol decode must use `meshtasticPacketRxTimeMs`** — `@meshtastic/core` `PacketMetadata.rxTime` is already a `Date` (ms); never `rxTime * 1000` (Date×1000 → ~1e15; regression lock in `MeshtasticProtocol.test.ts` Date-shaped rxTime + source-policy `meshtastic-protocol-rxtime-via-helper`). SQLite stores `last_heard` as unix seconds; `meshtasticDbCacheHydration.ts` normalizes to ms on hydrate (`normalizeLastHeardMs`); stale/online checks use `effectiveLastHeardMs`. BLE configure uses a **stall watchdog** (`MESHTASTIC_BLE_CONFIGURE_TIMEOUT_MS`, reset on each replayed `node_info` / position / telemetry via `touchMeshtasticConfigureProgress`) so large NodeDBs are not cut off mid-stream. Node list JSON export emits `last_heard` as unix seconds with `last_heard_unit: 'unix_sec'`. - **Static GPS:** `src/renderer/lib/gpsSource.ts` — App tab static coordinates sync to self-node, map, and radio `setPosition`. +- **ALERT_APP (port 11):** Decoded as UTF-8 text like `TEXT_MESSAGE_APP` in `MeshtasticProtocol` and MQTT (`mqtt-manager.ts`) so MECP / critical alerts reach chat + `useMecpAlertWatcher`. See [`mecp.md`](mecp.md). diff --git a/docs/credits.md b/docs/credits.md index 3abb8badd..409f7c9e9 100644 --- a/docs/credits.md +++ b/docs/credits.md @@ -32,6 +32,7 @@ We were inspired by features from these projects: - [Mesh Monitor](https://meshmonitor.org/): Web-based mesh network monitoring dashboard - [CoreScope](https://github.com/Kpa-clawbot/CoreScope): Self-hosted MeshCore network analyzer with RF analytics, packet visualization, and topology tools - [Ratspeak](https://github.com/ratspeak/Ratspeak): Primary reference for the Reticulum/rsReticulum/rsLXMF stack, sidecar IPC patterns, and peer interop ([rsReticulum](https://github.com/ratspeak/rsReticulum), [rsLXMF](https://github.com/ratspeak/rsLXMF)) +- [MECP](https://mecp.radio/) ([GitHub](https://github.com/xiang-dev-1/MECP)): Mesh Emergency Communication Protocol — structured emergency text for LoRa mesh (engine GPLv3; language packs CC BY 4.0) ### Bundled binaries @@ -49,9 +50,11 @@ Application source (Electron main / preload / renderer) is **GPL-3.0-or-later**; ### Vendored -| Source / file | License | Role | -| ------------------ | ------- | ----------------------------------- | -| `micron-parser-js` | MIT | Nomad Micron (.mu) → HTML (RFnexus) | +| Source / file | License | Role | +| ----------------------------------------------------------------------------------------- | --------- | ----------------------------------------------------------------------- | +| `micron-parser-js` | MIT | Nomad Micron (.mu) → HTML (RFnexus) | +| `src/renderer/lib/mecp/engine/` ([xiang-dev-1/MECP](https://github.com/xiang-dev-1/MECP)) | GPLv3 | MECP encode/decode engine | +| `src/renderer/lib/mecp/languages/*.json` | CC BY 4.0 | MECP localized code/category strings (see upstream `LICENSE-LANGUAGES`) | ## Third-party licenses diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 7bf56c0eb..a82569e7a 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -61,7 +61,7 @@ Works on macOS, Windows, Linux (.deb / .rpm / AppImage), and Flatpak. Local data | `meshcore.orphanRoomMessageCount` | number | Room posts whose `room_server_id` is not in current contacts | | `meshcore.roomNodeCount` / `roomMessageCount` / `roomsLastReadKeyCount` | numbers | Rooms triage counts | -Zip contents also include **`mesh-client.log.1`** when present (prior session preserved on restart, or size-rotated backup; export may tail-cap large backups). +Zip contents also include **`mesh-client.log.1`** when present (prior session preserved on restart, or size-rotated backup; export may tail-cap large backups). When present, support bundles also include **`mecp-received.log`** / **`mecp-received.log.1`** (durable MECP emergency audit trail — separate from the session app log). The top-level **`legend`** explains that ids like `offline-meshcore` are **internal hydration-slot store keys**, not “disconnected.” When connect reuses that slot (`hydrationSlotIsLiveSession: true`), the id still contains `offline-` while BLE/MQTT are up — that is **expected**. @@ -1988,6 +1988,24 @@ The app functions fully offline; this is not a critical error. If "Update check ### Language and Translations +## MECP (emergency reports) + +**Where is the MECP received log?** + +Inbound MECP messages are appended to a durable audit file under the app `userData` folder (not the rotating session `mesh-client.log`): + +- macOS: `~/Library/Application Support/mesh-client/mecp-received.log` (rotated backup `mecp-received.log.1`) +- Windows: `%APPDATA%\mesh-client\mecp-received.log` +- Linux: `~/.config/mesh-client/mecp-received.log` + +Use **App → MECP → Export MECP log**, or open a GitHub/Developer support bundle (includes the file when non-empty). Agent reference: [`docs/agents/mecp.md`](agents/mecp.md). + +**MAYDAY/URGENT alerts ignore mute** + +Severity 0 MECP alerts play a sweeping siren; severity 1 plays a US EAS-style 853+960 Hz attention tone. Both ignore mute and still fire while Chat is focused on that conversation. Severity 2–3 play a loud repeated tone when unmuted (also while focused). Drill codes (D01/D02) never alert. Configure Meshtastic↔MeshCore RF bridging under **App → MECP RF rebroadcast** (default off; optional bidirectional). + +## Language / i18n + **How do I change the language?** Click the **globe icon** in the header to select from the 16 supported languages. Your preference is saved across restarts. diff --git a/scripts/update.sh b/scripts/update.sh index cd21c0b7e..3604ff942 100755 --- a/scripts/update.sh +++ b/scripts/update.sh @@ -576,6 +576,13 @@ RATSPEAK_RELEASE_WATCH_ENTRIES=( 'ratspeak/Ratspeak||Ratspeak identity vault (vendored in sidecar)|file:crates/ratspeak-runtime/src/vault.rs@19e2a0d19202d4c7562adba79ac706ec352fdb86' ) +# Non-Ratspeak vendored upstreams (same file:@ reviewed-ref format). +# Keep in sync when re-vendoring MECP engine/languages from https://github.com/xiang-dev-1/MECP +MECP_UPSTREAM_WATCH_ENTRIES=( + 'xiang-dev-1/MECP||MECP engine (vendored in renderer)|file:engine/src@1441c5d13bd777e8dac8e12567bbd144b1a73e16' + 'xiang-dev-1/MECP||MECP language packs (vendored in renderer)|file:languages@ee17ef3d58d372d8c5157407c036f846869ab251' +) + RATSPEAK_KNOWN_ORG_REPOS=( '.github' 'C6-Reticulum-ASM' @@ -610,6 +617,64 @@ print_ratspeak_upstream_catalog() { for entry in "${RATSPEAK_KNOWN_ORG_REPOS[@]}"; do echo " ${entry}" done + echo 'MECP_UPSTREAM_WATCH_ENTRIES:' + for entry in "${MECP_UPSTREAM_WATCH_ENTRIES[@]}"; do + echo " ${entry}" + done +} + +# Shared file:@ watch used by Ratspeak + MECP vendored upstreams. +# Returns: 0 = current/skipped, 2 = drift warning emitted, 1 = not a file: ref. +check_vendored_file_watch_entry() { + local repo="$1" label="$2" reviewed="$3" + local file_spec file_path file_sha latest_sha short_pin short_latest + + if [[ "${reviewed}" != file:* ]]; then + return 1 + fi + file_spec="${reviewed#file:}" + file_path="${file_spec%@*}" + file_sha="${file_spec##*@}" + if [ -z "${file_path}" ] || [ -z "${file_sha}" ] || [ "${file_path}" = "${file_spec}" ]; then + return 0 + fi + latest_sha="$(github_file_latest_commit "${repo}" "${file_path}")" + if [ -z "${latest_sha}" ]; then + return 0 + fi + short_pin="${file_sha:0:12}" + short_latest="${latest_sha:0:12}" + if commit_shas_equal "${latest_sha}" "${file_sha}"; then + echo " ${label}: ${file_path} @ ${short_pin} (reviewed; current)" + return 0 + fi + warn_box "${label}" "${short_pin}" "${short_latest}" \ + "https://github.com/${repo}/commits?path=${file_path}" + echo " Reason tracked: vendored ${file_path} changed — compare with mesh-client vendored copy" + HAS_WARNING=1 + return 2 +} + +check_mecp_upstream() { + local entry repo stub label reviewed + local has_upstream_warning=0 + local file_rc + + echo '' + echo 'Checking MECP upstream vendored engine/languages...' + + for entry in "${MECP_UPSTREAM_WATCH_ENTRIES[@]}"; do + IFS='|' read -r repo stub label reviewed <<< "${entry}" + file_rc=0 + check_vendored_file_watch_entry "${repo}" "${label}" "${reviewed}" || file_rc=$? + if [ "${file_rc}" -eq 2 ]; then + has_upstream_warning=1 + fi + done + + if [ "${has_upstream_warning}" -eq 0 ]; then + echo ' MECP upstream watch complete (reviewed baselines current).' + fi } # Surface new Ratspeak library releases and brand-new org repos (stack floats via clone). @@ -628,27 +693,11 @@ check_ratspeak_upstream() { url="https://github.com/${repo}/releases" if [[ "${reviewed}" == file:* ]]; then - file_spec="${reviewed#file:}" - file_path="${file_spec%@*}" - file_sha="${file_spec##*@}" - if [ -z "${file_path}" ] || [ -z "${file_sha}" ] || [ "${file_path}" = "${file_spec}" ]; then - continue - fi - latest_sha="$(github_file_latest_commit "${repo}" "${file_path}")" - if [ -z "${latest_sha}" ]; then - continue - fi - short_pin="${file_sha:0:12}" - short_latest="${latest_sha:0:12}" - if commit_shas_equal "${latest_sha}" "${file_sha}"; then - echo " ${label}: ${file_path} @ ${short_pin} (reviewed; current)" - continue + local file_rc=0 + check_vendored_file_watch_entry "${repo}" "${label}" "${reviewed}" || file_rc=$? + if [ "${file_rc}" -eq 2 ]; then + has_upstream_warning=1 fi - warn_box "${label}" "${short_pin}" "${short_latest}" \ - "https://github.com/${repo}/commits?path=${file_path}" - echo " Reason tracked: vendored ${file_path} changed — compare with src/renderer/lib/reticulum/lxmface.ts" - has_upstream_warning=1 - HAS_WARNING=1 continue fi @@ -860,6 +909,7 @@ done check_pinned_majors check_ratspeak_patches check_ratspeak_upstream +check_mecp_upstream if [ "${HAS_WARNING}" -eq 0 ]; then echo 'No updates to watched packages — safe to proceed.' diff --git a/scripts/update.test.mjs b/scripts/update.test.mjs index 130d70e02..312314132 100644 --- a/scripts/update.test.mjs +++ b/scripts/update.test.mjs @@ -580,7 +580,7 @@ exit 0 expect(result.stdout).toContain('WARNING:'); expect(result.stdout).toContain('308a729d5bf9'); expect(result.stdout).toContain('aaaaaaaaaaaa'); - expect(result.stdout).toContain('src/renderer/lib/reticulum/lxmface.ts'); + expect(result.stdout).toContain('compare with mesh-client vendored copy'); }); it('runs cargo clean after a successful rebuild when CLEAN_SIDECAR_TARGET=1', () => { diff --git a/src/main/index.ipc-security.test.ts b/src/main/index.ipc-security.test.ts index bcfd8403c..c6271abd2 100644 --- a/src/main/index.ipc-security.test.ts +++ b/src/main/index.ipc-security.test.ts @@ -626,6 +626,13 @@ describe('privileged IPC sender validation (source contract)', () => { expect(body).toContain('isValidHttpHostname(s.server.trim())'); }); + it('mecp:appendReceived validates entry shape', () => { + const handlerIdx = INDEX_SOURCE.indexOf("ipcMain.handle('mecp:appendReceived'"); + expect(handlerIdx).toBeGreaterThan(-1); + const body = INDEX_SOURCE.slice(handlerIdx, handlerIdx + 500); + expect(body).toContain('isValidMecpAppendPayload'); + }); + it('chat:export caps message array length', () => { expect(INDEX_SOURCE).toContain('CHAT_EXPORT_MAX_MESSAGES'); }); @@ -650,6 +657,7 @@ describe('privileged IPC sender validation (source contract)', () => { 'support:exportBundle', 'storage:encrypt', 'storage:decrypt', + 'mecp:exportReceivedLog', ] as const) { const handlerIdx = INDEX_SOURCE.indexOf(`ipcMain.handle('${channel}'`); expect(handlerIdx).toBeGreaterThan(-1); diff --git a/src/main/index.ts b/src/main/index.ts index 507e976be..56619d272 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -146,6 +146,11 @@ import { sanitizeLogMessage, setMainWindow, } from './log-service'; +import { + appendMecpReceivedLog, + isValidMecpAppendPayload, + readMecpReceivedLogForExport, +} from './mecp-received-log'; import { MeshcoreMqttAdapter } from './meshcore-mqtt-adapter'; import { decodePathPayload, isPathPacket } from './meshcore-path-decoder'; import { ensureMicrophoneAccess, isAllowedMicrophonePrivacySettingsUrl } from './microphoneAccess'; @@ -4865,6 +4870,44 @@ ipcMain.handle('chat:export', async (event, messages: unknown) => { } }); +ipcMain.handle('mecp:appendReceived', async (event, entry: unknown) => { + if (!validateIpcSender(event)) throw new Error('IPC sender validation failed'); + if (!isValidMecpAppendPayload(entry)) { + throw new Error('mecp:appendReceived: invalid entry'); + } + await appendMecpReceivedLog(entry); + return { ok: true as const }; +}); + +ipcMain.handle('mecp:exportReceivedLog', async (event) => { + if (!validateIpcSender(event)) throw new Error('IPC sender validation failed'); + exportIpcRateLimit.checkOrThrow(); + if (!mainWindow) return { success: false as const, reason: 'error' as const }; + try { + const text = await readMecpReceivedLogForExport(); + if (!text.trim()) return { success: false as const, reason: 'empty' as const }; + const result = await dialog.showSaveDialog(mainWindow, { + title: 'Export MECP received log', + defaultPath: `mecp-received-${new Date().toISOString().slice(0, 10)}.txt`, + filters: [ + { name: 'Text file', extensions: ['txt'] }, + { name: 'JSON Lines', extensions: ['jsonl'] }, + ], + }); + if (result.canceled || !result.filePath) { + return { success: false as const, reason: 'cancelled' as const }; + } + await fs.promises.writeFile(result.filePath, text, 'utf8'); + return { success: true as const, path: result.filePath }; + } catch (err) { + console.error( + '[IPC] mecp:exportReceivedLog failed:', + sanitizeLogMessage(err instanceof Error ? err.message : String(err)), + ); + throw err; + } +}); + ipcMain.handle('gps:exportGpx', async (event, opts: unknown) => { if (!validateIpcSender(event)) throw new Error('IPC sender validation failed'); const o = opts && typeof opts === 'object' ? (opts as Record) : {}; diff --git a/src/main/mecp-received-log.test.ts b/src/main/mecp-received-log.test.ts new file mode 100644 index 000000000..0f7654a92 --- /dev/null +++ b/src/main/mecp-received-log.test.ts @@ -0,0 +1,69 @@ +import fs from 'fs'; +import os from 'os'; +import path from 'path'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('electron', () => ({ + app: { + getPath: () => os.tmpdir(), + }, +})); + +describe('mecp-received-log', () => { + let workDir: string; + + beforeEach(async () => { + workDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'mecp-log-')); + vi.resetModules(); + }); + + afterEach(async () => { + try { + await fs.promises.rm(workDir, { recursive: true, force: true }); + } catch { + // catch-no-log-ok cleanup + } + }); + + it('appends sanitized JSONL and exports content', async () => { + const mod = await import('./mecp-received-log'); + const logPath = path.join(workDir, 'mecp-received.log'); + mod.setMecpReceivedLogPathForTests(logPath); + + await mod.appendMecpReceivedLog({ + protocol: 'meshtastic', + severity: 0, + drill: false, + from: '!\x00bad', + payload: 'MECP/0/M01', + decoded: 'Injury', + }); + + const text = await mod.readMecpReceivedLogForExport(); + expect(text).toContain('MECP/0/M01'); + expect(text).toContain('"severity":0'); + const line = JSON.parse(text.trim().split('\n').pop()!) as { from?: string }; + expect(line.from).not.toContain('\x00'); + }); + + it('validates append payloads', async () => { + const { isValidMecpAppendPayload } = await import('./mecp-received-log'); + expect( + isValidMecpAppendPayload({ + protocol: 'meshcore', + severity: 1, + drill: true, + payload: 'MECP/1/D01', + }), + ).toBe(true); + expect(isValidMecpAppendPayload({ protocol: 'x', drill: false })).toBe(false); + expect( + isValidMecpAppendPayload({ + protocol: 'meshtastic', + severity: 9, + drill: false, + payload: 'MECP/0/M01', + }), + ).toBe(false); + }); +}); diff --git a/src/main/mecp-received-log.ts b/src/main/mecp-received-log.ts new file mode 100644 index 000000000..7e1827198 --- /dev/null +++ b/src/main/mecp-received-log.ts @@ -0,0 +1,147 @@ +/** + * Durable audit log for received MECP (emergency) messages. + * Survives app restarts; not wiped by session `mesh-client.log` promote. + */ + +import { app } from 'electron'; +import fs from 'fs'; +import path from 'path'; + +import { sanitizeLogMessage } from './sanitize-log-message'; + +export const MECP_RECEIVED_LOG_FILENAME = 'mecp-received.log'; +export const MECP_RECEIVED_LOG_BACKUP_FILENAME = 'mecp-received.log.1'; +const MECP_RECEIVED_LOG_MAX_BYTES = 20 * 1024 * 1024; // 20 MB +const MAX_FIELD_CHARS = 2000; + +export interface MecpReceivedLogEntry { + ts?: string; + protocol: string; + severity: number | null; + drill: boolean; + from?: string; + channel?: number | string; + payload: string; + decoded?: string; + direction?: 'received' | 'rebroadcast'; + toProtocol?: string; + toChannel?: number | string; + bidirectional?: boolean; + messageId?: string; +} + +let logFilePath: string | null = null; +let appendChain: Promise = Promise.resolve(); + +export function getMecpReceivedLogPath(): string { + if (!logFilePath) { + logFilePath = path.join(app.getPath('userData'), MECP_RECEIVED_LOG_FILENAME); + } + return logFilePath; +} + +/** @internal Test helper */ +export function setMecpReceivedLogPathForTests(p: string | null): void { + logFilePath = p; +} + +function clampField(value: string | number): string { + const sanitized = sanitizeLogMessage(typeof value === 'string' ? value : String(value)); + return sanitized.length > MAX_FIELD_CHARS ? sanitized.slice(0, MAX_FIELD_CHARS) : sanitized; +} + +function rotateIfNeeded(filePath: string): void { + try { + if (!fs.existsSync(filePath)) return; + const { size } = fs.statSync(filePath); + if (size < MECP_RECEIVED_LOG_MAX_BYTES) return; + const backup = path.join(path.dirname(filePath), MECP_RECEIVED_LOG_BACKUP_FILENAME); + if (fs.existsSync(backup)) { + fs.unlinkSync(backup); + } + fs.renameSync(filePath, backup); + } catch (e) { + console.warn( + '[mecp-received-log] rotate failed', + sanitizeLogMessage(e instanceof Error ? e.message : String(e)), + ); + } +} + +export function formatMecpReceivedLogLine(entry: MecpReceivedLogEntry): string { + const record = { + ts: entry.ts ?? new Date().toISOString(), + protocol: clampField(entry.protocol), + severity: entry.severity, + drill: entry.drill, + from: entry.from != null ? clampField(entry.from) : undefined, + channel: entry.channel != null ? clampField(entry.channel) : undefined, + payload: clampField(entry.payload), + decoded: entry.decoded != null ? clampField(entry.decoded) : undefined, + direction: entry.direction ?? 'received', + toProtocol: entry.toProtocol != null ? clampField(entry.toProtocol) : undefined, + toChannel: entry.toChannel != null ? clampField(entry.toChannel) : undefined, + bidirectional: entry.bidirectional, + messageId: entry.messageId != null ? clampField(entry.messageId) : undefined, + }; + return `${JSON.stringify(record)}\n`; +} + +export function appendMecpReceivedLog(entry: MecpReceivedLogEntry): Promise { + const filePath = getMecpReceivedLogPath(); + const line = formatMecpReceivedLogLine(entry); + const write = appendChain.then(async () => { + rotateIfNeeded(filePath); + await fs.promises.appendFile(filePath, line, 'utf8'); + }); + // Keep the chain alive after failures so later appends still run. + appendChain = write.catch((e: unknown) => { + console.warn( + '[mecp-received-log] append failed', + sanitizeLogMessage(e instanceof Error ? e.message : String(e)), + ); + }); + return write; +} + +export async function readMecpReceivedLogForExport(): Promise { + await appendChain.catch(() => undefined); + const filePath = getMecpReceivedLogPath(); + const backup = path.join(path.dirname(filePath), MECP_RECEIVED_LOG_BACKUP_FILENAME); + const parts: string[] = []; + try { + if (fs.existsSync(backup)) { + parts.push(await fs.promises.readFile(backup, 'utf8')); + } + } catch { + // catch-no-log-ok missing backup is fine + } + try { + if (fs.existsSync(filePath)) { + parts.push(await fs.promises.readFile(filePath, 'utf8')); + } + } catch { + // catch-no-log-ok missing current is fine + } + return parts.join(''); +} + +export function isValidMecpAppendPayload(raw: unknown): raw is MecpReceivedLogEntry { + if (!raw || typeof raw !== 'object') return false; + const o = raw as Record; + if (typeof o.protocol !== 'string' || o.protocol.length === 0 || o.protocol.length > 32) { + return false; + } + if ( + typeof o.payload !== 'string' || + o.payload.length === 0 || + o.payload.length > MAX_FIELD_CHARS + ) { + return false; + } + if (typeof o.drill !== 'boolean') return false; + if (o.severity != null && (typeof o.severity !== 'number' || o.severity < 0 || o.severity > 3)) { + return false; + } + return true; +} diff --git a/src/main/mqtt-manager.ts b/src/main/mqtt-manager.ts index e0318ec9b..e66b779bb 100644 --- a/src/main/mqtt-manager.ts +++ b/src/main/mqtt-manager.ts @@ -1015,8 +1015,8 @@ export class MQTTManager extends EventEmitter { }; try { - if (portnum === PortNum.TEXT_MESSAGE_APP) { - body.type = 'text'; + if (portnum === PortNum.TEXT_MESSAGE_APP || portnum === PortNum.ALERT_APP) { + body.type = portnum === PortNum.ALERT_APP ? 'alert' : 'text'; const textBytes = rawData.payload ?? new Uint8Array(); const textStr = new TextDecoder().decode(textBytes); let payloadVal: unknown; @@ -1597,7 +1597,7 @@ export class MQTTManager extends EventEmitter { return; } - if (typeLower === 'text') { + if (typeLower === 'text' || typeLower === 'alert') { this.handleJsonText(json, topic); return; } @@ -1984,13 +1984,16 @@ export class MQTTManager extends EventEmitter { this.upsertNodeCache({ node_id: nodeId, last_heard: Date.now() }); this.emitMinimalNodeUpdate(nodeId, hopsAway, portnum); } - } else if (portnum === PortNum.TEXT_MESSAGE_APP && (payload?.length || data.emoji)) { + } else if ( + (portnum === PortNum.TEXT_MESSAGE_APP || portnum === PortNum.ALERT_APP) && + (payload?.length || data.emoji) + ) { try { const payloadBytes = payload ?? new Uint8Array(); const resolved = resolveMeshtasticTextMessagePayload(payloadBytes); if (!resolved) { console.debug( - `[Meshtastic MQTT] Dropped non-readable TEXT_MESSAGE from node ${nodeId} len=${payloadBytes.length}`, + `[Meshtastic MQTT] Dropped non-readable ${portnum === PortNum.ALERT_APP ? 'ALERT_APP' : 'TEXT_MESSAGE'} from node ${nodeId} len=${payloadBytes.length}`, ); // log-filter-ok Meshtastic MQTT logs → App log panel this.upsertNodeCache({ node_id: nodeId, last_heard: Date.now() }); this.emitMinimalNodeUpdate(nodeId, hopsAway, portnum); @@ -2211,7 +2214,7 @@ export class MQTTManager extends EventEmitter { return false; } - if (portnum === PortNum.TEXT_MESSAGE_APP) { + if (portnum === PortNum.TEXT_MESSAGE_APP || portnum === PortNum.ALERT_APP) { if (data.emoji === MESHTASTIC_TAPBACK_DATA_EMOJI_FLAG) return true; if (!payload?.length && !data.emoji) return false; return resolveMeshtasticTextMessagePayload(payload ?? new Uint8Array()) !== null; diff --git a/src/main/support-bundle.ts b/src/main/support-bundle.ts index 903952240..4e29afa61 100644 --- a/src/main/support-bundle.ts +++ b/src/main/support-bundle.ts @@ -144,6 +144,8 @@ Contents: debug-snapshot.json — UI/session state for triage (Meshtastic, MeshCore, Reticulum sidecar) mesh-client.log — Application log (current session) mesh-client.log.1 — Prior session log (preserved on restart) or size-rotated backup + mecp-received.log — Durable MECP (emergency) received audit log + mecp-received.log.1 — Size-rotated MECP audit backup (if present) manifest.json — App version, buildChannel, and platform metadata README.txt — This file @@ -173,6 +175,8 @@ Contents: reticulum/lxmf-outbound.log — Filtered LXMF outbound / PN cascade lines from app logs mesh-client.log — Application log (current session) mesh-client.log.1 — Prior session log (preserved on restart) or size-rotated backup + mecp-received.log — Durable MECP (emergency) received audit log + mecp-received.log.1 — Size-rotated MECP audit backup (if present) manifest.json — App version, buildChannel, and platform metadata README.txt — This file `; @@ -306,6 +310,19 @@ export async function buildSupportBundleZip( zip.file(LOG_BACKUP_FILENAME, backupLog); } + const mecpLogPath = path.join(logDir, 'mecp-received.log'); + const mecpLog = await readFileOrEmpty(mecpLogPath); + if (mecpLog.length > 0) { + zip.file('mecp-received.log', mecpLog); + } + const mecpBackupPath = path.join(logDir, 'mecp-received.log.1'); + if (fs.existsSync(mecpBackupPath)) { + zip.file( + 'mecp-received.log.1', + await readFileTailOrEmpty(mecpBackupPath, MAX_SUPPORT_BUNDLE_LOG_BACKUP_BYTES), + ); + } + zip.file('manifest.json', JSON.stringify(buildManifest(mode), null, 2)); zip.file('README.txt', buildReadme(mode)); diff --git a/src/preload/index.contract.test.ts b/src/preload/index.contract.test.ts index 5efcc2fd5..b143a78d4 100644 --- a/src/preload/index.contract.test.ts +++ b/src/preload/index.contract.test.ts @@ -42,6 +42,8 @@ describe('preload bridge contract', () => { it('preload invokes chat export IPC', () => { expect(PRELOAD_SOURCE).toContain("'chat:export'"); + expect(PRELOAD_SOURCE).toContain("'mecp:appendReceived'"); + expect(PRELOAD_SOURCE).toContain("'mecp:exportReceivedLog'"); }); it('preload exposes readReticulumAttachmentAsDataUrl and linkPreview kind', () => { diff --git a/src/preload/index.ts b/src/preload/index.ts index 7c42ffcfa..6f272f861 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -1366,6 +1366,17 @@ contextBridge.exposeInMainWorld('electronAPI', { reason?: 'empty' | 'cancelled' | 'no_db' | 'no_window'; }>, }, + mecp: { + appendReceived: (entry: unknown) => + ipcRenderer.invoke('mecp:appendReceived', entry) as Promise<{ ok: true }>, + exportReceivedLog: () => + ipcRenderer.invoke('mecp:exportReceivedLog') as Promise<{ + success: boolean; + path?: string; + reason?: 'empty' | 'cancelled' | 'error'; + }>, + }, + chat: { export: (messages: unknown[]) => ipcRenderer.invoke('chat:export', messages) as Promise<{ success: boolean; path?: string }>, diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index 0b14112a3..f5b9fc67e 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -18,6 +18,7 @@ import { import { useTranslation } from 'react-i18next'; import { MESHCORE_ROOM_MESSAGE_CHANNEL } from '@/renderer/hooks/meshcore/meshcoreHookPreamble'; +import { useMecpAlertWatcher } from '@/renderer/hooks/useMecpAlertWatcher'; import { isAppWindowInactive } from '@/renderer/lib/appWindowActivity'; import { resolveInactiveChatNotificationType } from '@/renderer/lib/chatInactiveNotifications'; import { @@ -1293,6 +1294,35 @@ function AppContent() { [reticulumIdentity, reticulumRuntime.selfNodeId, reticulumRuntime.state.myNodeNum], ); + const mecpMeshtasticSlice = useMemo( + () => ({ + protocol: 'meshtastic' as const, + messages: meshtasticStoreMessages, + ownNodeIds: meshtasticOwnNodeIdSet, + ownSenderId: meshtasticRuntime.state.myNodeNum, + }), + [meshtasticStoreMessages, meshtasticOwnNodeIdSet, meshtasticRuntime.state.myNodeNum], + ); + const mecpMeshcoreSlice = useMemo( + () => ({ + protocol: 'meshcore' as const, + messages: meshcoreStoreMessages, + ownNodeIds: meshcoreOwnNodeIdSet, + ownSenderId: meshcoreRuntime.selfNodeId, + }), + [meshcoreStoreMessages, meshcoreOwnNodeIdSet, meshcoreRuntime.selfNodeId], + ); + const mecpReticulumSlice = useMemo( + () => ({ + protocol: 'reticulum' as const, + messages: reticulumStoreMessages, + ownNodeIds: reticulumOwnNodeIdSet, + ownSenderId: reticulumRuntime.state.myNodeNum, + }), + [reticulumStoreMessages, reticulumOwnNodeIdSet, reticulumRuntime.state.myNodeNum], + ); + useMecpAlertWatcher(mecpMeshtasticSlice, mecpMeshcoreSlice, mecpReticulumSlice); + useEffect(() => { if (!reticulumIdentityId || reticulumLastReadSanitizedRef.current) return; if (localStorage.getItem('mesh-client:lastReadSanitized:reticulum') === '1') { diff --git a/src/renderer/components/AppPanel.tsx b/src/renderer/components/AppPanel.tsx index 38658f45a..80536f83b 100644 --- a/src/renderer/components/AppPanel.tsx +++ b/src/renderer/components/AppPanel.tsx @@ -2,6 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; +import { MecpRebroadcastSettings } from '@/renderer/components/mecp/MecpRebroadcastSettings'; import { copyDebugSnapshotToClipboard } from '@/renderer/lib/debugSnapshot'; import { errLikeToLogString } from '@/renderer/lib/errLikeToLogString'; import { exportSupportBundleToDisk } from '@/renderer/lib/exportSupportBundle'; @@ -169,6 +170,7 @@ interface AppSettings { meshcoreOpenWireCompatEnabled: boolean; meshcorePathHashMode: 0 | 1 | 2; rrcUnreadAllRoomMessages: boolean; + mecpComposeEnabled: boolean; } const DEFAULT_SETTINGS: AppSettings = { @@ -258,6 +260,7 @@ export default function AppPanel({ const [supportBundleExporting, setSupportBundleExporting] = useState( null, ); + const [mecpExportBusy, setMecpExportBusy] = useState(false); const { addToast } = useToast(); const { t } = useTranslation(); const resolveNodes = useCallback( @@ -2142,6 +2145,98 @@ export default function AppPanel({ )} +
+
+

{t('mecp.section.title')}

+

{t('mecp.section.hint')}

+ +
+
+
+ { + updateSetting('mecpComposeEnabled', e.target.checked); + }} + aria-label={t('mecp.section.showComposeButton')} + className="accent-brand-green h-4 w-4 rounded" + /> + +
+

+ {t('mecp.section.showComposeButtonHint')} +

+
+ + +
+ {/* Danger Zone — collapsible; same pattern as Appearance → Color scheme */}

{t('appPanel.dangerZoneSection')}

diff --git a/src/renderer/components/ChatPanel.test.tsx b/src/renderer/components/ChatPanel.test.tsx index 4a4d98977..3b539e9c1 100644 --- a/src/renderer/components/ChatPanel.test.tsx +++ b/src/renderer/components/ChatPanel.test.tsx @@ -54,7 +54,11 @@ async function waitForComposer(): Promise { return textarea; } -vi.mock('../lib/chatNotifications', () => ({ playMessageNotification: vi.fn() })); +vi.mock('../lib/chatNotifications', () => ({ + playMessageNotification: vi.fn(), + playMecpSiren: vi.fn(), + playMecpEasAttention: vi.fn(), +})); let mockIsAtEnd = true; let mockScrollDirection: 'forward' | 'backward' | null = 'forward'; @@ -4246,9 +4250,13 @@ describe('ChatPanel — channel selection restored across reconnect', () => { describe('ChatPanel — notification sound on new messages', () => { const playMock = vi.mocked(chatNotifications.playMessageNotification); + const sirenMock = vi.mocked(chatNotifications.playMecpSiren); + const easMock = vi.mocked(chatNotifications.playMecpEasAttention); beforeEach(() => { playMock.mockClear(); + sirenMock.mockClear(); + easMock.mockClear(); localStorage.removeItem('mesh-client:notifMuted'); }); @@ -4386,6 +4394,95 @@ describe('ChatPanel — notification sound on new messages', () => { await waitForComposer(); expect(playMock).not.toHaveBeenCalled(); }); + + it('plays MECP siren when focused on the receiving chat for severity 0', async () => { + const { rerender } = render( + + + , + ); + + await waitForComposer(); + playMock.mockClear(); + sirenMock.mockClear(); + + const mecpMsg = makeMsg({ + sender_id: 2, + channel: 0, + payload: 'MECP/0/M01', + packetId: 9001, + isHistory: undefined, + }); + rerender( + + + , + ); + + await waitForComposer(); + expect(sirenMock).toHaveBeenCalledOnce(); + expect(playMock).not.toHaveBeenCalled(); + }); + + it('plays EAS attention tone when focused on the receiving chat for severity 1', async () => { + const { rerender } = render( + + + , + ); + + await waitForComposer(); + playMock.mockClear(); + sirenMock.mockClear(); + easMock.mockClear(); + + const mecpMsg = makeMsg({ + sender_id: 2, + channel: 0, + payload: 'MECP/1/T04', + packetId: 9003, + isHistory: undefined, + }); + rerender( + + + , + ); + + await waitForComposer(); + expect(easMock).toHaveBeenCalledOnce(); + expect(sirenMock).not.toHaveBeenCalled(); + expect(playMock).not.toHaveBeenCalled(); + }); + + it('plays noticeable mecp tone when focused on the receiving chat for severity 3', async () => { + const { rerender } = render( + + + , + ); + + await waitForComposer(); + playMock.mockClear(); + sirenMock.mockClear(); + + const mecpMsg = makeMsg({ + sender_id: 2, + channel: 0, + payload: 'MECP/3/L01', + packetId: 9002, + isHistory: undefined, + }); + rerender( + + + , + ); + + await waitForComposer(); + expect(playMock).toHaveBeenCalledWith('mecp'); + expect(sirenMock).not.toHaveBeenCalled(); + }); }); describe('ChatPanel reticulum dm-only chat', () => { diff --git a/src/renderer/components/ChatPanel.tsx b/src/renderer/components/ChatPanel.tsx index 275fd3fc3..a6e934a4f 100644 --- a/src/renderer/components/ChatPanel.tsx +++ b/src/renderer/components/ChatPanel.tsx @@ -37,6 +37,7 @@ import { } from 'react'; import { useTranslation } from 'react-i18next'; +import { isMecpComposeEnabled } from '@/renderer/lib/appSettingsStorage'; import { isAppWindowInactive } from '@/renderer/lib/appWindowActivity'; import { translateChatSendError } from '@/renderer/lib/chatSendErrorI18n'; import { errLikeToLogString } from '@/renderer/lib/errLikeToLogString'; @@ -142,6 +143,14 @@ import { resolveChatDmPeer, } from '../lib/chatUnreadCounts'; import { applyControlledEditableValue } from '../lib/controlledEditableValue'; +import { triggerMecpAlert } from '../lib/mecp/mecpAlert'; +import { + getCachedMecpLanguage, + loadMecpLanguage, + localizeMecpCodes, + mecpLanguageForAppLocale, + tryParseMecp, +} from '../lib/mecp/mecpMessages'; import { findMeshcoreParentMessageForReply, meshcoreChatMessagesForDisplay, @@ -179,6 +188,8 @@ import { ChatDmPaperShareControl, ChatPaperScanControl } from './ChatDmPaperCont import { ChatPayloadText } from './ChatPayloadText'; import { ChatRfHopLabel } from './ChatRfHopLabel'; import { HelpTooltip } from './HelpTooltip'; +import { MecpComposeModal } from './mecp/MecpComposeModal'; +import { mecpChatBubbleToneClasses, MecpSeverityBadge } from './mecp/MecpSeverityBadge'; import MeshcoreChatChannelManager from './MeshcoreChatChannelManager'; import { MessageStatusBadge } from './MessageStatusBadge'; import { RelayCoverageLine, relayCoverageMessageKey } from './RelayCoverageLine'; @@ -639,7 +650,7 @@ function ChatPanel({ resolveShareLocation, onSendLocationWaypoint, }: ChatPanelProps) { - const { t } = useTranslation(); + const { t, i18n } = useTranslation(); const capabilities = useRadioProvider(protocol); const use24HourTime = useTimeFormatStore((s) => s.use24HourTime); const parentIconTrigger = useParentIconTrigger(); @@ -905,6 +916,36 @@ function ChatPanel({ viewKey: string; } | null>(null); const [replyTo, setReplyTo] = useState(null); + const [mecpComposeOpen, setMecpComposeOpen] = useState(false); + const [mecpComposeSession, setMecpComposeSession] = useState(0); + const [mecpComposeEnabled, setMecpComposeEnabled] = useState(() => isMecpComposeEnabled()); + const [mecpLang, setMecpLang] = useState(() => + getCachedMecpLanguage(mecpLanguageForAppLocale(i18n.language || 'en')), + ); + + useEffect(() => { + const sync = () => { + setMecpComposeEnabled(isMecpComposeEnabled()); + }; + window.addEventListener('mesh-client:appSettings', sync); + return () => { + window.removeEventListener('mesh-client:appSettings', sync); + }; + }, []); + + useEffect(() => { + if (!mecpComposeEnabled) setMecpComposeOpen(false); + }, [mecpComposeEnabled]); + + useEffect(() => { + let cancelled = false; + void loadMecpLanguage(mecpLanguageForAppLocale(i18n.language || 'en')).then((lang) => { + if (!cancelled) setMecpLang(lang); + }); + return () => { + cancelled = true; + }; + }, [i18n.language]); const [pickerOpenFor, setPickerOpenFor] = useState(null); const [searchQuery, setSearchQuery] = useState(''); const [showSearch, setShowSearch] = useState(false); @@ -1479,13 +1520,38 @@ function ChatPanel({ saveStarred(protocol, starred); }, [protocol, starred]); - // Sound notification: plays when a new message arrives on a view the user is not reading. + // Sound notification: regular channel/DM tones for other views; MECP always (incl. open view). useEffect(() => { const prevLen = prevMessagesLengthRef.current; prevMessagesLengthRef.current = messages.length; - if (localStorage.getItem('mesh-client:notifMuted') === '1' || messages.length <= prevLen) - return; + if (messages.length <= prevLen) return; const newMsgs = messages.slice(prevLen); + + // MECP: audible even when focused on the receiving conversation (0–1 mute-bypass inside). + if (isActive && !isAppWindowInactive()) { + for (const msg of newMsgs) { + if (isOwnNode(msg.sender_id) || msg.isHistory) continue; + const mecp = tryParseMecp(msg.payload); + if (mecp?.severity == null) continue; + const peer = resolveDmPeer(msg); + const msgViewKey = peer != null ? `dm:${peer}` : `ch:${msg.channel}`; + const dedupeId = + msg.storeId ?? + (msg.packetId != null ? String(msg.packetId) : undefined) ?? + (msg.id != null ? String(msg.id) : undefined) ?? + `${msg.timestamp}:${msg.payload}`; + triggerMecpAlert({ + severity: mecp.severity, + isDrill: mecp.isDrill, + senderLabel: msg.sender_name || String(msg.sender_id), + viewKey: msgViewKey, + mutedViews, + dedupeKey: `${protocol}:${dedupeId}`, + }); + } + } + + if (localStorage.getItem('mesh-client:notifMuted') === '1') return; const gated = newMsgs.filter((msg) => { if (isOwnNode(msg.sender_id) || msg.isHistory) return false; const peer = resolveDmPeer(msg); @@ -3075,8 +3141,12 @@ function ChatPanel({ > {/* Message bubble */}
{ + const mecp = tryParseMecp(msg.payload); + if (mecp?.severity != null) { + return mecpChatBubbleToneClasses(mecp.severity, isOwn); + } + return compactMerged ? `${compactStackTop ? 'rounded-t-none border-t-0' : ''} ${compactStackBottom ? 'rounded-b-none border-b-0' : ''} ${ isDm ? isOwn @@ -3092,8 +3162,8 @@ function ChatPanel({ : `${isFollowedByContinuation ? 'rounded-bl-none' : 'rounded-bl-sm'} border border-purple-600/30 bg-purple-700/20${isContinuation ? 'rounded-tl-sm' : ''}` : isOwn ? `${isFollowedByContinuation ? 'rounded-br-none' : 'rounded-br-sm'} border border-blue-500/30 bg-blue-600/20${isContinuation ? 'rounded-tr-sm' : ''}` - : `${isFollowedByContinuation ? 'rounded-bl-none' : 'rounded-bl-sm'} border-chat-incoming-border border bg-chat-incoming-bg${isContinuation ? 'rounded-tl-sm' : ''}` - }`} + : `${isFollowedByContinuation ? 'rounded-bl-none' : 'rounded-bl-sm'} border-chat-incoming-border border bg-chat-incoming-bg${isContinuation ? 'rounded-tl-sm' : ''}`; + })()}`} > {/* Header: sender name (clickable) + DM indicator + time */} {!isContinuation && @@ -3341,6 +3411,21 @@ function ChatPanel({ }} /> )} + {(() => { + const mecp = tryParseMecp(msg.payload); + if (mecp?.severity == null) return null; + return ( +
+ +

+ {localizeMecpCodes(mecp, mecpLang)} +

+
+ ); + })()}
{/* Transport + RF hop count (incoming) */} @@ -3718,6 +3803,39 @@ function ChatPanel({ {protocol === 'reticulum' && hasLxmfPaper ? ( ) : null} + {mecpComposeEnabled ? ( +
+ +
+ ) : null} + {mecpComposeEnabled ? ( + { + setMecpComposeOpen(false); + }} + resolveGps={resolveShareLocation} + onSend={async (text) => { + if (viewMode === 'dm' && activeDmNode == null) { + const message = t('chatPanel.selectDmFirst'); + setChatActionError({ message, viewKey }); + throw new Error(message); + } + await handleSendChunk(text); + }} + /> + ) : null} { import { hydrateAxeThemeColors } from '../lib/a11yTestHelpers'; import { reticulumHashToNodeId } from '../lib/reticulum/destHash'; +import { + dismissReticulumStaleAlternate, + resetReticulumStaleAlternateDismissForTests, +} from '../lib/reticulum/reticulumStaleAlternateDismiss'; import { useNomadNetworkStore } from '../stores/nomadNetworkStore'; import { useReticulumIdentityActivityStore } from '../stores/reticulumIdentityActivityStore'; import { useReticulumPeerStore } from '../stores/reticulumPeerStore'; @@ -123,6 +127,7 @@ function fillLargePeerMap(count: number, buried?: { hash: string; name: string } describe('ReticulumPeerListPanel', () => { beforeEach(() => { + resetReticulumStaleAlternateDismissForTests(); reticulumSidecarMocks.isReticulumSidecarRunning.mockResolvedValue(true); reticulumSidecarMocks.requestReticulumPeerPath.mockReset(); reticulumSidecarMocks.probeReticulumPeer.mockReset(); @@ -872,6 +877,84 @@ describe('ReticulumPeerListPanel', () => { expect(chatButtons.length).toBeGreaterThanOrEqual(1); }); + it('skips stale-alternate toast when that pair was already dismissed', async () => { + const user = userEvent.setup(); + const wired = 'd010ea4417f71ff4fd15a6182747aaec'; + const wiredId = '098c1ee916253f73459dda7ced773c60'; + const testLxmf = 'e3359f1314aff4fb6261400a8202149b'; + const testId = '0f79468863d76b3ba574baa92606ffcb'; + dismissReticulumStaleAlternate(wired, testLxmf); + useReticulumPeerStore.setState({ + peers: new Map([ + [ + wired, + { + destination_hash: wired, + display_name: 'Ceorl-wired', + identity_hash: wiredId, + hops: 2, + last_seen: Date.now() / 1000, + interface: 'RNS_Transport_US-East', + }, + ], + [ + testLxmf, + { + destination_hash: testLxmf, + display_name: 'Ceorl-test', + identity_hash: testId, + hops: 1, + last_seen: Date.now() / 1000, + interface: 'RNodeInterface', + }, + ], + ]), + contacts: new Map(), + history: new Map(), + peersRevision: 3, + }); + useReticulumIdentityActivityStore.setState({ + byDestination: new Map([ + [ + wired, + [ + { + destination_hash: wired, + aspect: 'lxmf.delivery', + identity_hash: wiredId, + last_seen: 100, + }, + ], + ], + [ + testLxmf, + [ + { + destination_hash: testLxmf, + aspect: 'lxmf.delivery', + identity_hash: testId, + last_seen: 200, + }, + ], + ], + ]), + }); + const onSendMessage = vi.fn(); + render( + + + , + ); + const hubBadge = screen.getByText('peerListPanel.heardViaTcpHub'); + const row = hubBadge.closest('tr'); + expect(row).toBeTruthy(); + const chatBtn = row!.querySelector('button[aria-label="peerListPanel.openChat"]'); + expect(chatBtn).toBeTruthy(); + await user.click(chatBtn!); + expect(onSendMessage).toHaveBeenCalled(); + expect(screen.queryByText(/peerListPanel.staleAlternateToastNamed/)).not.toBeInTheDocument(); + }); + it('has no serious axe violations', async () => { const { container } = render( diff --git a/src/renderer/components/ReticulumPeerListPanel.tsx b/src/renderer/components/ReticulumPeerListPanel.tsx index a309a1942..979454f28 100644 --- a/src/renderer/components/ReticulumPeerListPanel.tsx +++ b/src/renderer/components/ReticulumPeerListPanel.tsx @@ -60,6 +60,7 @@ import { probeReticulumPeer, requestReticulumPeerPath, } from '@/renderer/lib/reticulum/reticulumSidecarReads'; +import { isReticulumStaleAlternateDismissed } from '@/renderer/lib/reticulum/reticulumStaleAlternateDismiss'; import type { ReticulumPeer } from '@/shared/reticulum-types'; import type { ContactGroup } from '../../shared/electron-api.types'; @@ -668,6 +669,7 @@ export default function ReticulumPeerListPanel({ openHasDelivered: stats.deliveredOutboundHashes.has(openNorm), }); if (hint.status !== 'stale_alternate') return; + if (isReticulumStaleAlternateDismissed(hint.openHash, hint.alternateHash)) return; const openPrefix = openNorm.slice(0, 8); const alternatePrefix = hint.alternateHash.slice(0, 8); addToast( diff --git a/src/renderer/components/Toast.tsx b/src/renderer/components/Toast.tsx index 3f5a3bbb6..9df84d35b 100644 --- a/src/renderer/components/Toast.tsx +++ b/src/renderer/components/Toast.tsx @@ -9,7 +9,7 @@ import { } from 'react'; import { useTranslation } from 'react-i18next'; -type ToastType = 'success' | 'error' | 'warning' | 'info'; +type ToastType = 'success' | 'error' | 'warning' | 'info' | 'emergency'; export interface ToastAction { label: string; @@ -134,6 +134,7 @@ function ToastItem({ toast, onDismiss }: { toast: Toast; onDismiss: (id: number) error: '✗', warning: '⚠', info: 'ℹ', + emergency: '!', }[toast.type]; const colors = { @@ -141,6 +142,7 @@ function ToastItem({ toast, onDismiss }: { toast: Toast; onDismiss: (id: number) error: 'bg-red-900/90 border-red-600 text-red-200', warning: 'bg-yellow-900/90 border-yellow-600 text-yellow-200', info: 'bg-deep-black/90 border-gray-600 text-gray-200', + emergency: 'bg-red-900/95 border-red-500 text-red-100', }[toast.type]; return ( diff --git a/src/renderer/components/mecp/MecpComposeModal.test.tsx b/src/renderer/components/mecp/MecpComposeModal.test.tsx new file mode 100644 index 000000000..9b1f9f8ab --- /dev/null +++ b/src/renderer/components/mecp/MecpComposeModal.test.tsx @@ -0,0 +1,79 @@ +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { describe, expect, it, vi } from 'vitest'; +import { axe } from 'vitest-axe'; + +import { hydrateAxeThemeColors } from '@/renderer/lib/a11yTestHelpers'; + +import { bumpMecpPaxFreetext, MecpComposeModal } from './MecpComposeModal'; +import { + MECP_SEVERITY_BADGE_CLASSES, + mecpChatBubbleToneClasses, + MecpSeverityBadge, +} from './MecpSeverityBadge'; + +describe('bumpMecpPaxFreetext', () => { + it('appends then increments pax counts', () => { + expect(bumpMecpPaxFreetext('')).toBe('1pax'); + expect(bumpMecpPaxFreetext('note')).toBe('note 1pax'); + expect(bumpMecpPaxFreetext('note 1pax')).toBe('note 2pax'); + expect(bumpMecpPaxFreetext('3pax west')).toBe('4pax west'); + }); +}); + +describe('MecpComposeModal', () => { + it('defaults to ROUTINE and Drill category with no codes selected', async () => { + const user = userEvent.setup(); + const onSend = vi.fn(); + render( {}} onSend={onSend} />); + expect(screen.getByRole('button', { name: 'ROUTINE' })).toHaveAttribute('aria-pressed', 'true'); + expect(screen.getByRole('button', { name: 'Drill / Test' })).toHaveAttribute( + 'aria-pressed', + 'true', + ); + expect(screen.getByRole('button', { name: /D02 This is a test/i })).toHaveAttribute( + 'aria-pressed', + 'false', + ); + expect(screen.getByRole('button', { name: /send mecp/i })).toBeDisabled(); + await user.click(screen.getByRole('button', { name: /send mecp/i })); + expect(onSend).not.toHaveBeenCalled(); + }); + + it('encodes and sends when an M01 code is selected', async () => { + const user = userEvent.setup(); + const onSend = vi.fn(); + render( {}} onSend={onSend} />); + await user.click(screen.getByRole('button', { name: 'Medical' })); + await user.click(screen.getByRole('button', { name: /M01 /i })); + await user.click(screen.getByRole('button', { name: /send mecp/i })); + expect(onSend).toHaveBeenCalled(); + expect(String(onSend.mock.calls[0]?.[0])).toMatch(/^MECP\/3\//); + expect(String(onSend.mock.calls[0]?.[0])).toMatch(/M01/); + }); + + it('has no axe violations', async () => { + const { container } = render( {}} onSend={() => {}} />); + hydrateAxeThemeColors(container); + expect(await axe(container)).toHaveNoViolations(); + }); +}); + +describe('MecpSeverityBadge', () => { + it.each([0, 1, 2, 3] as const)('has no axe violations for severity %i', async (severity) => { + const { container } = render(); + hydrateAxeThemeColors(container); + expect(await axe(container)).toHaveNoViolations(); + }); + + it('uses red for 0–1, yellow for 2, blue for 3', () => { + expect(MECP_SEVERITY_BADGE_CLASSES[0]).toContain('bg-red-700'); + expect(MECP_SEVERITY_BADGE_CLASSES[1]).toContain('bg-red-700'); + expect(MECP_SEVERITY_BADGE_CLASSES[2]).toContain('bg-yellow-600'); + expect(MECP_SEVERITY_BADGE_CLASSES[3]).toContain('bg-blue-700'); + expect(mecpChatBubbleToneClasses(0, false)).toContain('border-red-500'); + expect(mecpChatBubbleToneClasses(1, true)).toContain('border-red-400'); + expect(mecpChatBubbleToneClasses(2, false)).toContain('border-yellow-500'); + expect(mecpChatBubbleToneClasses(3, true)).toContain('border-blue-400'); + }); +}); diff --git a/src/renderer/components/mecp/MecpComposeModal.tsx b/src/renderer/components/mecp/MecpComposeModal.tsx new file mode 100644 index 000000000..2f17bba40 --- /dev/null +++ b/src/renderer/components/mecp/MecpComposeModal.tsx @@ -0,0 +1,378 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { + CATEGORIES, + type CategoryLetter, + encode, + getByteLength, + getCachedMecpLanguage, + loadMecpLanguage, + MAX_MESSAGE_BYTES, + mecpLanguageForAppLocale, + type Severity, + severityLabelKey, +} from '@/renderer/lib/mecp/mecpMessages'; + +import { MECP_SEVERITY_BADGE_CLASSES } from './MecpSeverityBadge'; + +const SEVERITY_ORDER: Severity[] = [0, 1, 2, 3]; + +const FOCUSABLE_SELECTOR = + 'button:not([disabled]), [href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'; + +/** Increment an existing `Npax` token, or append `1pax`. */ +export function bumpMecpPaxFreetext(prev: string): string { + if (/(\d+)pax/.test(prev)) { + return prev.replace(/(\d+)pax/, (_, n: string) => `${Number(n) + 1}pax`); + } + const trimmed = prev.trim(); + return trimmed ? `${trimmed} 1pax` : '1pax'; +} + +interface MecpComposeModalProps { + open: boolean; + onClose: () => void; + onSend: (mecpString: string) => void | Promise; + /** App GPS waterfall (device → static → browser → IP). Prefer over raw geolocation. */ + resolveGps?: () => Promise<{ lat: number; lon: number } | null>; +} + +const DEFAULT_MECP_SEVERITY: Severity = 3; +const DEFAULT_MECP_CATEGORY: CategoryLetter = 'D'; +const DEFAULT_MECP_CODES: string[] = []; + +export function MecpComposeModal({ open, onClose, onSend, resolveGps }: MecpComposeModalProps) { + const { t, i18n } = useTranslation(); + const [severity, setSeverity] = useState(DEFAULT_MECP_SEVERITY); + const [category, setCategory] = useState(DEFAULT_MECP_CATEGORY); + const [codes, setCodes] = useState(() => [...DEFAULT_MECP_CODES]); + const [freetext, setFreetext] = useState(''); + const [langFile, setLangFile] = useState(() => + getCachedMecpLanguage(mecpLanguageForAppLocale(i18n.language || 'en')), + ); + const [sending, setSending] = useState(false); + const [error, setError] = useState(null); + const panelRef = useRef(null); + const previouslyFocusedRef = useRef(null); + const onCloseRef = useRef(onClose); + + useEffect(() => { + onCloseRef.current = onClose; + }, [onClose]); + + useEffect(() => { + void loadMecpLanguage(mecpLanguageForAppLocale(i18n.language || 'en')).then(setLangFile); + }, [i18n.language]); + + useEffect(() => { + if (!open) return; + const panel = panelRef.current; + if (!panel) return; + + previouslyFocusedRef.current = + document.activeElement instanceof HTMLElement ? document.activeElement : null; + + const focusables = () => + [...panel.querySelectorAll(FOCUSABLE_SELECTOR)].filter( + (el) => el.offsetParent !== null || el === document.activeElement, + ); + + focusables()[0]?.focus(); + + const onKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape') { + e.preventDefault(); + e.stopPropagation(); + onCloseRef.current(); + return; + } + if (e.key !== 'Tab') return; + + const nodes = focusables(); + if (nodes.length === 0) return; + + const first = nodes[0]; + const last = nodes[nodes.length - 1]; + const active = document.activeElement; + + if (e.shiftKey) { + if (active === first || !panel.contains(active)) { + e.preventDefault(); + last.focus(); + } + } else if (active === last || !panel.contains(active)) { + e.preventDefault(); + first.focus(); + } + }; + + document.addEventListener('keydown', onKeyDown, true); + return () => { + document.removeEventListener('keydown', onKeyDown, true); + const prev = previouslyFocusedRef.current; + if (prev && document.contains(prev)) { + prev.focus(); + } + }; + }, [open]); + + const codesForCategory = useMemo(() => { + return Object.keys(langFile.codes) + .filter((c) => c.startsWith(category)) + .sort(); + }, [langFile.codes, category]); + + const encoded = useMemo( + () => encode(severity, codes, freetext.trim() || undefined), + [severity, codes, freetext], + ); + + const byteLen = encoded.byteLength || getByteLength(encoded.message); + const canSend = codes.length > 0 && !encoded.overLimit && !sending; + + const clearError = useCallback(() => { + setError(null); + }, []); + + const toggleCode = useCallback( + (code: string) => { + clearError(); + setCodes((prev) => (prev.includes(code) ? prev.filter((c) => c !== code) : [...prev, code])); + }, + [clearError], + ); + + const attachGps = useCallback(async () => { + clearError(); + try { + let pos: { lat: number; lon: number } | null = null; + if (resolveGps) { + pos = await resolveGps(); + } else if (typeof navigator !== 'undefined' && navigator.geolocation) { + pos = await new Promise<{ lat: number; lon: number } | null>((resolve) => { + navigator.geolocation.getCurrentPosition( + (p) => { + resolve({ lat: p.coords.latitude, lon: p.coords.longitude }); + }, + () => { + resolve(null); + }, + { enableHighAccuracy: true, timeout: 10_000 }, + ); + }); + } + if (!pos) { + setError(t('mecp.compose.gpsFailed')); + return; + } + const gps = `${pos.lat.toFixed(5)},${pos.lon.toFixed(5)}`; + setFreetext((prev) => (prev.trim() ? `${prev.trim()} ${gps}` : gps)); + } catch (e) { + console.warn('[MecpComposeModal] GPS failed', e instanceof Error ? e.message : e); + setError(t('mecp.compose.gpsFailed')); + } + }, [clearError, resolveGps, t]); + + const handleSend = useCallback(async () => { + if (!canSend) return; + clearError(); + setSending(true); + try { + await onSend(encoded.message); + onClose(); + setCodes([...DEFAULT_MECP_CODES]); + setCategory(DEFAULT_MECP_CATEGORY); + setFreetext(''); + setSeverity(DEFAULT_MECP_SEVERITY); + } catch (e) { + console.warn('[MecpComposeModal] send failed', e instanceof Error ? e.message : e); + const msg = e instanceof Error && e.message.trim() ? e.message : t('mecp.compose.sendFailed'); + setError(msg); + } finally { + setSending(false); + } + }, [canSend, clearError, encoded.message, onClose, onSend, t]); + + if (!open) return null; + + return ( +
+ +
+ +

{t('mecp.compose.severity')}

+
+ {SEVERITY_ORDER.map((s) => ( + + ))} +
+ +

{t('mecp.compose.category')}

+
+ {(Object.keys(CATEGORIES) as CategoryLetter[]).map((letter) => ( + + ))} +
+ +

{t('mecp.compose.codes')}

+
+ {codesForCategory.map((code) => ( + + ))} +
+ {codes.length > 0 ? ( +
+ {codes.map((code) => ( + + ))} +
+ ) : null} + + +