diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 1e618f538..490110f3a 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -113,7 +113,7 @@ Sanitize user-controlled strings before logs and IPC per [AGENTS.md](AGENTS.md). - **UI:** `RoomsPanel.tsx` + shared `ChatComposer.tsx`; unread `meshcoreRoomsUnread.ts`. - **Runtime:** `useMeshcoreRuntime.ts` coordinates login queue, auto-sync (`meshcoreRoomSyncScheduler.ts`), and ingest dedup (`meshcoreStoreDedup.ts`). -- **RPC/helpers:** `meshcoreRoomLoginRpc.ts`, `meshcoreRoomPostRpc.ts`, `meshcoreRoomSession.ts`, `meshcoreChannelText.ts` (SignedPlain / tapbacks / Open wire via optional App toggle), `meshcoreGifWire.ts`, `meshcoreOpenReaction.ts`. RF-only (not MQTT). User guide: [docs/meshcore-meshtastic-parity.md](docs/meshcore-meshtastic-parity.md#meshcore-room-servers). +- **RPC/helpers:** `meshcoreRoomLoginRpc.ts`, `meshcoreRoomPostRpc.ts`, `meshcoreRoomSession.ts`, `meshcoreChannelText.ts` (SignedPlain / tapbacks / Open wire via optional Radio toggle), `meshcoreGifWire.ts`, `meshcoreOpenReaction.ts`. RF-only (not MQTT). User guide: [docs/meshcore-meshtastic-parity.md](docs/meshcore-meshtastic-parity.md#meshcore-room-servers). ### UI diff --git a/README.md b/README.md index 2bd66a845..63ea6b2cc 100644 --- a/README.md +++ b/README.md @@ -252,7 +252,7 @@ MeshCore runs simultaneously alongside Meshtastic and Reticulum. Use the protoco - Channel messaging and **direct messages (DMs)** with delivery ACK tracking (`expectedAckCrc`) and failure timeout; **DM threads can be closed** from the chat UI - **Transport badges** on received messages; **RF**, **MQTT**, or **both** (persisted as `received_via` in `meshcore_messages`); MQTT JSON chat can be used when RF is down - **Inbound dedup** (`meshcoreStoreDedup.ts`): merges duplicate RF/MQTT echoes, companion TX echoes, and tapback self-echoes so chat and Rooms stay readable -- **MeshCore Open GIFs**: inbound `g:GIFID` (and Giphy URLs) render inline in chat; outbound send via App **MeshCore Open compatibility** toggle (paste URL/ID or **GIF** composer button) — see [parity doc](docs/meshcore-meshtastic-parity.md#meshcore-open-gif-wire-ggifid) +- **MeshCore Open GIFs**: inbound `g:GIFID` (and Giphy URLs) render inline in chat; outbound send via Radio **MeshCore Open compatibility** toggle (paste URL/ID or **GIF** composer button) — see [parity doc](docs/meshcore-meshtastic-parity.md#meshcore-open-gif-wire-ggifid) - Incoming push events: periodic advert (0x80), path update (0x81), send confirmed (0x82), message waiting (0x83), new contact (0x8A), incoming DM (7), incoming channel message (8) - All messages and contacts persisted to SQLite (`meshcore_messages`, `meshcore_contacts` tables) @@ -261,7 +261,7 @@ MeshCore runs simultaneously alongside Meshtastic and Reticulum. Use the protoco - Login to room-server contacts (guest read-only or admin post); **Continue read-only** when the server guest password is empty (zero-byte password, matching the official Android client) - Post plain UTF-8 after login; inbound **SignedPlain** pushes show author prefix stripped in the UI - **Remember password**, **Auto-sync** (periodic re-login while connected, minimum 60 minutes per room), per-room unread badges (sidebar **Rooms** tab; separate from **Chat** badges) -- Room admin CLI in the Rooms panel; session/login queue and path sync in `meshcoreRoom*.ts` — see [docs/meshcore-meshtastic-parity.md](docs/meshcore-meshtastic-parity.md#meshcore-room-servers) and [Troubleshooting](docs/troubleshooting.md#meshcore-room-server-login-posts-and-windows-10) +- Room admin CLI / ACL setperm on the **Repeaters** tab (room rows); Rooms Members still call `get acl` via the same CLI path. Session/login queue and path sync in `meshcoreRoom*.ts` — see [docs/meshcore-meshtastic-parity.md](docs/meshcore-meshtastic-parity.md#meshcore-room-servers) and [Troubleshooting](docs/troubleshooting.md#meshcore-room-server-login-posts-and-windows-10) **Diagnostics & Remote Queries** @@ -272,8 +272,8 @@ MeshCore runs simultaneously alongside Meshtastic and Reticulum. Use the protoco **Repeaters** -- **Repeaters panel** (MeshCore-only tab): list repeaters with on-demand status (noise floor, RSSI/SNR, packet counts, air time, uptime, TX queue); **Path** column shows a per-hop SNR sparkline from the last trace (last trace/path hop data is also stored in local SQLite so sparklines can survive app restarts); per-row **Neighbors** expands an inline neighbor list (same query as node detail, including **Load more**) -- **Per-repeater admin passwords**: optional **Remember** saves credentials per repeater in SQLite `app_settings` (`meshcoreRepeaterCredential:`); collapsible **Saved repeater passwords** sidebar section with per-repeater Forget +- **Repeaters panel** (MeshCore-only tab): list **repeaters and room servers** (All / Repeaters / Rooms filter) with on-demand status (noise floor, RSSI/SNR, packet counts, air time, uptime, TX queue); **Path** column shows a per-hop SNR sparkline from the last trace (last trace/path hop data is also stored in local SQLite so sparklines can survive app restarts); per-row **Neighbors** expands an inline neighbor list (same query as node detail, including **Load more**); room rows add **Open room** (jump to Rooms) plus room CLI pills (`get acl`, `allow.read.only`, ACL setperm) +- **Per-node admin passwords**: optional **Remember** saves credentials per repeater/room in SQLite `app_settings` (`meshcoreRepeaterCredential:` / room admin password); collapsible **Saved passwords** sidebar section with per-node Forget - **Waiting-message drain**: header status indicator (queued backlog and active sync on any protocol tab; **paused/deferred** state only on the MeshCore tab) during serial companion backlog drain; **Sync now** for manual catch-up - **Repeater CLI**: per-repeater expandable **CLI** interface; command input with Enter to send, scrollable command/response history, Up/Down arrow history navigation, quick-command bar (get name, get radio, neighbors, version, clock, clock sync, clear stats, advert, board, …), flood vs. auto (saved path) routing toggle; responses are correlated to commands via 2-character hex prefix tokens; configurable retries with dynamic timeout; **auto Ping** before the first multi-hop CLI command when no trace exists this session (info toast while establishing route); **destructive-command confirm** modal for reboot/erase/factory-reset patterns - **Remote session authentication (optional)**: Password may be required for **CLI** and some **telemetry** paths when firmware ACL demands it. **Status** and **Neighbors** use pubkey-framed companion commands and typically work without login on direct (0-hop) repeaters; the auth modal offers “Continue without password.” Saved passwords persist when **Remember** is checked. Admin RPCs share a serialized companion queue — expect up to ~2 minutes blocked while a ping or multi-hop request runs. Status/Telemetry/Neighbors toast when the radio is disconnected. diff --git a/docs/agents/meshcore-repeaters.md b/docs/agents/meshcore-repeaters.md index 096aa6332..b7106ac7f 100644 --- a/docs/agents/meshcore-repeaters.md +++ b/docs/agents/meshcore-repeaters.md @@ -2,21 +2,25 @@ Deep subsystem reference for AI assistants. Open this when a task touches MeshCore repeater admin RPCs, trace/ping, neighbors paging, CLI, or waiting-message drain. Hard rules live in [`AGENTS.md`](../../AGENTS.md). +**Repeaters & Rooms ops tab:** [`RepeatersPanel.tsx`](../../src/renderer/components/RepeatersPanel.tsx) lists **both** `hw_model === 'Repeater'` and `hw_model === 'Room'` (All / Repeaters / Rooms filter). Sidebar label is `tabs.repeaters` (**Repeaters**); in-panel title is `repeatersPanel.title` (**Repeaters & Rooms**). Rooms BBS stays on the Rooms tab; Manage there jumps here via `pendingFocusNodeId`. **Open room** on a room row jumps back to Rooms. Admin passwords use [`meshcoreInfraAdminSecrets.ts`](../../src/renderer/lib/meshcoreInfraAdminSecrets.ts) (two `app_settings` prefixes; room forget clears **admin only**). Room CLI goes through `sendRepeaterCliCommand` / thin `sendRoomAdminCliCommand` alias (forwards `confirmedDanger`); room remote CLI requires ACL admin password (not guest BBS). Room-only CLI pills: `get acl`, `allow.read.only on|off` + ACL `setperm` form. + MeshCore firmware **serializes traceroutes** — one active trace cycle per RF link. mesh-client enforces: - **Trace queue** (`meshcoreRepeaterRpcInFlight.ts`): global ping queue; duplicate clicks coalesce per node. - **Companion queue** (`repeaterRemoteRpcQueue.ts`): serializes RPC _sends_ (Status, Telemetry, Neighbors binary req, trace SendTracePath, CLI login). - **Queued send** (`meshcoreRepeaterRpcQueuedSend.ts`): queue slot ends at `RESP_SENT`; response listeners run outside the slot. - **Admin idle** (`meshcoreTraceRadioIdle.ts`): `beforeSend` waits for TraceData in flight only (not pending route registration). Same-node admin awaits ping wrapper settle (`MESHCORE_REPEATER_PING_SETTLE_MAX_MS` = 2× ping cap). -- **0-hop contract** (`meshcoreRepeaterTracePath.ts`, `meshcoreZeroHopRepeaterWorkingState.test.ts`): Status/Telemetry/Neighbors use pubkey-framed frames (no contact-list gate). Ping seeds 1-byte prefix; direct retry escalates to full pubkey only when `hopsAway === 0`. Multi-hop ping requires hash-segment path (≥2 bytes), never full destination pubkey. Status/Telemetry/Neighbors **throw** on disconnect (`MESHCORE_ERR_NOT_CONNECTED`) so RepeatersPanel / node-detail toasts fire — do not bare-`return`. +- **0-hop contract** (`meshcoreRepeaterTracePath.ts`, `meshcoreZeroHopRepeaterWorkingState.test.ts`): Status/Telemetry/Neighbors use pubkey-framed frames (no contact-list gate). Ping seeds a per-contact pubkey prefix sized from that contact’s packed `outPathLen` (1–3 bytes; plain lengths still 1-byte); direct retry escalates to full pubkey only when `hopsAway === 0`. Multi-hop ping requires hash-segment path (≥2 bytes), never full destination pubkey. Status/Telemetry/Neighbors **throw** on disconnect (`MESHCORE_ERR_NOT_CONNECTED`) so RepeatersPanel / node-detail toasts fire — do not bare-`return`. +- **One active traceroute**: firmware allows one TraceData cycle; `meshcoreTracePathMultiplex` waits for prior TraceData before the next `SendTracePath`. Room-login active traces use the same global ping queue (`runMeshcoreRepeaterRpcOnce('trace')`) and cancel the multiplex handle on route-resolve timeout. - **Neighbors paging** (`MESHCORE_NEIGHBORS_PAGE_SIZE` = 50 request cap, `MeshcoreRequestNeighborsOpts.offset`, `mergeMeshcoreNeighborPage`, `meshcoreGetNeighboursBinary.ts`): first fetch replaces the cache; `offset > 0` appends when `offset === cache.length` (dedupe by `prefixHex`). In-flight coalesce keys by offset so refresh and Load more do not share one closed-over fetch. Firmware reply buffers often return fewer rows than requested (~11 at 6-byte prefixes); UI **Load more** on RepeatersPanel and NodeDetailModal continues from `neighbours.length`. - **Trace route priming** (`meshcoreTraceRoutePrime.ts`, `meshcoreRepeaterTracePath.ts`, constants/wait helpers in `meshcoreHookPreamble.ts`): when multi-hop but outPath bytes are missing, **passive** PathUpdated (129) wait + contact refresh first (**15s + 5s × hops**, cap **45s**/round). For **2+ hops**, if passive fails, up to **two** **flood-advert** rounds as fallback (listener registered **before** each advert). **1-hop** targets may synthesize `[relayPrefix, destPrefix]` from a known 0-hop repeater; **2-hop** may prepend a relay byte to a stored 2-byte path. Skip priming when synthesis or a usable stored path exists. Ping/trace may fast-fail with `meshcore.errors.pingNoRoute` when priming and synthesis cannot produce a hash-segment path (≥2 bytes for multi-hop). - **Prefix-matched push RPCs** (`meshcoreRepeaterPrefixPushRpc.ts`): Status, Telemetry, and repeater admin login share pubkey-prefix listeners; login registers LoginFail as an auxiliary event while waiting for LoginSuccess. - **Timeouts**: Status/Telemetry/Neighbors = 120s flat; ping end-to-end = 180s; SENT wait = 45s. - **Login**: Optional for CLI/telemetry when password saved; Status/Neighbors do not require login RPC. **Room login** rejects immediately on prefix-matched LoginFail. **Repeater admin login** matches meshcore.js — LoginFail alone does not reject (congested links may emit LoginFail before LoginSuccess); timeout after LoginFail is reported as timeout, not wrong password. -- **Repeater CLI danger**: destructive commands (`meshcoreRepeaterCliDanger.ts`) require confirm modal in Repeaters panel; runtime rejects unconfirmed sends (`meshcore.errors.cliDangerNotConfirmed`). Commands longer than **512** characters (`REPEATER_CLI_MAX_COMMAND_LENGTH`) are rejected before send. Multi-hop CLI auto-pings once per session when no trace exists (`RepeatersPanel` → `onPing`); CLI aborts when ping does not produce a trace result. Safe quick pills include `clock`, `clock sync`, `clear stats`, `advert`, `board` (firmware CLI tokens as labels). -- **Per-repeater passwords:** shared factory `meshcorePerNodeCredentialStorage.ts` with `meshcoreRepeaterCredentialStorage.ts` / `meshcoreRoomCredentialStorage.ts` (`meshcoreRepeaterCredential:` and room keys in `app_settings` via IPC), `useMeshcoreRepeaterRemoteAuth.tsx`, `MeshcoreRepeaterPasswordControls.tsx`; Repeaters sidebar **Saved repeater passwords** + Forget (parallel to Rooms). -- **Waiting-message drain:** event 131 → `meshcoreWaitingMessagesDrain.ts` / `meshcoreProcessWaitingMessageItem.ts`; silent auto-drain vs manual **Sync now** (`MeshcoreWaitingMessagesHeaderIndicator.tsx` in the App header via `meshcoreWaitingMessagesStatusText.ts`; **queued backlog visible on any protocol tab**; **active sync spinner and paused/deferred** state only on the MeshCore tab); defers during TraceData/admin RPC. Silent bulk `getWaitingMessages` circuit-opens after **2** consecutive timeouts (`MESHCORE_WAITING_MESSAGES_SILENT_BULK_TIMEOUT_TRIP`) and skips bulk until reconnect/success; disconnect bumps the attempt id so a late timeout cannot trip the next connection. +- **Repeater CLI danger**: destructive commands (`meshcoreRepeaterCliDanger.ts`: `reboot|erase|clkreboot|factory|shutdown|poweroff`) require confirm modal in Repeaters panel; runtime rejects unconfirmed sends (`meshcore.errors.cliDangerNotConfirmed`). Commands longer than **512** characters (`REPEATER_CLI_MAX_COMMAND_LENGTH`) are rejected before send. Multi-hop CLI auto-pings once per session when no trace exists (`RepeatersPanel` → `onPing`); CLI aborts when ping does not produce a trace result. Safe quick pills include `clock`, `clock sync`, `clear stats`, `advert`, `advert.zerohop`, `board`, `stats-*`, `get role` (firmware CLI tokens as labels). +- **Per-node admin passwords:** `meshcoreInfraAdminSecrets.ts` facade over `meshcoreRepeaterCredentialStorage.ts` / `meshcoreRoomCredentialStorage.ts` (separate prefixes); ops modal via `useMeshcoreRepeaterRemoteAuth.tsx`; room admin-only persist allowed; ops Forget on a room clears **admin only** (guest/auto-login unchanged). Accordion lists both kinds. +- **Waiting-message drain:** event 131 → `meshcoreWaitingMessagesDrain.ts` / `meshcoreProcessWaitingMessageItem.ts`; silent auto-drain vs manual **Sync now** (`MeshcoreWaitingMessagesHeaderIndicator.tsx` in the App header via `meshcoreWaitingMessagesStatusText.ts`; **queued backlog visible on any protocol tab**; **active sync spinner and paused/deferred** state only on the MeshCore tab); defers during TraceData/admin RPC. Silent bulk `getWaitingMessages` circuit-opens after **2** consecutive timeouts (`MESHCORE_WAITING_MESSAGES_SILENT_BULK_TIMEOUT_TRIP`) and skips bulk until reconnect/success; disconnect bumps the attempt id so a late timeout cannot trip the next connection. Repeater CLI waits for drain idle **before** `runMeshcoreRepeaterRpcOnce` (waiting inside the once slot deadlocks because in-flight defers drain). CLI_DATA waiting messages dispatch as CLI responses, not chat. If drain is busy at start or SENT, CLI timeout is padded by `MESHCORE_WAITING_MESSAGES_SILENT_TIMEOUT_MS` (45s), capped at `REPEATER_CLI_MAX_TIMEOUT_MS`. While CLI awaits a reply, `beginMeshcoreCliReplyHold` blocks new traces and keeps silent drain from deferring on TraceData; 0-hop CLI also preempts pending traces and force-kicks `processWaitingMessages({ force: true })` on a short interval. +- **Repeater CLI clock:** `clock` / `clock sync` are firmware tokens. `clock sync` uses the companion/computer UTC unix timestamp; MeshCore firmware **refuses to set time backwards** (`ERR: clock cannot go backwards`). Do not auto-`clkreboot`; toast `repeatersPanel.cliClockCannotGoBackwards` and leave `clkreboot` behind the danger confirm. Optional companion `syncDeviceTime()` runs before repeater `clock sync`. - **Cross-traffic**: Room sync/auto-login defer while `meshcoreCompanionRepeaterRfBusy()`; waiting-messages drain defers during TraceData. Do not change behavior guarded by `meshcoreZeroHopRepeaterWorkingState.test.ts` without explicit user request. See [../meshcore-meshtastic-parity.md](../meshcore-meshtastic-parity.md#serialized-traceroutes-protocol-requirement). diff --git a/docs/agents/meshcore-rooms.md b/docs/agents/meshcore-rooms.md index 0ae3f3315..3520139e7 100644 --- a/docs/agents/meshcore-rooms.md +++ b/docs/agents/meshcore-rooms.md @@ -2,10 +2,12 @@ Deep subsystem reference for AI assistants. Open this when a task touches MeshCore Rooms login/post, session RPCs, saved passwords, auto-sync scheduling, or room wire text. Hard rules live in [`AGENTS.md`](../../AGENTS.md). -- **UI:** `RoomsPanel.tsx` — login overlay, post composer (`ChatComposer`), admin CLI, auto-sync toggles; sidebar badge via `meshcoreRoomsUnread.ts` (`mesh-client:meshcoreRoomsUnread`). +- **UI:** `RoomsPanel.tsx` — login overlay, post composer (`ChatComposer`), auto-sync toggles, Members ACL refresh (`get acl`); **Manage on Repeaters** jumps to Repeaters & Rooms ops (CLI / setperm / infra). Sidebar badge via `meshcoreRoomsUnread.ts` (`mesh-client:meshcoreRoomsUnread`). +- **Ops (not on Rooms tab):** Ping / Status / Neighbors / Sensor / CLI / ACL setperm live on `RepeatersPanel` (mixed Repeater + Room table). **Open room** on a room row jumps back to Rooms. Guest passwords stay in `meshcoreRoomCredentialStorage`; ops admin uses `meshcoreInfraAdminSecrets` (admin-only records allowed). +- **Room remote CLI:** needs room **admin** password via SendLogin ACL (`meshcoreCancelRoomLogin` then admin login inside `sendRepeaterCliCommand`); guest BBS login is not enough (`repeatersPanel.roomCliNeedsAdminPassword`). - **Post length (single-packet):** room posts use the shared `ChatComposer` (`variant="room"`) and are **single-packet** like MeshCore chat — over the room payload limit (`getMeshcoreRoomPayloadLimit` in `chatComposerLimits.ts`) the send is blocked with the same `meshcoreSingleNotice` callout rather than split into `[i/N]` parts, and the ~5s fast-send advisory applies. See [`chat.md`](chat.md) (Composer limits / send cadence). - **Session / RPC:** `meshcoreRoomSession.ts`, `meshcoreRoomLoginRpc.ts`, `meshcoreRoomPostRpc.ts`, `meshcoreRoomLogoutRpc.ts`, `meshcoreRoomLoginQueue.ts`, `meshcoreRoomLoginPathSync.ts`, `meshcoreRoomSentWait.ts`; credentials in `meshcoreRoomCredentialStorage.ts` / `meshcoreRoomSyncStorage.ts`. - **Saved passwords:** `meshcoreRoomSavedSecrets.ts` — sidebar/overlay **Forget** / **Stop auto-login**; `forgetMeshcoreRoomSavedSecrets` clears credential + disables auto-login and auto-sync; `disableMeshcoreRoomLoginAfterAuthFailure` disables both without clearing password or in-memory failure UI. - **Scheduler:** `meshcoreRoomSyncScheduler.ts` + `useMeshcoreRuntime.ts` — periodic re-login (Auto-sync, RF-only); single-flight ticks; background route resolve uses `skipTrace` / `MESHCORE_ROOM_SYNC_ROUTE_RESOLVE_FAST_MS`. Auth failure disables auto-sync and auto-login via `disableMeshcoreRoomLoginAfterAuthFailure`. Timeouts in `timeConstants.ts` (shorter for TCP / 0-hop). - **Connect auto-login:** `meshcoreRoomAutoLoginOnConnect.ts` + `useMeshcoreRuntime.ts` — 500ms debounce (`MESHCORE_ROOM_AUTO_LOGIN_DEBOUNCE_MS`) when Room contacts appear (`roomAutoLoginReadyKey`, including pubkey-ready). Overlapping triggers share one pass (`runMeshcoreRoomAutoLoginSingleFlight`) and dirty-re-run so a second room hydrating mid-pathSync still logs in. Disconnect bumps a generation so a dying pass must not `SendLogin` on the new conn. Skips logged-in, queued (`meshcoreIsRoomLoginQueued`), failed (`getMeshcoreRoomAutoLoginFailure`), and not-yet-hydrated contacts. -- **Wire text:** `meshcoreChannelText.ts` — channel/DM/room payloads, SignedPlain inbound strip, tapback/reply lines; `meshcoreGifWire.ts` — Open `g:GIFID`; `meshcoreOpenReaction.ts` — Open `r:HASH:INDEX`. Default companion keyless outbound; opt-in Open wire via App `meshcoreOpenWireCompatEnabled`. +- **Wire text:** `meshcoreChannelText.ts` — channel/DM/room payloads, SignedPlain inbound strip, tapback/reply lines; `meshcoreGifWire.ts` — Open `g:GIFID`; `meshcoreOpenReaction.ts` — Open `r:HASH:INDEX`. Default companion keyless outbound; opt-in Open wire via Radio `meshcoreOpenWireCompatEnabled` (UI on `RadioPanel`). diff --git a/docs/agents/renderer-hooks.md b/docs/agents/renderer-hooks.md index be81d94d6..f8bef811c 100644 --- a/docs/agents/renderer-hooks.md +++ b/docs/agents/renderer-hooks.md @@ -31,4 +31,4 @@ WAL SQLite; `user_version` in `database.ts`; migrations as `migration_N()`; `db- ## UI -Panels: `src/renderer/components/`. New tabs: `lazyTabPanels.ts` / `lazyAppPanels.ts` + capabilities. Tab visibility: `src/renderer/lib/tabSlotIds.ts` (`TAB_SLOT_IDS`) → `src/renderer/lib/appTabMappings.ts` (`TAB_CAPABILITY_REQUIREMENTS`, `computeTabMappings()` in `App.tsx`). Stores: module defaults; persist vs SQLite IPC as elsewhere. +Panels: `src/renderer/components/`. New tabs: `lazyTabPanels.ts` / `lazyAppPanels.ts` + capabilities. Tab visibility: `src/renderer/lib/tabSlotIds.ts` (`TAB_SLOT_IDS`) → `src/renderer/lib/appTabMappings.ts` (`TAB_CAPABILITY_REQUIREMENTS`, `computeTabMappings()` in `App.tsx`). Stores: module defaults; persist vs SQLite IPC as elsewhere. **MeshCore Open wire / path-hash UI** mounts from `RadioPanel`, which persists `meshcoreOpenWireCompatEnabled` and `meshcorePathHashMode` as app settings via `mergeAppSetting` (ownership stays on RadioPanel, not AppPanel). diff --git a/docs/meshcore-meshtastic-parity.md b/docs/meshcore-meshtastic-parity.md index 57881f01d..2443bdbf7 100644 --- a/docs/meshcore-meshtastic-parity.md +++ b/docs/meshcore-meshtastic-parity.md @@ -12,41 +12,41 @@ Shared UI gates use `ProtocolCapabilities` in [`src/renderer/lib/radio/BaseRadio ## Feature matrix -| Area | Meshtastic | MeshCore | Gap type | -| --------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------- | -| Transports | BLE, Serial, HTTP (`@meshtastic/core`), WiFi/TCP fast path (`TransportTcpIpc`, port 4403); launch auto-connect via `useProtocolRfAutoConnect` / `ProtocolAutoConnectCoordinator` + `protocolRfAutoConnectGate` | BLE, Web Serial, TCP bridge (5000; main `meshcore:tcp-*`, tolerates peer FIN after contact burst, `TCP_NODELAY` + keepalive); same RF auto-connect coordinator; TCP reconnect owned by `useMeshcoreRuntime` + `rfReconnectController` (conn side effects skip `handleConnectionLost` for TCP) | Different stacks | -| Tab “Modules” / “Repeaters” | `ModulePanel` (protobuf modules; Remote Hardware GPIO, IP Tunnel status) | `RepeatersPanel` (trace, status, neighbors) | Product split | -| Tab “Administration” | `AdminPanel` (reboot, shutdown, factory reset, NodeDB reset, OTA/DFU) | `AdminPanel` (reboot via companion; meshcore.js limits for shutdown/factory/OTA) | **App** (implemented; reboot wired; extended admin capability-limited) | -| MQTT broker UI | Full (with transport selection) | Same broker fields; transport protocol selected when connecting; MeshCore-only **LetsMesh** / **MeshMapper** / **Colorado Mesh** / **Waev** / **Meshat.se** / **MeshCore.CA** / **EastMesh** / **Ripple** / **Custom** presets fill known public brokers | **Post-MQTT** codec on broker path | -| MQTT wire format | `ServiceEnvelope` / `MeshPacket` ([`mqtt-manager.ts`](../src/main/mqtt-manager.ts)) | JSON **v1** chat on `{topicPrefix}/meshcore/chat` (non-LetsMesh / private brokers); **LetsMesh**: optional meshcoretomqtt-style **packet** JSON on `{topicPrefix}/meshcore/packets` ([`meshcore-mqtt-adapter.ts`](../src/main/meshcore-mqtt-adapter.ts)); chat parser in [`meshcoreMqttEnvelope.ts`](../src/shared/meshcoreMqttEnvelope.ts) | Adapter vs protobuf | -| MQTT channel crypto / uplink | AES-128/256-CTR, `channelPsks`, TLS ([`mqttTls.ts`](../src/renderer/lib/mqttTls.ts)), per-channel publish ([`meshtasticMqttPublish.ts`](../src/renderer/lib/meshtasticMqttPublish.ts)); [`mqtt-manager.ts`](../src/main/mqtt-manager.ts) | JSON v1 path unchanged | **App** (Meshtastic wire) | -| Node list hops / MQTT columns | `hops_away`, `via_mqtt` from device | Contact model; node-list `hops_away` derives from contact `outPathLen` (`meshcoreInferHopsFromOutPath`); per-message chat hop pills instead use the companion `pathLen` on RX events 7/8 (`meshcoreCompanionRxPathLenToHopCount`) — see [docs/agents/chat.md](agents/chat.md) §Hop badges | **App** (implemented) | -| RF diagnostics (LocalStats) | From protobuf | Different data model: Repeater Status `meshcore_local_stats` packet-stats feed **Elevated Noise Floor** / **Excessive Flooding** findings only (no CU/TX-based findings) | **App** (implemented, different metrics) | -| Routing diagnostics (hop-based) | `RoutingDiagnosticEngine` with hop count | `hasHopCount` is `true` (hops via `outPathLen`); same `RoutingDiagnosticEngine` hop anomalies run, plus MeshCore-only `weak_link` (per-hop trace SNR) | **App** (implemented) | -| Foreign LoRa overhear UI | Diagnostics tab tables (MeshCore / Reticulum RNS / unknown); Meshtastic decode-fail logs + dual-radio MeshCore RX | Records foreign traffic; Diagnostics foreign-LoRa tables on MeshCore tab (keyed by MeshCore self id) and Meshtastic tab | **App** (implemented; tables on Meshtastic and MeshCore tabs) | -| Neighbor UI | `neighborInfo` protobuf | Paged binary `GetNeighbours` (`MESHCORE_NEIGHBORS_PAGE_SIZE` request cap, `offset` append via `mergeMeshcoreNeighborPage`); **Load more** on RepeatersPanel and NodeDetailModal (firmware often returns fewer rows than requested) | Different primitive | -| Radio config | Full protobuf (role, presets, WiFi, etc.) | `setRadioParams`, channels, advert name/position | **Blocked** for Meshtastic-only admin | -| Channel URL sync | Radio tab import/export via [`meshtasticUrlEncoder.ts`](../src/shared/meshtasticUrlEncoder.ts) + [`meshtasticChannelApply.ts`](../src/shared/meshtasticChannelApply.ts) (`https://meshtastic.org/e/#…`, `meshtastic://`) | Not available | **App** (Meshtastic-only) | -| Position | Full GPS protobuf + request position | Radio **Position / GPS**: advertised readout + lat/lon + `setAdvertLatLong` via Send Position; no GPS mode / broadcast intervals / altitude / request-position | **App** (implemented; advert lat/lon only — protocol) | -| Waypoints | Supported | Not in protocol surface | **Blocked** | -| Favorites | `nodes` table | `meshcore_contacts.favorited` + `db:updateMeshcoreContactFavorited` | **App** (implemented) | -| Environment telemetry charts | Device telemetry module | Cayenne LPP via `getTelemetry` → `environmentTelemetry` | **App** (implemented) | -| Chat transport badges / history | `received_via` (`rf` / `mqtt` / `both`) plus `via_store_forward` for S&F replays; router heartbeat triggers `CLIENT_HISTORY` via [`meshtasticBacklogUtils.ts`](../src/renderer/lib/meshtasticBacklogUtils.ts) | `meshcore_messages.received_via` (`rf` / `mqtt` / `both`) | **App** (implemented) | -| Chat search | `searchMessages` | `searchMeshcoreMessages`; UI search modal supports `user:` / `channel:` filters for cross-channel lookup | Parallel DB tables | -| Chat `@[Display Name]` tokens | Same on-wire pattern for replies / reactions / path-style lines | Same | **App** (implemented); chat body renders tokens as inline labels (see below) | -| Emoji reactions / tapbacks | `reactions.ts` decodes protobuf tapbacks (`emoji` flag + UTF-8 payload, legacy index 1–12); `ChatPanel` quick picker + `sendReaction` | Default outbound keyless `@[Name] emoji` / `@[Name] body`; optional **MeshCore Open compatibility** (App toggle) enables keyed replies, `r:HASH:INDEX`, and `g:GIFID` send — [`buildMeshcoreOutboundTapbackWire`](../src/renderer/lib/meshcoreChannelText.ts), [`buildMeshcoreOutboundSendText`](../src/renderer/lib/meshcoreChannelText.ts), [`meshcoreOpenReaction.ts`](../src/renderer/lib/meshcoreOpenReaction.ts), [`meshcoreGifWire.ts`](../src/renderer/lib/meshcoreGifWire.ts); inbound keyed/keyless + Open wire always parsed; emoji-only replies promoted via [`meshcorePromoteEmojiOnlyReplyToTapback`](../src/renderer/lib/meshcoreChannelText.ts); echo dedup in [`meshcoreStoreDedup.ts`](../src/renderer/lib/meshcoreStoreDedup.ts) | **App** (shared UI, protocol-specific wire) | -| MeshCore Open wire (experimental) | N/A | App toggle `meshcoreOpenWireCompatEnabled` ([`defaultAppSettings.ts`](../src/renderer/lib/defaultAppSettings.ts)): keyed replies, `r:` reactions, `g:` GIF send; default off (companion keyless wire) | **App** (MeshCore-only) | -| Chat composer | `ChatComposer.tsx` in `ChatPanel`; long text auto-splits into `[i/N]` chunks (up to 9, [`getMaxChunks`](../src/renderer/lib/chatComposerLimits.ts)) | Same `ChatComposer` in `ChatPanel` and `RoomsPanel`, but **single-packet**: `getMaxChunks('meshcore') === 1`, so over-limit text is **blocked** with an explanatory notice (no multi-part split — busy repeaters drop parts, [meshcore-dev/MeshCore#1502](https://github.com/meshcore-dev/MeshCore/issues/1502) / [#2820](https://github.com/meshcore-dev/MeshCore/issues/2820)). Non-blocking fast-send advisory ([`meshcoreSendRateNotice.ts`](../src/renderer/lib/meshcoreSendRateNotice.ts)) when sending within ~5s. Inbound multi-part still merged. | **App** (shared UI; MeshCore single-packet parity gap) | -| Repeater CLI | Not applicable | Per-repeater expandable CLI in `RepeatersPanel`; prefix-token correlation (`RepeaterCommandService`); **auto Ping** before the first multi-hop CLI command when no trace exists this session; **destructive-command confirm** (`reboot` / `erase` / factory-reset patterns via `meshcoreRepeaterCliDanger.ts`); ping-first guidance for multi-hop CLI; quick pills include `clock`, `clock sync`, `clear stats`, `advert`, `board`; **Flood Advert** and **Sync Clock** toolbar actions live on Radio panel (Device Actions) — distinct from the CLI **`clock sync`** pill; auto flood advert scheduling available in App Settings (disabled / 12h / 24h) | **App** (MeshCore-only) | -| Regional flood scope | Meshtastic region via LoRa config | Radio tab **flood scope** (`setFloodScope` / `clearFloodScope`); user-managed saved hashtags (`meshcoreFloodScopePresets`) + Chat split-Send override **remembered per channel view**; `app_settings` reapply on connect. Community region/scope guide: [RegionMesh MeshCore region configuration](https://www.regionmesh.com/meshcore-region-configuration/) | **App** (MeshCore v8+ transport keys) | -| Meshtastic MQTT downlink | Firmware MQTT module + `MqttClientProxyMessage` bridge when `proxy_to_client_enabled` (BLE/serial); per-channel downlink on Radio tab | N/A (JSON MQTT ingest only) | **App** (Meshtastic) | -| Security / PKI admin | `SecurityPanel` when `hasSecurityPanel`; DM backup/restore **per `nodeNum`** (full public + private pair) — see [key-backup-and-crypto.md](key-backup-and-crypto.md) | `SecurityPanel` (partial): backup/restore **per `nodeId`**, sign, export/import; no Meshtastic PKI admin. Active MQTT cache: `mesh-client:meshcoreIdentity` — see [key-backup-and-crypto.md](key-backup-and-crypto.md) | **Partial** — shared tab; protocol-specific backup + MC MQTT cache | -| PKC remote admin | `ConfigureNodeSelector`, [`meshtasticRemoteAdmin.ts`](../src/renderer/lib/meshtasticRemoteAdmin.ts), [`meshtasticRemoteAdminKeyStorage.ts`](../src/renderer/lib/meshtasticRemoteAdminKeyStorage.ts); local radio (2.5+) | Not available | **App** (Meshtastic-only) | -| Contact groups | Built-in groups (GPS, RF+MQTT) via `meshtasticContactGroupUtils`; user-managed via `ContactGroupsModal` | SQLite-backed groups + Nodes toolbar (`useContactGroups`, `ContactGroupsModal`); built-in Room filter | **App** (implemented); protocol-neutral with Meshtastic built-ins | -| Log analyzer | `LogPanel` → **Analyze** (`logAnalyzer.ts`, protocol-aware) | Same shared UI | **App** (implemented) | -| Room servers (BBS) | Not applicable | **Rooms** tab: login/post/admin CLI; optional **Remember password** (`app_settings`); **Auto-sync** periodic re-login while radio connected ([`meshcoreRoomSyncScheduler.ts`](../src/renderer/lib/meshcoreRoomSyncScheduler.ts), [`useMeshcoreRuntime.ts`](../src/renderer/runtime/useMeshcoreRuntime.ts)); RF-only (not MQTT) | **App** (MeshCore-only) | -| Repeater admin passwords | Not applicable | Per-repeater **Remember** (`meshcoreRepeaterCredential:` in `app_settings`); shared factory [`meshcorePerNodeCredentialStorage.ts`](../src/renderer/lib/meshcorePerNodeCredentialStorage.ts) with [`meshcoreRepeaterCredentialStorage.ts`](../src/renderer/lib/meshcoreRepeaterCredentialStorage.ts) / [`meshcoreRoomCredentialStorage.ts`](../src/renderer/lib/meshcoreRoomCredentialStorage.ts); [`useMeshcoreRepeaterRemoteAuth.tsx`](../src/renderer/hooks/useMeshcoreRepeaterRemoteAuth.tsx), [`MeshcoreRepeaterPasswordControls.tsx`](../src/renderer/components/MeshcoreRepeaterPasswordControls.tsx); Repeaters sidebar **Saved repeater passwords** + Forget | **App** (MeshCore-only) | -| MsgWaiting background drain | Not applicable | Event 131 silent drain ([`meshcoreWaitingMessagesDrain.ts`](../src/renderer/lib/meshcoreWaitingMessagesDrain.ts)): bulk `getWaitingMessages` first (header **X / Y**), `syncNextMessage` fallback on timeout without disconnect (**Fetched N…**); **header status indicator** (queued backlog and active sync on any protocol tab; **paused/deferred** only on MeshCore tab); manual **Sync now** with determinate progress | **App** (MeshCore-only) | +| Area | Meshtastic | MeshCore | Gap type | +| --------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------- | +| Transports | BLE, Serial, HTTP (`@meshtastic/core`), WiFi/TCP fast path (`TransportTcpIpc`, port 4403); launch auto-connect via `useProtocolRfAutoConnect` / `ProtocolAutoConnectCoordinator` + `protocolRfAutoConnectGate` | BLE, Web Serial, TCP bridge (5000; main `meshcore:tcp-*`, tolerates peer FIN after contact burst, `TCP_NODELAY` + keepalive); same RF auto-connect coordinator; TCP reconnect owned by `useMeshcoreRuntime` + `rfReconnectController` (conn side effects skip `handleConnectionLost` for TCP) | Different stacks | +| Tab “Modules” / “Repeaters” | `ModulePanel` (protobuf modules; Remote Hardware GPIO, IP Tunnel status) | `RepeatersPanel` (trace, status, neighbors) | Product split | +| Tab “Administration” | `AdminPanel` (reboot, shutdown, factory reset, NodeDB reset, OTA/DFU) | `AdminPanel` (reboot via companion; meshcore.js limits for shutdown/factory/OTA) | **App** (implemented; reboot wired; extended admin capability-limited) | +| MQTT broker UI | Full (with transport selection) | Same broker fields; transport protocol selected when connecting; MeshCore-only **LetsMesh** / **MeshMapper** / **Colorado Mesh** / **Waev** / **Meshat.se** / **MeshCore.CA** / **EastMesh** / **Ripple** / **Custom** presets fill known public brokers | **Post-MQTT** codec on broker path | +| MQTT wire format | `ServiceEnvelope` / `MeshPacket` ([`mqtt-manager.ts`](../src/main/mqtt-manager.ts)) | JSON **v1** chat on `{topicPrefix}/meshcore/chat` (non-LetsMesh / private brokers); **LetsMesh**: optional meshcoretomqtt-style **packet** JSON on `{topicPrefix}/meshcore/packets` ([`meshcore-mqtt-adapter.ts`](../src/main/meshcore-mqtt-adapter.ts)); chat parser in [`meshcoreMqttEnvelope.ts`](../src/shared/meshcoreMqttEnvelope.ts) | Adapter vs protobuf | +| MQTT channel crypto / uplink | AES-128/256-CTR, `channelPsks`, TLS ([`mqttTls.ts`](../src/renderer/lib/mqttTls.ts)), per-channel publish ([`meshtasticMqttPublish.ts`](../src/renderer/lib/meshtasticMqttPublish.ts)); [`mqtt-manager.ts`](../src/main/mqtt-manager.ts) | JSON v1 path unchanged | **App** (Meshtastic wire) | +| Node list hops / MQTT columns | `hops_away`, `via_mqtt` from device | Contact model; node-list `hops_away` derives from contact `outPathLen` (`meshcoreInferHopsFromOutPath`); per-message chat hop pills instead use the companion `pathLen` on RX events 7/8 (`meshcoreCompanionRxPathLenToHopCount`) — see [docs/agents/chat.md](agents/chat.md) §Hop badges | **App** (implemented) | +| RF diagnostics (LocalStats) | From protobuf | Different data model: Repeater Status `meshcore_local_stats` packet-stats feed **Elevated Noise Floor** / **Excessive Flooding** findings only (no CU/TX-based findings) | **App** (implemented, different metrics) | +| Routing diagnostics (hop-based) | `RoutingDiagnosticEngine` with hop count | `hasHopCount` is `true` (hops via `outPathLen`); same `RoutingDiagnosticEngine` hop anomalies run, plus MeshCore-only `weak_link` (per-hop trace SNR) | **App** (implemented) | +| Foreign LoRa overhear UI | Diagnostics tab tables (MeshCore / Reticulum RNS / unknown); Meshtastic decode-fail logs + dual-radio MeshCore RX | Records foreign traffic; Diagnostics foreign-LoRa tables on MeshCore tab (keyed by MeshCore self id) and Meshtastic tab | **App** (implemented; tables on Meshtastic and MeshCore tabs) | +| Neighbor UI | `neighborInfo` protobuf | Paged binary `GetNeighbours` (`MESHCORE_NEIGHBORS_PAGE_SIZE` request cap, `offset` append via `mergeMeshcoreNeighborPage`); **Load more** on RepeatersPanel and NodeDetailModal (firmware often returns fewer rows than requested) | Different primitive | +| Radio config | Full protobuf (role, presets, WiFi, etc.) | `setRadioParams`, channels, advert name/position | **Blocked** for Meshtastic-only admin | +| Channel URL sync | Radio tab import/export via [`meshtasticUrlEncoder.ts`](../src/shared/meshtasticUrlEncoder.ts) + [`meshtasticChannelApply.ts`](../src/shared/meshtasticChannelApply.ts) (`https://meshtastic.org/e/#…`, `meshtastic://`) | Not available | **App** (Meshtastic-only) | +| Position | Full GPS protobuf + request position | Radio **Position / GPS**: advertised readout + lat/lon + `setAdvertLatLong` via Send Position; no GPS mode / broadcast intervals / altitude / request-position | **App** (implemented; advert lat/lon only — protocol) | +| Waypoints | Supported | Not in protocol surface | **Blocked** | +| Favorites | `nodes` table | `meshcore_contacts.favorited` + `db:updateMeshcoreContactFavorited` | **App** (implemented) | +| Environment telemetry charts | Device telemetry module | Cayenne LPP via `getTelemetry` → `environmentTelemetry` | **App** (implemented) | +| Chat transport badges / history | `received_via` (`rf` / `mqtt` / `both`) plus `via_store_forward` for S&F replays; router heartbeat triggers `CLIENT_HISTORY` via [`meshtasticBacklogUtils.ts`](../src/renderer/lib/meshtasticBacklogUtils.ts) | `meshcore_messages.received_via` (`rf` / `mqtt` / `both`) | **App** (implemented) | +| Chat search | `searchMessages` | `searchMeshcoreMessages`; UI search modal supports `user:` / `channel:` filters for cross-channel lookup | Parallel DB tables | +| Chat `@[Display Name]` tokens | Same on-wire pattern for replies / reactions / path-style lines | Same | **App** (implemented); chat body renders tokens as inline labels (see below) | +| Emoji reactions / tapbacks | `reactions.ts` decodes protobuf tapbacks (`emoji` flag + UTF-8 payload, legacy index 1–12); `ChatPanel` quick picker + `sendReaction` | Default outbound keyless `@[Name] emoji` / `@[Name] body`; optional **MeshCore Open compatibility** (Radio toggle) enables keyed replies, `r:HASH:INDEX`, and `g:GIFID` send — [`buildMeshcoreOutboundTapbackWire`](../src/renderer/lib/meshcoreChannelText.ts), [`buildMeshcoreOutboundSendText`](../src/renderer/lib/meshcoreChannelText.ts), [`meshcoreOpenReaction.ts`](../src/renderer/lib/meshcoreOpenReaction.ts), [`meshcoreGifWire.ts`](../src/renderer/lib/meshcoreGifWire.ts); inbound keyed/keyless + Open wire always parsed; emoji-only replies promoted via [`meshcorePromoteEmojiOnlyReplyToTapback`](../src/renderer/lib/meshcoreChannelText.ts); echo dedup in [`meshcoreStoreDedup.ts`](../src/renderer/lib/meshcoreStoreDedup.ts) | **App** (shared UI, protocol-specific wire) | +| MeshCore Open wire (experimental) | N/A | Radio toggle `meshcoreOpenWireCompatEnabled` ([`defaultAppSettings.ts`](../src/renderer/lib/defaultAppSettings.ts)): keyed replies, `r:` reactions, `g:` GIF send; default off (companion keyless wire) | **App** (MeshCore-only) | +| Chat composer | `ChatComposer.tsx` in `ChatPanel`; long text auto-splits into `[i/N]` chunks (up to 9, [`getMaxChunks`](../src/renderer/lib/chatComposerLimits.ts)) | Same `ChatComposer` in `ChatPanel` and `RoomsPanel`, but **single-packet**: `getMaxChunks('meshcore') === 1`, so over-limit text is **blocked** with an explanatory notice (no multi-part split — busy repeaters drop parts, [meshcore-dev/MeshCore#1502](https://github.com/meshcore-dev/MeshCore/issues/1502) / [#2820](https://github.com/meshcore-dev/MeshCore/issues/2820)). Non-blocking fast-send advisory ([`meshcoreSendRateNotice.ts`](../src/renderer/lib/meshcoreSendRateNotice.ts)) when sending within ~5s. Inbound multi-part still merged. | **App** (shared UI; MeshCore single-packet parity gap) | +| Repeater CLI | Not applicable | Expandable CLI in `RepeatersPanel` for repeater **and** room rows; prefix-token correlation (`RepeaterCommandService`); **auto Ping** before the first multi-hop CLI command when no trace exists this session; **destructive-command confirm** (`reboot` / `erase` / factory-reset patterns via `meshcoreRepeaterCliDanger.ts`); ping-first guidance for multi-hop CLI; quick pills include `clock`, `clock sync`, `clear stats`, `advert`, `board` (+ room `get acl` / `allow.read.only`); **Flood Advert** and **Sync Clock** toolbar actions live on Radio panel (Device Actions) — distinct from the CLI **`clock sync`** pill; auto flood advert scheduling available in App Settings (disabled / 12h / 24h) | **App** (MeshCore-only) | +| Regional flood scope | Meshtastic region via LoRa config | Radio tab **flood scope** (`setFloodScope` / `clearFloodScope`); user-managed saved hashtags (`meshcoreFloodScopePresets`) + Chat split-Send override **remembered per channel view**; `app_settings` reapply on connect. Community region/scope guide: [RegionMesh MeshCore region configuration](https://www.regionmesh.com/meshcore-region-configuration/) | **App** (MeshCore v8+ transport keys) | +| Meshtastic MQTT downlink | Firmware MQTT module + `MqttClientProxyMessage` bridge when `proxy_to_client_enabled` (BLE/serial); per-channel downlink on Radio tab | N/A (JSON MQTT ingest only) | **App** (Meshtastic) | +| Security / PKI admin | `SecurityPanel` when `hasSecurityPanel`; DM backup/restore **per `nodeNum`** (full public + private pair) — see [key-backup-and-crypto.md](key-backup-and-crypto.md) | `SecurityPanel` (partial): backup/restore **per `nodeId`**, sign, export/import; no Meshtastic PKI admin. Active MQTT cache: `mesh-client:meshcoreIdentity` — see [key-backup-and-crypto.md](key-backup-and-crypto.md) | **Partial** — shared tab; protocol-specific backup + MC MQTT cache | +| PKC remote admin | `ConfigureNodeSelector`, [`meshtasticRemoteAdmin.ts`](../src/renderer/lib/meshtasticRemoteAdmin.ts), [`meshtasticRemoteAdminKeyStorage.ts`](../src/renderer/lib/meshtasticRemoteAdminKeyStorage.ts); local radio (2.5+) | Not available | **App** (Meshtastic-only) | +| Contact groups | Built-in groups (GPS, RF+MQTT) via `meshtasticContactGroupUtils`; user-managed via `ContactGroupsModal` | SQLite-backed groups + Nodes toolbar (`useContactGroups`, `ContactGroupsModal`); built-in Room filter | **App** (implemented); protocol-neutral with Meshtastic built-ins | +| Log analyzer | `LogPanel` → **Analyze** (`logAnalyzer.ts`, protocol-aware) | Same shared UI | **App** (implemented) | +| Room servers (BBS) | Not applicable | **Rooms** tab: login/post; optional **Remember password** (`app_settings`); **Auto-sync** periodic re-login while radio connected ([`meshcoreRoomSyncScheduler.ts`](../src/renderer/lib/meshcoreRoomSyncScheduler.ts), [`useMeshcoreRuntime.ts`](../src/renderer/runtime/useMeshcoreRuntime.ts)); room admin CLI / ACL setperm on **Repeaters** (room rows); RF-only (not MQTT) | **App** (MeshCore-only) | +| Repeater admin passwords | Not applicable | Per-repeater **Remember** (`meshcoreRepeaterCredential:` in `app_settings`); shared factory [`meshcorePerNodeCredentialStorage.ts`](../src/renderer/lib/meshcorePerNodeCredentialStorage.ts) with [`meshcoreRepeaterCredentialStorage.ts`](../src/renderer/lib/meshcoreRepeaterCredentialStorage.ts) / [`meshcoreRoomCredentialStorage.ts`](../src/renderer/lib/meshcoreRoomCredentialStorage.ts); [`useMeshcoreRepeaterRemoteAuth.tsx`](../src/renderer/hooks/useMeshcoreRepeaterRemoteAuth.tsx), [`MeshcoreRepeaterPasswordControls.tsx`](../src/renderer/components/MeshcoreRepeaterPasswordControls.tsx); Repeaters sidebar **Saved admin passwords** + Forget | **App** (MeshCore-only) | +| MsgWaiting background drain | Not applicable | Event 131 silent drain ([`meshcoreWaitingMessagesDrain.ts`](../src/renderer/lib/meshcoreWaitingMessagesDrain.ts)): bulk `getWaitingMessages` first (header **X / Y**), `syncNextMessage` fallback on timeout without disconnect (**Fetched N…**); **header status indicator** (queued backlog and active sync on any protocol tab; **paused/deferred** only on MeshCore tab); manual **Sync now** with determinate progress | **App** (MeshCore-only) | ## MeshCore: Room servers @@ -120,7 +120,7 @@ Meshtastic and MeshCore use the literal form `@[Display Name]` in channel payloa ## MeshCore Open: GIF wire (`g:GIFID`) -MeshCore Open sends GIFs as compact wire text `g:{giphyId}` (Giphy CDN). mesh-client renders these inline in chat via [`meshcoreGifWire.ts`](../src/renderer/lib/meshcoreGifWire.ts) and [`ChatPayloadText.tsx`](../src/renderer/components/ChatPayloadText.tsx). Full Giphy media/page URLs are also recognized. **Outbound GIF send** (paste URL/ID or **GIF** button in Chat composer) is available when **MeshCore Open compatibility** is enabled in **App → MeshCore Open wire (experimental)**. +MeshCore Open sends GIFs as compact wire text `g:{giphyId}` (Giphy CDN). mesh-client renders these inline in chat via [`meshcoreGifWire.ts`](../src/renderer/lib/meshcoreGifWire.ts) and [`ChatPayloadText.tsx`](../src/renderer/components/ChatPayloadText.tsx). Full Giphy media/page URLs are also recognized. **Outbound GIF send** (paste URL/ID or **GIF** button in Chat composer) is available when **MeshCore Open compatibility** is enabled in **Radio → MeshCore Open wire (experimental)**. ## MeshCore: emoji reactions (tapbacks) @@ -137,9 +137,9 @@ Outbound tapbacks use [`formatMeshcoreWireTapbackPrefix`](../src/renderer/lib/me **Local model:** Stored rows use clean `payload` plus `replyId` / quote preview when applicable — never the bracket wire string. Inbound replies with a single-emoji body are classified as tapbacks via [`meshcorePromoteEmojiOnlyReplyToTapback`](../src/renderer/lib/meshcoreChannelText.ts) (live ingest, repair, and display hydration). -**MeshCore Open (`r:HASH:INDEX`):** Parsed inbound for display. Outbound `r:` reactions are sent when **MeshCore Open compatibility** is enabled in App settings (fallback to keyless `@[Name] emoji` when the picker emoji is not in the Open index table). See [`meshcoreOpenReaction.ts`](../src/renderer/lib/meshcoreOpenReaction.ts). +**MeshCore Open (`r:HASH:INDEX`):** Parsed inbound for display. Outbound `r:` reactions are sent when **MeshCore Open compatibility** is enabled in Radio settings (fallback to keyless `@[Name] emoji` when the picker emoji is not in the Open index table). See [`meshcoreOpenReaction.ts`](../src/renderer/lib/meshcoreOpenReaction.ts). -**MeshCore Open compatibility (App toggle, default off):** Enables keyed outbound text replies (`@[Name#replyKey] body`), `r:` reactions, and `g:` GIF send for meshes with MeshCore Open clients. Default wire remains official companion keyless `@[Name] …` for replies and tapbacks. +**MeshCore Open compatibility (Radio toggle, default off):** Enables keyed outbound text replies (`@[Name#replyKey] body`), `r:` reactions, and `g:` GIF send for meshes with MeshCore Open clients. Default wire remains official companion keyless `@[Name] …` for replies and tapbacks. **Limitations:** diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index b50d5a936..3e3cd1af1 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -843,7 +843,7 @@ The client deduplicates overlapping RF and MQTT hears within **5 minutes** (cros **Reactions on other clients:** By default mesh-client sends tapbacks and text replies as keyless `@[Display Name] …` (official companion wire). Inbound keyed `@[Name#key]` and emoji-only replies render locally as tapback badges via [`meshcorePromoteEmojiOnlyReplyToTapback`](../src/renderer/lib/meshcoreChannelText.ts). Inbound MeshCore Open wire (`r:HASH:INDEX`, `g:GIFID`) is always parsed for display. -**MeshCore Open compatibility (optional):** In **App → MeshCore Open wire (experimental)**, enable **MeshCore Open compatibility** to send keyed text replies (`@[Name#key] body`), compact `r:` reactions (fallback to keyless tapback when the emoji is not in the Open index), and `g:` Giphy GIFs (paste URL/ID or use the **GIF** button in Chat). Default off — use only when other nodes on your mesh run MeshCore Open-aware clients. Details: [meshcore-meshtastic-parity.md — MeshCore emoji reactions](meshcore-meshtastic-parity.md#meshcore-emoji-reactions-tapbacks) and [GIF wire](meshcore-meshtastic-parity.md#meshcore-open-gif-wire-ggifid). +**MeshCore Open compatibility (optional):** In **Radio → MeshCore Open wire (experimental)**, enable **MeshCore Open compatibility** to send keyed text replies (`@[Name#key] body`), compact `r:` reactions (fallback to keyless tapback when the emoji is not in the Open index), and `g:` Giphy GIFs (paste URL/ID or use the **GIF** button in Chat). Default off — use only when other nodes on your mesh run MeshCore Open-aware clients. Details: [meshcore-meshtastic-parity.md — MeshCore emoji reactions](meshcore-meshtastic-parity.md#meshcore-emoji-reactions-tapbacks) and [GIF wire](meshcore-meshtastic-parity.md#meshcore-open-gif-wire-ggifid). ### MeshCore: "Get Telemetry" returns timeout @@ -894,7 +894,7 @@ The client deduplicates overlapping RF and MQTT hears within **5 minutes** (cros - When the room server **guest password is empty**, use **Continue read-only** on the Rooms login overlay. That sends **zero password bytes** (same as the official Android app). **Login** with an empty guest field is disabled; it would send the default **`hello`** password instead. - When the server **does** configure a guest password, enter that value in the guest field and click **Login** (some communities use **`hello`**). -- **Room admin CLI** (Rooms → admin overlay): many stock room servers use **`hello`** as the default admin password when none was configured. The guest field placeholder shows `hello`; admin login uses the same MeshCore default when you type it explicitly — do not confuse that with **Continue read-only**, which sends zero bytes for blank guest servers. +- **Room admin CLI** (**Repeaters** tab → room row CLI; needs the room **admin** password via SendLogin ACL, not guest BBS login): many stock room servers use **`hello`** as the default admin password when none was configured. Save the admin password under Repeaters → password for that room. Do not confuse that with **Continue read-only** on the Rooms login overlay, which sends zero bytes for blank guest servers. - Logs showing push **`0x86`** (frame 134) mean **LoginFail** (wrong password or ACL denied). **Room login** rejects immediately on a prefix-matched LoginFail. **Repeater admin login** keeps waiting for a possible LoginSuccess (meshcore.js behavior on congested links); timeout after LoginFail alone is reported as timeout, not wrong password. - **Admin password** working while guest/read-only fails usually means the guest password on the server does not match what the client sent, or ACL denies read-only login. - If the room **changed its password** and mesh-client keeps trying to log in, open the **Rooms** tab: expand **Saved passwords** in the sidebar (or use the login overlay for the selected room). Use **Stop auto-login** to stop connect-time retries while keeping the old password stored, or **Forget saved password** to clear the stored guest/admin password and turn off auto-login and auto-sync. After a wrong-password failure, auto-login is turned off automatically until you log in again with **Remember password** or re-enable it. diff --git a/scripts/check-i18n-quality.mjs b/scripts/check-i18n-quality.mjs index 7c6627b1a..997d529a2 100644 --- a/scripts/check-i18n-quality.mjs +++ b/scripts/check-i18n-quality.mjs @@ -64,6 +64,11 @@ export const MESHCORE_ROOM_UI_KEYS = new Set([ 'meshcoreContactSettings.typeRoomServers', 'nodesPanel.meshcoreTypeRoom', 'appPanel.roomMessages', + 'repeatersPanel.title', + 'repeatersPanel.openRoom', + 'repeatersPanel.filterRooms', + 'repeatersPanel.savedPasswordOrphanRoomLabel', + 'repeatersPanel.roomCliNeedsAdminPassword', ]); /** modulePanel MQTT proxy toggle + error (must not use legal/delegation false friends). */ diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index f556d9802..342e3ec8d 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -647,6 +647,7 @@ function AppContent() { }); const [pendingDmTarget, setPendingDmTarget] = useState(null); const [pendingRoomTarget, setPendingRoomTarget] = useState(null); + const [pendingRepeaterFocusNodeId, setPendingRepeaterFocusNodeId] = useState(null); const [lastReadRevision, setLastReadRevision] = useState({ meshtastic: 0, meshcore: 0, @@ -2636,10 +2637,28 @@ function AppContent() { [tabsByProtocol.meshcore], ); + const handleOpenRepeaterOps = useCallback( + (nodeNum: number) => { + setPendingRepeaterFocusNodeId(nodeNum); + const filteredIndex = findFilteredTabIndexForPanel( + tabsByProtocol.meshcore, + MODULES_PANEL_INDEX, + ); + if (filteredIndex >= 0) { + setActiveTab(filteredIndex); + } + }, + [tabsByProtocol.meshcore], + ); + const handleRoomTargetConsumed = useCallback(() => { setPendingRoomTarget(null); }, []); + const handleRepeaterFocusConsumed = useCallback(() => { + setPendingRepeaterFocusNodeId(null); + }, []); + const handleLocationFilterChange = useCallback((f: LocationFilter) => { setLocationFilter(f); }, []); @@ -3763,6 +3782,16 @@ function AppContent() { ? meshcorePanelActions.syncClock : undefined } + deviceReportedPathHashMode={ + capabilities.hasCompanionContactManagementConfig + ? (meshcoreRuntime.state.pathHashMode ?? null) + : null + } + onApplyMeshcorePathHashMode={ + capabilities.hasCompanionContactManagementConfig + ? meshcorePanelActions.applyMeshcorePathHashMode + : undefined + } onRefreshContacts={ capabilities.hasContactImportExport ? meshcorePanelActions.refreshContacts @@ -3819,6 +3848,9 @@ function AppContent() { meshcoreRepeaterRpcPending={ meshcoreRuntime.meshcoreRepeaterRpcPending } + onOpenRoom={handleOpenRoom} + pendingFocusNodeId={pendingRepeaterFocusNodeId} + onPendingFocusConsumed={handleRepeaterFocusConsumed} /> @@ -3968,9 +4000,7 @@ function AppContent() { onLeaveRoom={meshcorePanelActions.leaveRoom} onSendRoomPost={meshcorePanelActions.sendRoomPost} onSendRoomAdminCli={meshcorePanelActions.sendRoomAdminCliCommand} - meshcoreCliHistories={meshcoreRuntime.meshcoreCliHistories} - meshcoreCliErrors={meshcoreRuntime.meshcoreCliErrors} - onClearCliHistory={meshcorePanelActions.clearCliHistory} + onOpenRepeaterOps={handleOpenRepeaterOps} onMessageNode={handleMessageNode} onToggleFavorite={meshcorePanelActions.setNodeFavorited} scrollToTopRef={scrollToTopRoomsRef} @@ -4145,21 +4175,6 @@ function AppContent() { onAlwaysShowMessageActionsChange={ handleAlwaysShowMessageActionsChange } - deviceReportedPathHashMode={ - capabilities.modulesTabUsesRepeatersLabel - ? (meshcoreRuntime.state.pathHashMode ?? null) - : null - } - isMeshcoreRadioConnected={ - capabilities.modulesTabUsesRepeatersLabel && - (meshcoreRuntime.state.status === 'connected' || - meshcoreRuntime.state.status === 'configured') - } - onApplyMeshcorePathHashMode={ - capabilities.modulesTabUsesRepeatersLabel - ? meshcorePanelActions.applyMeshcorePathHashMode - : undefined - } reticulumIdentityId={reticulumIdentityId} reticulumSidecarReady={ reticulumRuntime.state.status !== 'disconnected' @@ -4537,9 +4552,6 @@ function AppContent() { ? handleOpenRoom : undefined } - onLoginRoom={ - detailModalProtocol === 'meshcore' ? meshcorePanelActions.loginRoom : undefined - } onToggleFavorite={detailModalPanelActions.setNodeFavorited} remoteAdminKey={ detailModalProtocol === 'meshtastic' && selectedNode != null diff --git a/src/renderer/components/AppPanel.test.tsx b/src/renderer/components/AppPanel.test.tsx index a89f6709e..8470a6166 100644 --- a/src/renderer/components/AppPanel.test.tsx +++ b/src/renderer/components/AppPanel.test.tsx @@ -274,7 +274,7 @@ describe('AppPanel: RRC unread all room messages toggle', () => { }); }); -describe('AppPanel: MeshCore path hash mode persist', () => { +describe('AppPanel: MeshCore Radio-owned settings are not on App', () => { const defaultProps = { nodeCount: 0, messageCount: 0, @@ -287,13 +287,13 @@ describe('AppPanel: MeshCore path hash mode persist', () => { localStorage.removeItem('mesh-client:appSettings'); }); - it('does not stamp default meshcorePathHashMode into app settings on mount', async () => { + it('does not stamp meshcorePathHashMode or Open-wire into app settings on mount', async () => { render( , ); - await screen.findByLabelText(/Default path hash size/i); + await screen.findByText('App Settings'); await waitFor( () => { const raw = localStorage.getItem('mesh-client:appSettings'); @@ -303,103 +303,22 @@ describe('AppPanel: MeshCore path hash mode persist', () => { ); const raw = localStorage.getItem('mesh-client:appSettings'); expect(raw).not.toContain('meshcorePathHashMode'); + expect(raw).not.toContain('meshcoreOpenWireCompatEnabled'); }); - it('persists meshcorePathHashMode when the user changes the dropdown', async () => { - render( + it('does not show Open-wire or path-hash controls on App', async () => { + const { container } = render( , ); - const select = await screen.findByLabelText(/Default path hash size/i); - fireEvent.change(select, { target: { value: '1' } }); - await waitFor(() => { - const raw = localStorage.getItem('mesh-client:appSettings'); - expect(raw).toContain('"meshcorePathHashMode":1'); - }); - }); - - it('syncs dropdown from device-reported mode when user has not changed it', async () => { - const { rerender } = render( - - - , - ); - const select = await screen.findByLabelText(/Default path hash size/i); - expect(select).toHaveValue('0'); - - rerender( - - - , - ); - await waitFor(() => { - expect(screen.getByLabelText(/Default path hash size/i)).toHaveValue('1'); - }); - }); -}); - -describe('AppPanel: MeshCore Open wire toggle', () => { - const defaultProps = { - nodeCount: 0, - messageCount: 0, - channels: [] as { index: number; name: string }[], - myNodeNum: null as number | null, - onLocationFilterChange: vi.fn(), - }; - - beforeEach(() => { - localStorage.removeItem('mesh-client:appSettings'); - }); - - it('shows Open wire toggle only on MeshCore protocol tab', async () => { - const { unmount } = render( - - - , - ); + await screen.findByText('App Settings'); expect( screen.queryByRole('checkbox', { name: /Enable MeshCore Open compatibility/i }), ).toBeNull(); - unmount(); - - render( - - - , - ); - const checkbox = await screen.findByRole('checkbox', { - name: /Enable MeshCore Open compatibility/i, - }); - expect(checkbox).not.toBeChecked(); - }); - - it('persists meshcoreOpenWireCompatEnabled to app settings', async () => { - render( - - - , - ); - const checkbox = await screen.findByRole('checkbox', { - name: /Enable MeshCore Open compatibility/i, - }); - act(() => { - fireEvent.click(checkbox); - }); - await waitFor(() => { - const raw = localStorage.getItem('mesh-client:appSettings'); - expect(raw).toContain('"meshcoreOpenWireCompatEnabled":true'); - }); + expect(screen.queryByLabelText(/Default path hash size/i)).toBeNull(); + hydrateAxeThemeColors(container); + expect(await axe(container)).toHaveNoViolations(); }); }); diff --git a/src/renderer/components/AppPanel.tsx b/src/renderer/components/AppPanel.tsx index c0b125abf..d0ce22c3e 100644 --- a/src/renderer/components/AppPanel.tsx +++ b/src/renderer/components/AppPanel.tsx @@ -8,7 +8,6 @@ import { exportSupportBundleToDisk } from '@/renderer/lib/exportSupportBundle'; import type { MessageClearRefreshOptions } from '@/renderer/lib/hydrateIdentityStoresFromDb'; import { DetailsChevron } from '@/renderer/lib/icons/detailsChevron'; import { parseDatabaseSchemaTooNewFromMessage } from '@/shared/databaseSchemaTooNew'; -import { isMeshcorePathHashMode } from '@/shared/meshcorePathHash'; import type { SupportBundleMode } from '@/shared/support-bundle.types'; import type { LocationFilter } from '../App'; @@ -216,9 +215,6 @@ interface Props { onAutoFloodAdvertTypeChange?: (type: 'flood' | 'zeroHop') => void; onChatCompactModeChange?: (compact: boolean) => void; onAlwaysShowMessageActionsChange?: (alwaysShow: boolean) => void; - deviceReportedPathHashMode?: 0 | 1 | 2 | null; - isMeshcoreRadioConnected?: boolean; - onApplyMeshcorePathHashMode?: (mode: 0 | 1 | 2) => Promise; /** Reticulum LXMF identity for DM-only message clear in Danger Zone. */ reticulumIdentityId?: string | null; reticulumSidecarReady?: boolean; @@ -255,9 +251,6 @@ export default function AppPanel({ onAutoFloodAdvertTypeChange, onChatCompactModeChange, onAlwaysShowMessageActionsChange, - deviceReportedPathHashMode, - isMeshcoreRadioConnected = false, - onApplyMeshcorePathHashMode, reticulumIdentityId = null, reticulumSidecarReady = false, }: Props) { @@ -309,7 +302,6 @@ export default function AppPanel({ // ─── Node retention settings ──────────────────────────────── const [settings, setSettings] = useState(loadSettings); - const pathHashModeUserChangedRef = useRef(false); const [themeColors, setThemeColors] = useState>(loadThemeColors); const [hasSavedThemeSnapshot, setHasSavedThemeSnapshot] = useState(hasThemeSnapshot); const [messageActionsBarBgVisible, setMessageActionsBarBgVisibleState] = useState( @@ -397,10 +389,7 @@ export default function AppPanel({ if (saveTimerRef.current) clearTimeout(saveTimerRef.current); saveTimerRef.current = setTimeout(() => { mergeAppSettingsPartial( - appPanelSettingsPersistPayload( - settings as unknown as Record, - pathHashModeUserChangedRef.current, - ), + appPanelSettingsPersistPayload(settings as unknown as Record), 'AppPanel saveSettings', ); }, 300); @@ -409,17 +398,6 @@ export default function AppPanel({ }; }, [settings]); - // Keep dropdown aligned with companion when connect adopts radio mode into settings. - useEffect(() => { - if (!isMeshcorePathHashMode(deviceReportedPathHashMode)) return; - if (pathHashModeUserChangedRef.current) return; - setSettings((prev) => - prev.meshcorePathHashMode === deviceReportedPathHashMode - ? prev - : { ...prev, meshcorePathHashMode: deviceReportedPathHashMode }, - ); - }, [deviceReportedPathHashMode]); - useEffect(() => { onLocationFilterChange({ enabled: settings.distanceFilterEnabled, @@ -1428,92 +1406,6 @@ export default function AppPanel({ )} - {/* MeshCore Open wire compatibility (experimental) */} - {protocol === 'meshcore' && ( -
-

- {t('appPanel.meshcoreOpenWireExperimentalTitle')} -

-
-
- { - updateSetting('meshcoreOpenWireCompatEnabled', e.target.checked); - }} - aria-label={t('appPanel.meshcoreOpenWireCompatLabel')} - className="accent-brand-green mt-0.5" - /> - -
-

- {t('appPanel.meshcoreOpenWireCompatHint')} -

-
-
- )} - - {protocol === 'meshcore' && ( -
-

- {t('appPanel.meshcorePathHashExperimentalTitle')} -

-
- - - {deviceReportedPathHashMode != null && isMeshcoreRadioConnected ? ( -

- {t('appPanel.meshcorePathHashDeviceReported', { - mode: - deviceReportedPathHashMode === 0 - ? t('appPanel.meshcorePathHashModeShort0') - : deviceReportedPathHashMode === 1 - ? t('appPanel.meshcorePathHashModeShort1') - : t('appPanel.meshcorePathHashModeShort2'), - })} -

- ) : null} -

- {t('appPanel.meshcorePathHashModeHint')} -

-
-
- )} - {/* Messages: load limit (localStorage) + DB retention cap — single card (issue #387). */}

diff --git a/src/renderer/components/MeshcoreRepeaterPasswordControls.test.tsx b/src/renderer/components/MeshcoreRepeaterPasswordControls.test.tsx index 96ef84a3a..bfa4357d2 100644 --- a/src/renderer/components/MeshcoreRepeaterPasswordControls.test.tsx +++ b/src/renderer/components/MeshcoreRepeaterPasswordControls.test.tsx @@ -2,7 +2,9 @@ import { render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { axe } from 'vitest-axe'; +import { hydrateAxeThemeColors } from '../lib/a11yTestHelpers'; import { MeshcoreRepeaterPasswordControls } from './MeshcoreRepeaterPasswordControls'; vi.mock('@/renderer/lib/meshcoreRepeaterSavedSecrets', () => ({ @@ -50,7 +52,7 @@ describe('MeshcoreRepeaterPasswordControls', () => { const onSecretsChanged = vi.fn(); const onStatusMessage = vi.fn(); - render( + const { container } = render( { />, ); - await user.click(screen.getByRole('button', { name: /forget saved repeater password/i })); + await user.click(screen.getByRole('button', { name: /forget saved admin password/i })); expect(forgetMeshcoreRepeaterSavedSecret).toHaveBeenCalledWith(0x200); expect(onSecretsChanged).toHaveBeenCalled(); expect(onStatusMessage).toHaveBeenCalledWith(expect.stringMatching(/removed/i)); + hydrateAxeThemeColors(container); + expect(await axe(container)).toHaveNoViolations(); }); }); diff --git a/src/renderer/components/MeshcoreRoomAclControls.tsx b/src/renderer/components/MeshcoreRoomAclControls.tsx new file mode 100644 index 000000000..a35b06baf --- /dev/null +++ b/src/renderer/components/MeshcoreRoomAclControls.tsx @@ -0,0 +1,75 @@ +import { type SyntheticEvent, useCallback, useState } from 'react'; +import { useTranslation } from 'react-i18next'; + +interface Props { + disabled?: boolean; + onApply: (pubkeyHex: string, level: number) => Promise; +} + +/** Room ACL setperm form — used on Repeaters & Rooms ops CLI row. */ +export function MeshcoreRoomAclControls({ disabled, onApply }: Props) { + const { t } = useTranslation(); + const [aclPubkey, setAclPubkey] = useState(''); + // Default guest (read-only) — matches historical Rooms ACL form; avoid silent RW bump. + const [aclLevel, setAclLevel] = useState(1); + const [pending, setPending] = useState(false); + + const handleSubmit = useCallback( + async (e: SyntheticEvent) => { + e.preventDefault(); + const normalized = aclPubkey.trim().toLowerCase(); + if (!/^[0-9a-f]{64}$/.test(normalized)) return; + setPending(true); + try { + await onApply(normalized, aclLevel); + setAclPubkey(''); + } finally { + setPending(false); + } + }, + [aclLevel, aclPubkey, onApply], + ); + + return ( +

void handleSubmit(e)}> + + + +
+ ); +} diff --git a/src/renderer/components/NodeDetailModal.test.tsx b/src/renderer/components/NodeDetailModal.test.tsx index d2f8b64ad..eedb87582 100644 --- a/src/renderer/components/NodeDetailModal.test.tsx +++ b/src/renderer/components/NodeDetailModal.test.tsx @@ -293,8 +293,8 @@ describe('NodeDetailModal MeshCore actions', () => { await user.click(screen.getByRole('button', { name: '📊 Request Status' })); - expect(screen.getByText('Repeater admin password')).toBeInTheDocument(); - const authOverlay = screen.getByText('Repeater admin password').closest('.fixed'); + expect(screen.getByText('Admin password')).toBeInTheDocument(); + const authOverlay = screen.getByText('Admin password').closest('.fixed'); expect(authOverlay).toHaveStyle({ zIndex: String(Z_NESTED_AUTH_OVERLAY) }); const nodeModalOverlay = container.querySelector('.fixed'); @@ -456,7 +456,7 @@ describe('NodeDetailModal MeshCore actions', () => { renderMeshcoreModal({ onRequestRepeaterStatus }); await user.click(screen.getByRole('button', { name: '📊 Request Status' })); - await user.type(screen.getByLabelText('Repeater admin password (optional)'), 'repeater-secret'); + await user.type(screen.getByLabelText('Admin password (optional)'), 'repeater-secret'); await user.click(screen.getByRole('button', { name: 'Continue' })); expect(onRequestRepeaterStatus).toHaveBeenCalledWith(meshcoreRepeaterNode.node_id); @@ -469,7 +469,7 @@ describe('NodeDetailModal MeshCore actions', () => { await user.click(screen.getByRole('button', { name: '📊 Request Status' })); await user.click(screen.getByRole('checkbox')); - await user.type(screen.getByLabelText('Repeater admin password (optional)'), 'session-only'); + await user.type(screen.getByLabelText('Admin password (optional)'), 'session-only'); await user.click(screen.getByRole('button', { name: 'Continue' })); expect(onRequestRepeaterStatus).toHaveBeenCalledWith(meshcoreRepeaterNode.node_id); @@ -482,7 +482,7 @@ describe('NodeDetailModal MeshCore actions', () => { renderMeshcoreModal({ onRequestRepeaterStatus }); await user.click(screen.getByRole('button', { name: '📊 Request Status' })); - await user.type(screen.getByLabelText('Repeater admin password (optional)'), 'repeater-secret'); + await user.type(screen.getByLabelText('Admin password (optional)'), 'repeater-secret'); await user.click(screen.getByRole('button', { name: 'Continue' })); expect(onRequestRepeaterStatus).toHaveBeenCalledWith(meshcoreRepeaterNode.node_id); diff --git a/src/renderer/components/NodeDetailModal.tsx b/src/renderer/components/NodeDetailModal.tsx index fc946e64e..53ec474c6 100644 --- a/src/renderer/components/NodeDetailModal.tsx +++ b/src/renderer/components/NodeDetailModal.tsx @@ -20,7 +20,6 @@ import { formatMeshtasticNodeId } from '@/shared/nodeNameUtils'; import { MESHCORE_NEIGHBORS_MAX_RECOMMENDED_HOPS } from '../hooks/meshcore/meshcoreHookPreamble'; import { useMeshcoreRepeaterRemoteAuth } from '../hooks/useMeshcoreRepeaterRemoteAuth'; -import { useMeshcoreRoomAuth } from '../hooks/useMeshcoreRoomAuth'; import { formatCoordPair } from '../lib/coordUtils'; import { downloadBlob } from '../lib/downloadBlob'; import { meshtasticHwModelDisplay } from '../lib/hardwareModels'; @@ -40,7 +39,6 @@ import { meshcorePathBytesEqual, meshcoreTraceHopDisplayRows, } from '../lib/meshcorePathChainDisplay'; -import { meshcoreGetRoomSession, meshcoreIsRoomLoggedIn } from '../lib/meshcoreRoomSession'; import { isMeshcoreDmExcludedHwModel, MESHCORE_CHAT_STUB_ID_MAX, @@ -94,12 +92,6 @@ interface NodeDetailModalProps { onMessageNode?: (nodeNum: number) => void; /** MeshCore room server: open Rooms tab for BBS posts (not DM). */ onOpenRoom?: (nodeNum: number) => void; - /** MeshCore room server login before status/admin actions. */ - onLoginRoom?: ( - nodeId: number, - password: string, - opts?: { adminPassword?: string; guestPassword?: string; forceRelogin?: boolean }, - ) => Promise; onToggleFavorite: (nodeId: number, favorited: boolean) => void; isConnected: boolean; mqttConnected?: boolean; @@ -225,7 +217,6 @@ export default function NodeDetailModal({ onDeleteNode, onMessageNode, onOpenRoom, - onLoginRoom, onToggleFavorite, isConnected, mqttConnected = false, @@ -265,7 +256,6 @@ export default function NodeDetailModal({ const use24HourTime = useTimeFormatStore((s) => s.use24HourTime); const { ensureRepeaterAuth, promptRepeaterPassword, RemoteAuthModal } = useMeshcoreRepeaterRemoteAuth(); - const { ensureRoomAuth, RemoteAuthModal: RoomAuthModal } = useMeshcoreRoomAuth(); const [repeaterSecretsEpoch, setRepeaterSecretsEpoch] = useState(0); const refreshRepeaterSecrets = useCallback(() => { setRepeaterSecretsEpoch((n) => n + 1); @@ -504,41 +494,29 @@ export default function NodeDetailModal({ hwModel: string | undefined, mode: 'guest' | 'admin', ): Promise => { - if (hwModel === 'Room') { - const roomName = node?.long_name ?? `Room-${nodeId.toString(16)}`; - const auth = await ensureRoomAuth(nodeId, mode === 'admin' ? 'admin' : 'guest', roomName); - if (!auth.ok || !onLoginRoom) { - setActionStatus(t('nodeDetailModal.remoteAuthCancelled')); - return false; - } - const password = mode === 'admin' ? auth.adminPassword : auth.guestPassword; - const session = meshcoreGetRoomSession(nodeId); - const forceRelogin = - meshcoreIsRoomLoggedIn(nodeId) && - (session?.role === 'readonly' || (mode === 'admin' && session?.role !== 'admin')); - try { - await onLoginRoom(nodeId, password, { - adminPassword: auth.adminPassword, - guestPassword: auth.guestPassword, - forceRelogin, - }); - return true; - } catch (e) { - console.warn('[NodeDetailModal] room login failed ' + errLikeToLogString(e)); + // Infra ops (status/telemetry/neighbors) use ops admin secrets like RepeatersPanel — + // not the Rooms BBS guest/admin overlay. + if (hwModel === 'Room' || hwModel === 'Repeater') { + const fallbackLabel = + hwModel === 'Room' + ? t('repeatersPanel.savedPasswordOrphanRoomLabel', { + nodeId: nodeId.toString(16), + }) + : t('repeatersPanel.savedPasswordOrphanLabel', { + nodeId: nodeId.toString(16), + }); + const auth = await ensureRepeaterAuth(nodeId, node?.long_name ?? fallbackLabel, hwModel); + if (!auth.ok) { setActionStatus(t('nodeDetailModal.remoteAuthCancelled')); return false; } + if (auth.saved) refreshRepeaterSecrets(); + return true; } - const repeaterName = node?.long_name ?? `Repeater-${nodeId.toString(16)}`; - const auth = await ensureRepeaterAuth(nodeId, repeaterName); - if (!auth.ok) { - setActionStatus(t('nodeDetailModal.remoteAuthCancelled')); - return false; - } - if (auth.saved) refreshRepeaterSecrets(); + void mode; return true; }, - [ensureRepeaterAuth, ensureRoomAuth, node?.long_name, onLoginRoom, refreshRepeaterSecrets, t], + [ensureRepeaterAuth, node?.long_name, refreshRepeaterSecrets, t], ); useEffect(() => { @@ -898,7 +876,7 @@ export default function NodeDetailModal({ {protocol === 'meshcore' && !isOurNode && - node.hw_model === 'Repeater' && + (node.hw_model === 'Repeater' || node.hw_model === 'Room') && meshcoreNeighborError && !showMeshcoreNeighbors && (
@@ -2000,57 +1978,59 @@ export default function NodeDetailModal({ : t('nodeDetailModal.sensorTelemetryButton')} )} - {protocol === 'meshcore' && onRequestNeighbors && node.hw_model === 'Repeater' && ( - - )} + className="bg-secondary-dark min-w-[8rem] flex-1 rounded-lg px-3 py-2 text-sm font-medium text-gray-200 transition-colors hover:bg-gray-600 disabled:cursor-not-allowed disabled:opacity-40" + > + {neighborsPending + ? t('nodeDetailModal.requestingEllipsis') + : t('nodeDetailModal.getNeighbors')} + + )} {onOpenRoom && protocol === 'meshcore' && node.hw_model === 'Room' && (
{RemoteAuthModal} - {RoomAuthModal} ); } diff --git a/src/renderer/components/RadioPanel.test.tsx b/src/renderer/components/RadioPanel.test.tsx index 1f6dc4099..86355b86c 100644 --- a/src/renderer/components/RadioPanel.test.tsx +++ b/src/renderer/components/RadioPanel.test.tsx @@ -529,3 +529,124 @@ describe('RadioPanel collapsible section consistency', () => { }); }); }); + +describe('RadioPanel MeshCore Open wire and path hash', () => { + beforeEach(() => { + localStorage.removeItem('mesh-client:appSettings'); + }); + + it('shows Open-wire and path-hash controls for MeshCore capabilities', () => { + render( + + + , + ); + expect( + screen.getByRole('checkbox', { name: /Enable MeshCore Open compatibility/i }), + ).not.toBeChecked(); + expect(screen.getByLabelText(/Default path hash size/i)).toHaveValue('0'); + }); + + it('does not show Open-wire or path-hash without MeshCore capabilities', () => { + render( + + + , + ); + expect( + screen.queryByRole('checkbox', { name: /Enable MeshCore Open compatibility/i }), + ).toBeNull(); + expect(screen.queryByLabelText(/Default path hash size/i)).toBeNull(); + }); + + it('persists meshcoreOpenWireCompatEnabled to app settings', async () => { + render( + + + , + ); + const checkbox = screen.getByRole('checkbox', { + name: /Enable MeshCore Open compatibility/i, + }); + fireEvent.click(checkbox); + await waitFor(() => { + const raw = localStorage.getItem('mesh-client:appSettings'); + expect(raw).toContain('"meshcoreOpenWireCompatEnabled":true'); + }); + }); + + it('persists meshcorePathHashMode when the user changes the dropdown', async () => { + render( + + + , + ); + const select = screen.getByLabelText(/Default path hash size/i); + fireEvent.change(select, { target: { value: '1' } }); + await waitFor(() => { + const raw = localStorage.getItem('mesh-client:appSettings'); + expect(raw).toContain('"meshcorePathHashMode":1'); + }); + }); + + it('syncs dropdown from device-reported mode when user has not changed it', async () => { + const { rerender } = render( + + + , + ); + expect(screen.getByLabelText(/Default path hash size/i)).toHaveValue('0'); + + rerender( + + + , + ); + await waitFor(() => { + expect(screen.getByLabelText(/Default path hash size/i)).toHaveValue('1'); + }); + }); + + it('applies path hash mode to the radio when connected', async () => { + const onApplyMeshcorePathHashMode = vi.fn().mockResolvedValue(undefined); + render( + + + , + ); + fireEvent.change(screen.getByLabelText(/Default path hash size/i), { + target: { value: '2' }, + }); + await waitFor(() => { + expect(onApplyMeshcorePathHashMode).toHaveBeenCalledWith(2); + }); + }); + + it('MeshCore Open-wire / path-hash controls have no axe violations', async () => { + const { container } = render( + + + , + ); + expect( + screen.getByRole('checkbox', { name: /Enable MeshCore Open compatibility/i }), + ).toBeInTheDocument(); + hydrateAxeThemeColors(container); + expect(await axe(container)).toHaveNoViolations(); + }); +}); diff --git a/src/renderer/components/RadioPanel.tsx b/src/renderer/components/RadioPanel.tsx index 3d38b17af..ab0b5bc6a 100644 --- a/src/renderer/components/RadioPanel.tsx +++ b/src/renderer/components/RadioPanel.tsx @@ -21,6 +21,7 @@ import { buildMeshcoreChannelAddUri, classifyMeshClientDeepLink, } from '@/shared/meshClientDeepLink'; +import { isMeshcorePathHashMode, type MeshcorePathHashMode } from '@/shared/meshcorePathHash'; import { formatMeshtasticBluetoothPin, parseMeshtasticBluetoothPin, @@ -42,6 +43,8 @@ import { useMeshcoreContactCapacity, } from '../hooks/useMeshcoreContactCapacity'; import { useSyncFormFromConfig } from '../hooks/useSyncFormFromConfig'; +import { getAppSettingsRaw, mergeAppSetting } from '../lib/appSettingsStorage'; +import { DEFAULT_APP_SETTINGS_SHARED } from '../lib/defaultAppSettings'; import type { OurPosition } from '../lib/gpsSource'; import type { MeshCoreContactRaw, MeshCoreSelfInfo } from '../lib/meshcore/meshcoreHookTypes'; import type { MeshcoreAutoaddWireState } from '../lib/meshcoreContactAutoAdd'; @@ -61,6 +64,7 @@ import { meshcoreSelfInfoBwToDisplayKhz, meshcoreSelfInfoFreqToDisplayHz, } from '../lib/meshcoreUtils'; +import { parseStoredJson } from '../lib/parseStoredJson'; import type { ProtocolCapabilities } from '../lib/radio/BaseRadioProvider'; import type { ConfigTargetContext, RemoteConfigChannelsTailStatus } from '../lib/types'; import { ConfigApplyNotice } from './ConfigApplyNotice'; @@ -96,6 +100,26 @@ function numericArray(value: unknown): number[] | null { return value; } +function loadMeshcoreRadioExperimentalSettings(): { + meshcoreOpenWireCompatEnabled: boolean; + meshcorePathHashMode: MeshcorePathHashMode; +} { + const parsed = parseStoredJson<{ + meshcoreOpenWireCompatEnabled?: boolean; + meshcorePathHashMode?: unknown; + }>(getAppSettingsRaw(), 'RadioPanel meshcore experimental'); + const mode = parsed?.meshcorePathHashMode; + return { + meshcoreOpenWireCompatEnabled: + typeof parsed?.meshcoreOpenWireCompatEnabled === 'boolean' + ? parsed.meshcoreOpenWireCompatEnabled + : DEFAULT_APP_SETTINGS_SHARED.meshcoreOpenWireCompatEnabled, + meshcorePathHashMode: isMeshcorePathHashMode(mode) + ? mode + : DEFAULT_APP_SETTINGS_SHARED.meshcorePathHashMode, + }; +} + interface Props { configTarget?: ConfigTargetContext; onSetConfig: (config: unknown) => Promise; @@ -175,6 +199,8 @@ interface Props { onXmodemUpload?: () => Promise; onXmodemDownload?: (filename: string) => Promise; onSyncClock?: () => Promise; + deviceReportedPathHashMode?: MeshcorePathHashMode | null; + onApplyMeshcorePathHashMode?: (mode: MeshcorePathHashMode) => Promise; onRefreshContacts?: () => Promise; onOffloadContactsFromRadio?: () => Promise; /** Remote admin: channel indices that failed to load from the target node. */ @@ -689,6 +715,8 @@ export default function RadioPanel({ onXmodemUpload, onXmodemDownload, onSyncClock, + deviceReportedPathHashMode = null, + onApplyMeshcorePathHashMode, onRefreshContacts, onOffloadContactsFromRadio, remoteChannelFailedIndices, @@ -1036,6 +1064,21 @@ export default function RadioPanel({ const [advertLoading, setAdvertLoading] = useState(false); const [zeroHopAdvertLoading, setZeroHopAdvertLoading] = useState(false); const [syncClockLoading, setSyncClockLoading] = useState(false); + const pathHashModeUserChangedRef = useRef(false); + const [meshcoreOpenWireCompatEnabled, setMeshcoreOpenWireCompatEnabled] = useState( + () => loadMeshcoreRadioExperimentalSettings().meshcoreOpenWireCompatEnabled, + ); + const [meshcorePathHashMode, setMeshcorePathHashMode] = useState( + () => loadMeshcoreRadioExperimentalSettings().meshcorePathHashMode, + ); + + useEffect(() => { + if (!isMeshcorePathHashMode(deviceReportedPathHashMode)) return; + if (pathHashModeUserChangedRef.current) return; + setMeshcorePathHashMode((prev) => + prev === deviceReportedPathHashMode ? prev : deviceReportedPathHashMode, + ); + }, [deviceReportedPathHashMode]); const disabled = !isConnected || (configTarget?.mode === 'remote' && !configTarget.isReady); const loraDisabled = @@ -2687,6 +2730,97 @@ export default function RadioPanel({
)} + + {capabilities?.hasCompanionContactManagementConfig && ( + <> +
+

+ {t('appPanel.meshcoreOpenWireExperimentalTitle')} +

+
+
+ { + const next = e.target.checked; + setMeshcoreOpenWireCompatEnabled(next); + mergeAppSetting( + 'meshcoreOpenWireCompatEnabled', + next, + 'RadioPanel meshcoreOpenWire', + ); + }} + aria-label={t('appPanel.meshcoreOpenWireCompatLabel')} + className="accent-brand-green mt-0.5" + /> + +
+

+ {t('appPanel.meshcoreOpenWireCompatHint')} +

+
+
+
+

+ {t('appPanel.meshcorePathHashExperimentalTitle')} +

+
+ + + {deviceReportedPathHashMode != null && isConnected ? ( +

+ {t('appPanel.meshcorePathHashDeviceReported', { + mode: + deviceReportedPathHashMode === 0 + ? t('appPanel.meshcorePathHashModeShort0') + : deviceReportedPathHashMode === 1 + ? t('appPanel.meshcorePathHashModeShort1') + : t('appPanel.meshcorePathHashModeShort2'), + })} +

+ ) : null} +

+ {t('appPanel.meshcorePathHashModeHint')} +

+
+
+ + )} ); } diff --git a/src/renderer/components/RepeatersPanel.auth.test.tsx b/src/renderer/components/RepeatersPanel.auth.test.tsx index 6cd1eb7ae..0f9465cdb 100644 --- a/src/renderer/components/RepeatersPanel.auth.test.tsx +++ b/src/renderer/components/RepeatersPanel.auth.test.tsx @@ -1,9 +1,17 @@ -import { render, screen } from '@testing-library/react'; +import { render, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import type { ComponentProps } from 'react'; import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { APP_SETTINGS_STORAGE_KEY } from '../lib/appSettingsStorage'; +import { clearAllRoomEphemeralAdminPasswords } from '../lib/meshcoreInfraAdminSecrets'; +import { meshcoreRepeaterCredentialSettingForNode } from '../lib/meshcoreRepeaterCredentialStorage'; import { clearAllMeshcoreRepeaterEphemeralPasswords } from '../lib/meshcoreRepeaterSession'; +import { + getMeshcoreRoomCredential, + meshcoreRoomCredentialSettingForNode, + setMeshcoreRoomCredential, +} from '../lib/meshcoreRoomCredentialStorage'; import type { MeshNode } from '../lib/types'; import RepeatersPanel from './RepeatersPanel'; @@ -43,6 +51,18 @@ const repeater: MeshNode = { longitude: null, }; +const room: MeshNode = { + node_id: 0xdef, + long_name: 'Test Room', + short_name: 'RM', + hw_model: 'Room', + snr: 2, + battery: 100, + last_heard: Math.floor(Date.now() / 1000), + latitude: null, + longitude: null, +}; + function makeProps( overrides: Partial> = {}, ): ComponentProps { @@ -62,6 +82,7 @@ describe('RepeatersPanel repeater auth', () => { beforeEach(() => { localStorage.clear(); clearAllMeshcoreRepeaterEphemeralPasswords(); + clearAllRoomEphemeralAdminPasswords(); vi.mocked(window.electronAPI.appSettings.set).mockClear(); vi.mocked(window.electronAPI.appSettings.set).mockResolvedValue({ changes: 1 }); }); @@ -72,7 +93,7 @@ describe('RepeatersPanel repeater auth', () => { render(); await user.click(screen.getByRole('button', { name: 'Request status' })); - await user.type(screen.getByLabelText('Repeater admin password (optional)'), 'repeater-secret'); + await user.type(screen.getByLabelText('Admin password (optional)'), 'repeater-secret'); await user.click(screen.getByRole('button', { name: 'Continue' })); expect(onRequestRepeaterStatus).toHaveBeenCalledWith(repeater.node_id); @@ -85,9 +106,54 @@ describe('RepeatersPanel repeater auth', () => { render(); await user.click(screen.getByRole('button', { name: 'Request status' })); - await user.type(screen.getByLabelText('Repeater admin password (optional)'), 'repeater-secret'); + await user.type(screen.getByLabelText('Admin password (optional)'), 'repeater-secret'); await user.click(screen.getByRole('button', { name: 'Continue' })); expect(onRequestRepeaterStatus).toHaveBeenCalledWith(repeater.node_id); }); + + it('Room Status Remember writes room adminPassword not repeater key', async () => { + const user = userEvent.setup(); + const onRequestRepeaterStatus = vi.fn().mockResolvedValue(undefined); + render( + , + ); + + await user.click(screen.getByRole('button', { name: 'Request status' })); + await user.type(screen.getByLabelText('Admin password (optional)'), 'room-admin'); + await user.click(screen.getByRole('button', { name: 'Continue' })); + + await waitFor(() => { + expect(onRequestRepeaterStatus).toHaveBeenCalledWith(room.node_id); + }); + expect(getMeshcoreRoomCredential(room.node_id)?.adminPassword).toBe('room-admin'); + const raw = localStorage.getItem(APP_SETTINGS_STORAGE_KEY) ?? ''; + expect(raw).toContain(meshcoreRoomCredentialSettingForNode(room.node_id)); + expect(raw).not.toContain(meshcoreRepeaterCredentialSettingForNode(room.node_id)); + }); + + it('Room Status skips auth modal when admin password is already saved', async () => { + await setMeshcoreRoomCredential(room.node_id, { + guestPassword: '', + adminPassword: 'saved', + }); + const onRequestRepeaterStatus = vi.fn().mockResolvedValue(undefined); + render( + , + ); + + await userEvent.click(screen.getByRole('button', { name: 'Request status' })); + expect(screen.queryByLabelText('Admin password (optional)')).not.toBeInTheDocument(); + expect(onRequestRepeaterStatus).toHaveBeenCalledWith(room.node_id); + }); }); diff --git a/src/renderer/components/RepeatersPanel.test.tsx b/src/renderer/components/RepeatersPanel.test.tsx index 02c6a46cb..76f96ca3a 100644 --- a/src/renderer/components/RepeatersPanel.test.tsx +++ b/src/renderer/components/RepeatersPanel.test.tsx @@ -137,7 +137,7 @@ describe('RepeatersPanel', () => { props.onRequestNeighbors = vi.fn().mockRejectedValue(new Error('neighbors timeout')); render(); - await userEvent.click(screen.getByRole('button', { name: 'Repeater neighbors' })); + await userEvent.click(screen.getByRole('button', { name: 'Neighbors' })); expect(warnSpy).toHaveBeenCalled(); expect(mockAddToast).toHaveBeenCalledWith( @@ -173,7 +173,7 @@ describe('RepeatersPanel', () => { />, ); - await userEvent.click(screen.getByRole('button', { name: 'Repeater neighbors' })); + await userEvent.click(screen.getByRole('button', { name: 'Neighbors' })); expect(onRequestNeighbors).toHaveBeenCalledWith(repeater.node_id); const loadMore = await screen.findByRole('button', { @@ -210,7 +210,7 @@ describe('RepeatersPanel', () => { />, ); - await userEvent.click(screen.getByRole('button', { name: 'Repeater neighbors' })); + await userEvent.click(screen.getByRole('button', { name: 'Neighbors' })); expect(screen.queryByRole('button', { name: /Load more neighbors/i })).not.toBeInTheDocument(); }); @@ -244,7 +244,7 @@ describe('RepeatersPanel', () => { />, ); - await userEvent.click(screen.getByRole('button', { name: 'Repeater neighbors' })); + await userEvent.click(screen.getByRole('button', { name: 'Neighbors' })); const loadMore = await screen.findByRole('button', { name: 'Load more neighbors (50 of 60 loaded)', }); @@ -283,7 +283,7 @@ describe('RepeatersPanel', () => { const { rerender } = render(); - await userEvent.click(screen.getByRole('button', { name: 'Repeater neighbors' })); + await userEvent.click(screen.getByRole('button', { name: 'Neighbors' })); expect( await screen.findByRole('button', { name: 'Load more neighbors (50 of 60 loaded)', @@ -329,7 +329,7 @@ describe('RepeatersPanel', () => { const { rerender } = render(); - await userEvent.click(screen.getByRole('button', { name: 'Repeater neighbors' })); + await userEvent.click(screen.getByRole('button', { name: 'Neighbors' })); const loadMore = await screen.findByRole('button', { name: 'Load more neighbors (50 of 60 loaded)', }); @@ -455,6 +455,23 @@ describe('RepeatersPanel', () => { expect(onSendCliCommand).toHaveBeenCalledWith(repeater.node_id, 'name', undefined); }); + it('shows translated CLI error in the expanded CLI panel', async () => { + render( + , + ); + + await userEvent.click( + screen.getByRole('button', { name: /CLI: Node not found \(no encryption key\)/i }), + ); + expect(screen.getByText('Node not found (no encryption key)')).toBeInTheDocument(); + }); + it('calls onSendCliCommand when a quick command button is clicked', async () => { const onSendCliCommand = vi.fn().mockResolvedValue('ok'); render(); @@ -495,6 +512,21 @@ describe('RepeatersPanel', () => { expect(onSendCliCommand).toHaveBeenCalledWith(repeater.node_id, 'clock sync', undefined); }); + it('toasts when clock sync is refused because the repeater clock cannot go backwards', async () => { + const onSendCliCommand = vi.fn().mockResolvedValue('02|ERR: clock cannot go backwards'); + render(); + + await userEvent.click(screen.getByRole('button', { name: 'CLI interface' })); + await userEvent.click(screen.getByRole('button', { name: 'clock sync' })); + + await waitFor(() => { + expect(mockAddToast).toHaveBeenCalledWith( + expect.stringMatching(/clock is ahead of this computer/i), + 'info', + ); + }); + }); + it('requires confirmation before sending destructive CLI commands', async () => { const onSendCliCommand = vi.fn().mockResolvedValue('ok'); render(); @@ -618,7 +650,7 @@ describe('RepeatersPanel', () => { await waitFor(() => { expect(onPing).toHaveBeenCalledWith(multiHop.node_id); expect(mockAddToast).toHaveBeenCalledWith( - 'Ping failed; run Ping manually before retrying CLI on multi-hop repeaters.', + 'Ping failed; run Ping manually before retrying CLI on multi-hop nodes.', 'error', ); }); @@ -644,7 +676,7 @@ describe('RepeatersPanel', () => { await waitFor(() => { expect(onPing).toHaveBeenCalledWith(multiHop.node_id); expect(mockAddToast).toHaveBeenCalledWith( - 'Ping failed; run Ping manually before retrying CLI on multi-hop repeaters.', + 'Ping failed; run Ping manually before retrying CLI on multi-hop nodes.', 'error', ); }); @@ -865,6 +897,226 @@ describe('RepeatersPanel', () => { onRequestNeighbors={vi.fn()} />, ); - expect(screen.getByRole('button', { name: 'Repeater neighbors' })).toBeDisabled(); + expect(screen.getByRole('button', { name: 'Neighbors' })).toBeDisabled(); + }); + + function mockRoomNode(id: number): MeshNode { + return { + node_id: id, + long_name: 'Test Room', + short_name: 'TR', + hw_model: 'Room', + snr: 2, + battery: 100, + last_heard: Math.floor(Date.now() / 1000), + latitude: null, + longitude: null, + }; + } + + it('lists room nodes alongside repeaters with type badges', () => { + const room = mockRoomNode(0xdef); + render( + , + ); + expect(screen.getByText('Test Repeater')).toBeInTheDocument(); + expect(screen.getByText('Test Room')).toBeInTheDocument(); + expect(screen.getAllByText('Repeater').length).toBeGreaterThan(0); + expect(screen.getAllByText('Room').length).toBeGreaterThan(0); + }); + + it('filters by All / Repeaters / Rooms chips', async () => { + const user = userEvent.setup(); + const room = mockRoomNode(0xdef); + render( + , + ); + await user.click(screen.getByRole('button', { name: 'Repeaters' })); + expect(screen.getByText('Test Repeater')).toBeInTheDocument(); + expect(screen.queryByText('Test Room')).not.toBeInTheDocument(); + await user.click(screen.getByRole('button', { name: 'Rooms' })); + expect(screen.queryByText('Test Repeater')).not.toBeInTheDocument(); + expect(screen.getByText('Test Room')).toBeInTheDocument(); + await user.click(screen.getByRole('button', { name: 'All' })); + expect(screen.getByText('Test Repeater')).toBeInTheDocument(); + expect(screen.getByText('Test Room')).toBeInTheDocument(); + }); + + it('calls onOpenRoom for room rows', async () => { + const user = userEvent.setup(); + const room = mockRoomNode(0xdef); + const onOpenRoom = vi.fn(); + render( + , + ); + await user.click(screen.getByRole('button', { name: 'Open room' })); + expect(onOpenRoom).toHaveBeenCalledWith(room.node_id); + }); + + it('shows room-only CLI pills and ACL form for Room rows', async () => { + const user = userEvent.setup(); + const room = mockRoomNode(0xdef); + const onSendCliCommand = vi.fn().mockResolvedValue('ok'); + render( + , + ); + await user.click(screen.getByRole('button', { name: 'CLI interface' })); + expect(screen.getByRole('button', { name: 'get acl' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'allow.read.only on' })).toBeInTheDocument(); + expect(screen.getByLabelText('Public key (64 hex)')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'advert.zerohop' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'stats-core' })).toBeInTheDocument(); + }); + + it('room get acl pill sends get acl', async () => { + const user = userEvent.setup(); + const room = mockRoomNode(0xdef); + const onSendCliCommand = vi.fn().mockResolvedValue('ok'); + render( + , + ); + await user.click(screen.getByRole('button', { name: 'CLI interface' })); + await user.click(screen.getByRole('button', { name: 'get acl' })); + expect(onSendCliCommand).toHaveBeenCalledWith(room.node_id, 'get acl', undefined); + }); + + it('expanded room ACL form has no axe violations', async () => { + const user = userEvent.setup(); + const room = mockRoomNode(0xdef); + const { container } = render( + , + ); + await user.click(screen.getByRole('button', { name: 'CLI interface' })); + expect(screen.getByLabelText('Public key (64 hex)')).toBeInTheDocument(); + hydrateAxeThemeColors(container); + expect(await axe(container)).toHaveNoViolations(); + }); + + it('ACL form submits setperm with normalized 64-hex and level', async () => { + const user = userEvent.setup(); + const room = mockRoomNode(0xdef); + const onSendCliCommand = vi.fn().mockResolvedValue('ok'); + const hex = 'a'.repeat(64); + render( + , + ); + await user.click(screen.getByRole('button', { name: 'CLI interface' })); + await user.type(screen.getByLabelText('Public key (64 hex)'), hex.toUpperCase()); + await user.click(screen.getByRole('button', { name: 'Apply ACL' })); + expect(onSendCliCommand).toHaveBeenCalledWith(room.node_id, `setperm ${hex} 1`, undefined); + }); + + it('ACL form ignores invalid pubkey', async () => { + const user = userEvent.setup(); + const room = mockRoomNode(0xdef); + const onSendCliCommand = vi.fn().mockResolvedValue('ok'); + render( + , + ); + await user.click(screen.getByRole('button', { name: 'CLI interface' })); + await user.type(screen.getByLabelText('Public key (64 hex)'), 'not-a-key'); + expect(screen.getByRole('button', { name: 'Apply ACL' })).toBeDisabled(); + expect(onSendCliCommand).not.toHaveBeenCalled(); + }); + + it('does not show room-only ACL pills on Repeater rows', async () => { + const user = userEvent.setup(); + render( + , + ); + await user.click(screen.getByRole('button', { name: 'CLI interface' })); + expect(screen.queryByRole('button', { name: 'get acl' })).not.toBeInTheDocument(); + expect(screen.queryByLabelText('Public key (64 hex)')).not.toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'advert.zerohop' })).toBeInTheDocument(); + }); + + it('requires confirmation before shutdown on a Room row', async () => { + const user = userEvent.setup(); + const room = mockRoomNode(0xdef); + const onSendCliCommand = vi.fn().mockResolvedValue('ok'); + render( + , + ); + await user.click(screen.getByRole('button', { name: 'CLI interface' })); + const input = screen.getByRole('textbox', { name: 'CLI command input' }); + await user.type(input, 'shutdown'); + await user.click(screen.getByRole('button', { name: /Send/i })); + expect(onSendCliCommand).not.toHaveBeenCalled(); + await user.click(screen.getByRole('button', { name: 'Run command' })); + expect(onSendCliCommand).toHaveBeenCalledWith(room.node_id, 'shutdown', { + confirmedDanger: true, + }); + }); + + it('expands CLI for pendingFocusNodeId room', async () => { + const room = mockRoomNode(0xdef); + const onPendingFocusConsumed = vi.fn(); + render( + , + ); + expect(await screen.findByRole('textbox', { name: 'CLI command input' })).toBeInTheDocument(); + expect(onPendingFocusConsumed).toHaveBeenCalled(); }); }); diff --git a/src/renderer/components/RepeatersPanel.tsx b/src/renderer/components/RepeatersPanel.tsx index 336091382..fc933e0c4 100644 --- a/src/renderer/components/RepeatersPanel.tsx +++ b/src/renderer/components/RepeatersPanel.tsx @@ -20,7 +20,13 @@ import type { import { meshcoreRepeaterAdminErrorMessage, translateMeshcoreUserMessage, + translateRepeaterCliHistoryText, } from '../lib/meshcore/meshcoreMessageI18n'; +import { + forgetAdminPassword, + listSavedAdminPasswords, + type MeshcoreInfraAdminPasswordEntry, +} from '../lib/meshcoreInfraAdminSecrets'; import { buildMeshcorePathChainSegments, buildMeshcorePathResolutionFromNodes, @@ -32,8 +38,6 @@ import { import type { MeshcoreRepeaterRpcPendingMap } from '../lib/meshcoreRepeaterAdminPending'; import { isRepeaterAdminRpcPending } from '../lib/meshcoreRepeaterAdminPending'; import { isMeshcoreRepeaterCliDangerCommand } from '../lib/meshcoreRepeaterCliDanger'; -import { listMeshcoreRepeaterCredentialNodeIds } from '../lib/meshcoreRepeaterCredentialStorage'; -import { forgetMeshcoreRepeaterSavedSecret } from '../lib/meshcoreRepeaterSavedSecrets'; import { meshcoreTracePathLenToHops } from '../lib/meshcoreUtils'; import { effectiveLastHeardMs, @@ -43,6 +47,7 @@ import { } from '../lib/nodeStatus'; import type { PathRecord } from '../lib/pathHistoryTypes'; import { useRadioProvider } from '../lib/radio/providerFactory'; +import { REPEATER_CLI_MAX_COMMAND_LENGTH } from '../lib/repeaterCommandService'; import { MS_PER_DAY } from '../lib/timeConstants'; import type { MeshNode } from '../lib/types'; import { useCoordFormatStore } from '../stores/coordFormatStore'; @@ -51,11 +56,41 @@ import { useRepeaterSignalStore } from '../stores/repeaterSignalStore'; import { ConfirmModal } from './ConfirmModal'; import { HelpTooltip } from './HelpTooltip'; import { MeshcoreRepeaterSavedPasswordIndicator } from './MeshcoreRepeaterPasswordControls'; +import { MeshcoreRoomAclControls } from './MeshcoreRoomAclControls'; import { MeshcoreRouteChain } from './MeshcoreRouteChain'; import { formatSecondsAgo } from './NodeInfoBody'; import SnrIndicator from './SnrIndicator'; import { useToast } from './Toast'; +type TypeFilter = 'all' | 'repeater' | 'room'; + +const SHARED_CLI_QUICK_COMMANDS = [ + 'name', + 'radio', + 'neighbors', + 'version', + 'status', + 'config', + 'help', + 'clock', + 'clock sync', + 'clear stats', + 'advert', + 'advert.zerohop', + 'board', + 'get role', + 'stats-core', + 'stats-radio', + 'stats-packets', + 'discover.neighbors', + 'get path.hash.mode', + 'set path.hash.mode 0', + 'set path.hash.mode 1', + 'set path.hash.mode 2', +] as const; + +const ROOM_CLI_QUICK_COMMANDS = ['get acl', 'allow.read.only on', 'allow.read.only off'] as const; + interface Props { nodes: Map; meshcoreNodeStatus: Map; @@ -86,6 +121,11 @@ interface Props { /** MeshCore: when set (non-null), prefetches SQLite path history for visible repeaters. */ meshcoreCanPingTrace?: (nodeId: number) => boolean; onToggleFavorite?: (nodeId: number, favorited: boolean) => void; + /** Jump to Rooms tab for this room server. */ + onOpenRoom?: (nodeId: number) => void; + /** Select/expand CLI for this node (from Rooms Manage jump). */ + pendingFocusNodeId?: number | null; + onPendingFocusConsumed?: () => void; } const SIGNAL_MAX_AGE_MS = MS_PER_DAY; @@ -98,6 +138,14 @@ function isMeshcoreNeighborsHopBlocked(node: MeshNode): boolean { return hops != null && hops >= MESHCORE_NEIGHBORS_MAX_RECOMMENDED_HOPS; } +function formatComputerUtcStamp(d = new Date()): string { + return `${d.toISOString().slice(0, 16).replace('T', ' ')} UTC`; +} + +function isRepeaterCliClockCannotGoBackwards(command: string, response: string): boolean { + return command.trim().toLowerCase() === 'clock sync' && /cannot go backwards/i.test(response); +} + function effectiveRepeaterLastAdvert( dbAdvert: number | null | undefined, nodeLastHeard: number | undefined, @@ -145,18 +193,23 @@ async function runRepeaterAdminAction( orphanLabel: (nodeId: number) => string, ensureRepeaterAuth: ( nodeId: number, - repeaterName: string, + displayName: string, + hwModel?: string, ) => Promise<{ ok: boolean; saved?: boolean }>, - refreshStoredRepeaters: () => void, + refreshStoredSecrets: () => void, action: () => Promise, toastKey: string, logTag: string, addToast: (message: string, type: 'error') => void, ): Promise { const node = nodes.get(nodeId); - const auth = await ensureRepeaterAuth(nodeId, node?.long_name ?? orphanLabel(nodeId)); + const auth = await ensureRepeaterAuth( + nodeId, + node?.long_name ?? orphanLabel(nodeId), + node?.hw_model, + ); if (!auth.ok) return; - if (auth.saved) refreshStoredRepeaters(); + if (auth.saved) refreshStoredSecrets(); try { await action(); } catch (e) { @@ -263,29 +316,36 @@ export default function RepeatersPanel({ meshcoreCliErrors, onClearCliHistory, onToggleFavorite, + onOpenRoom, + pendingFocusNodeId, + onPendingFocusConsumed, }: Props) { const { addToast } = useToast(); const { t } = useTranslation(); const { ensureRepeaterAuth, RemoteAuthModal } = useMeshcoreRepeaterRemoteAuth(); - const [storedRepeaterIds, setStoredRepeaterIds] = useState( - () => new Set(listMeshcoreRepeaterCredentialNodeIds()), + const [savedAdminEntries, setSavedAdminEntries] = useState( + () => listSavedAdminPasswords(), ); const [savedPasswordsOpen, setSavedPasswordsOpen] = useState(false); - const [forgetConfirmNodeId, setForgetConfirmNodeId] = useState(null); - const refreshStoredRepeaters = useCallback(() => { - setStoredRepeaterIds(new Set(listMeshcoreRepeaterCredentialNodeIds())); + const [forgetConfirmKey, setForgetConfirmKey] = useState(null); + const [typeFilter, setTypeFilter] = useState('all'); + const lastConsumedPendingFocusRef = useRef(null); + const refreshStoredSecrets = useCallback(() => { + setSavedAdminEntries(listSavedAdminPasswords()); }, []); - const savedCredentialNodeIds = useMemo( - () => [...storedRepeaterIds].sort((a, b) => a - b), - [storedRepeaterIds], - ); - const resolveRepeaterDisplayName = useCallback( - (nodeId: number): string => { + const savedCredentialEntries = useMemo(() => savedAdminEntries, [savedAdminEntries]); + const resolveNodeDisplayName = useCallback( + (nodeId: number, kind: MeshcoreInfraAdminPasswordEntry['kind']): string => { const n = nodes.get(nodeId); if (n?.long_name) return n.long_name; - return t('repeatersPanel.savedPasswordOrphanLabel', { - nodeId: nodeId.toString(16).padStart(8, '0'), - }); + return t( + kind === 'Room' + ? 'repeatersPanel.savedPasswordOrphanRoomLabel' + : 'repeatersPanel.savedPasswordOrphanLabel', + { + nodeId: nodeId.toString(16).padStart(8, '0'), + }, + ); }, [nodes, t], ); @@ -350,10 +410,10 @@ export default function RepeatersPanel({ const { nodeStaleThresholdMs, nodeOfflineThresholdMs } = useRadioProvider('meshcore'); - const repeaters = useMemo( + const infraNodes = useMemo( () => Array.from(nodes.values()) - .filter((n) => n.hw_model === 'Repeater') + .filter((n) => n.hw_model === 'Repeater' || n.hw_model === 'Room') .sort((a, b) => { const aFav = a.favorited ? 1 : 0; const bFav = b.favorited ? 1 : 0; @@ -365,18 +425,39 @@ export default function RepeatersPanel({ useEffect(() => { if (nodes.size === 0) return; - console.debug('[RepeatersPanel] nodes=', nodes.size, 'repeatersCount=', repeaters.length); - }, [nodes.size, repeaters.length]); + console.debug('[RepeatersPanel] nodes=', nodes.size, 'infraCount=', infraNodes.length); + }, [nodes.size, infraNodes.length]); const repeatersFiltered = useMemo(() => { + let list = infraNodes; + if (typeFilter === 'repeater') { + list = list.filter((n) => n.hw_model === 'Repeater'); + } else if (typeFilter === 'room') { + list = list.filter((n) => n.hw_model === 'Room'); + } const q = searchQuery.trim().toLowerCase(); - if (!q) return repeaters; - return repeaters.filter( + if (!q) return list; + return list.filter( (n) => n.long_name.toLowerCase().includes(q) || n.node_id.toString(16).toLowerCase().includes(q), ); - }, [repeaters, searchQuery]); + }, [infraNodes, searchQuery, typeFilter]); + useEffect(() => { + if (pendingFocusNodeId == null) return; + if (lastConsumedPendingFocusRef.current === pendingFocusNodeId) return; + const target = nodes.get(pendingFocusNodeId); + if (!target || (target.hw_model !== 'Repeater' && target.hw_model !== 'Room')) { + lastConsumedPendingFocusRef.current = pendingFocusNodeId; + onPendingFocusConsumed?.(); + return; + } + if (target.hw_model === 'Room') setTypeFilter('room'); + else setTypeFilter('repeater'); + setExpandedCli((prev) => new Set([...prev, pendingFocusNodeId])); + lastConsumedPendingFocusRef.current = pendingFocusNodeId; + onPendingFocusConsumed?.(); + }, [nodes, onPendingFocusConsumed, pendingFocusNodeId]); const repeaterTableScrollRef = useRef(null); const shouldVirtualizeRepeaterRows = repeatersFiltered.length > REPEATER_VIRTUALIZE_THRESHOLD; const repeaterRowVirtualizer = useVirtualizer({ @@ -420,15 +501,19 @@ export default function RepeatersPanel({ repeaterRowVirtualizer, ]); - const handleForgetSavedPassword = async (nodeId: number) => { - if (forgetConfirmNodeId !== nodeId) { - setForgetConfirmNodeId(nodeId); + const handleForgetSavedPassword = async ( + nodeId: number, + kind: MeshcoreInfraAdminPasswordEntry['kind'], + ) => { + const confirmKey = `${kind}:${nodeId}`; + if (forgetConfirmKey !== confirmKey) { + setForgetConfirmKey(confirmKey); return; } - setForgetConfirmNodeId(null); + setForgetConfirmKey(null); try { - await forgetMeshcoreRepeaterSavedSecret(nodeId); - refreshStoredRepeaters(); + await forgetAdminPassword(nodeId, kind); + refreshStoredSecrets(); addToast(t('repeatersPanel.passwordForgotten'), 'success'); } catch (e) { console.warn('[RepeatersPanel] forget saved password failed ' + errLikeToLogString(e)); @@ -442,7 +527,7 @@ export default function RepeatersPanel({ nodes, (id) => t('repeatersPanel.savedPasswordOrphanLabel', { nodeId: id.toString(16) }), ensureRepeaterAuth, - refreshStoredRepeaters, + refreshStoredSecrets, () => onRequestRepeaterStatus(nodeId), 'repeatersPanel.statusFailedToast', 'requestRepeaterStatus error', @@ -517,7 +602,7 @@ export default function RepeatersPanel({ nodes, (id) => t('repeatersPanel.savedPasswordOrphanLabel', { nodeId: id.toString(16) }), ensureRepeaterAuth, - refreshStoredRepeaters, + refreshStoredSecrets, async () => { // Re-read length after auth so concurrent refresh / double-submit do not reuse a stale offset. if (isLoadMore) { @@ -561,7 +646,7 @@ export default function RepeatersPanel({ nodes, (id) => t('repeatersPanel.savedPasswordOrphanLabel', { nodeId: id.toString(16) }), ensureRepeaterAuth, - refreshStoredRepeaters, + refreshStoredSecrets, async () => { await onRequestTelemetry?.(nodeId); setExpandedTelemetry((prev) => new Set([...prev, nodeId])); @@ -616,20 +701,36 @@ export default function RepeatersPanel({ command: string, opts?: { confirmedDanger?: boolean }, ) => { - if (!onSendCliCommand || !command.trim()) return; + if (!onSendCliCommand || !command.trim()) { + return; + } const node = nodes.get(nodeId); const auth = await ensureRepeaterAuth( nodeId, node?.long_name ?? - t('repeatersPanel.savedPasswordOrphanLabel', { nodeId: nodeId.toString(16) }), + t( + node?.hw_model === 'Room' + ? 'repeatersPanel.savedPasswordOrphanRoomLabel' + : 'repeatersPanel.savedPasswordOrphanLabel', + { nodeId: nodeId.toString(16) }, + ), + node?.hw_model, ); if (!auth.ok) return; - if (auth.saved) refreshStoredRepeaters(); - if (!(await ensureCliRoutePrimed(nodeId))) return; + if (auth.saved) refreshStoredSecrets(); + const primed = await ensureCliRoutePrimed(nodeId); + if (!primed) return; try { - await onSendCliCommand(nodeId, command.trim(), opts); + const response = await onSendCliCommand(nodeId, command.trim(), opts); + if (isRepeaterCliClockCannotGoBackwards(command, response)) { + addToast( + t('repeatersPanel.cliClockCannotGoBackwards', { utc: formatComputerUtcStamp() }), + 'info', + ); + } } catch (e) { console.warn('[RepeatersPanel] CLI command error ' + errLikeToLogString(e)); + addToast(meshcoreRepeaterAdminErrorMessage(t, e), 'error'); } }; @@ -675,20 +776,51 @@ export default function RepeatersPanel({

{t('repeatersPanel.title')}

- { - setSearchQuery(e.target.value); - }} - placeholder={t('repeatersPanel.searchRepeatersPlaceholder')} - aria-label={t('repeatersPanel.searchRepeaters')} - className="bg-secondary-dark/80 focus:border-brand-green/50 max-w-[20rem] min-w-[8rem] flex-1 rounded-lg border border-gray-600/50 px-3 py-1.5 text-sm text-gray-200 focus:outline-none" - /> +
+
+ {( + [ + ['all', 'repeatersPanel.filterAll'], + ['repeater', 'repeatersPanel.filterRepeaters'], + ['room', 'repeatersPanel.filterRooms'], + ] as const + ).map(([id, labelKey]) => ( + + ))} +
+ { + setSearchQuery(e.target.value); + }} + placeholder={t('repeatersPanel.searchRepeatersPlaceholder')} + aria-label={t('repeatersPanel.searchRepeaters')} + className="bg-secondary-dark/80 focus:border-brand-green/50 max-w-[20rem] min-w-[8rem] flex-1 rounded-lg border border-gray-600/50 px-3 py-1.5 text-sm text-gray-200 focus:outline-none" + /> +

{t('repeatersPanel.columnsDataHint')}

- {savedCredentialNodeIds.length > 0 && ( + {savedCredentialEntries.length > 0 && (
{savedPasswordsOpen && (
    - {savedCredentialNodeIds.map((nodeId) => ( + {savedCredentialEntries.map(({ nodeId, kind }) => (
  • - - {resolveRepeaterDisplayName(nodeId)} + + + {kind === 'Room' + ? t('nodeListPanel.meshcoreTypeRoom') + : t('nodeListPanel.meshcoreTypeRepeater')} + + {resolveNodeDisplayName(nodeId, kind)} @@ -735,7 +878,7 @@ export default function RepeatersPanel({
)} - {repeaters.length === 0 ? ( + {infraNodes.length === 0 ? (

{t('repeatersPanel.noRepeatersYet')}

@@ -949,7 +1092,7 @@ export default function RepeatersPanel({ - + {onToggleFavorite ? ( - {storedRepeaterIds.has(node.node_id) ? ( + {savedCredentialEntries.some((e) => e.nodeId === node.node_id) ? ( ) : null} @@ -1270,6 +1424,19 @@ export default function RepeatersPanel({ {t('repeatersPanel.buttonCli')} ))} + {onOpenRoom && node.hw_model === 'Room' ? ( + + ) : null} ); })}

+ {node.hw_model === 'Room' ? ( + { + await handleCliCommand( + node.node_id, + `setperm ${pubkeyHex} ${level}`, + ); + }} + /> + ) : null} {showCliMultiHopHint ? (

{t('repeatersPanel.cliMultiHopHint')}

) : null} + {cliErrorText ? ( +

{cliErrorText}

+ ) : null}
)) )} diff --git a/src/renderer/components/RoomsPanel.test.tsx b/src/renderer/components/RoomsPanel.test.tsx index ccf09f5f8..f329c48fb 100644 --- a/src/renderer/components/RoomsPanel.test.tsx +++ b/src/renderer/components/RoomsPanel.test.tsx @@ -178,7 +178,7 @@ describe('RoomsPanel', () => { }); }); - it('forces admin relogin when managing from a read-only session', async () => { + it('opens Repeater ops for a read-only session without showing Rooms CLI/ACL', () => { const room = makeRoom(0x100c, 'Admin Elevate Room'); const nodes = new Map([[room.node_id, room]]); meshcoreApplyRoomSession(room.node_id, { @@ -186,21 +186,14 @@ describe('RoomsPanel', () => { adminPassword: '', role: 'readonly', }); - const onLoginRoom = vi.fn().mockResolvedValue(undefined); - renderRoomsPanel(nodes, { initialRoomTarget: room.node_id, onLoginRoom }); + const onOpenRepeaterOps = vi.fn(); + renderRoomsPanel(nodes, { initialRoomTarget: room.node_id, onOpenRepeaterOps }); fireEvent.click(screen.getByText('roomsPanel.manageRoom')); - await waitFor(() => { - expect(onLoginRoom).toHaveBeenCalledWith( - room.node_id, - 'password', - expect.objectContaining({ - adminPassword: 'password', - forceRelogin: true, - }), - ); - }); + expect(onOpenRepeaterOps).toHaveBeenCalledWith(room.node_id); + expect(screen.queryByPlaceholderText('roomsPanel.cliPlaceholder')).not.toBeInTheDocument(); + expect(screen.queryByText('roomsPanel.aclPubkeyLabel')).not.toBeInTheDocument(); }); it('shows login form for room B while room A login is in progress', () => { @@ -422,7 +415,25 @@ describe('RoomsPanel', () => { expect(textarea).toHaveValue('retry me'); }); - it('closes manage section when Close is clicked', async () => { + it('Refresh ACL in Members calls get acl', async () => { + meshcoreClearAllRoomSessions(); + const room = makeRoom(0x1010, 'ACL Room'); + const nodes = new Map([[room.node_id, room]]); + meshcoreApplyRoomSession(room.node_id, { + guestPassword: '', + adminPassword: 'password', + role: 'admin', + }); + const onSendRoomAdminCli = vi.fn().mockResolvedValue('aabbccdd 3\n'); + renderRoomsPanel(nodes, { initialRoomTarget: room.node_id, onSendRoomAdminCli }); + fireEvent.click(screen.getByText(/roomsPanel.membersHeading/)); + fireEvent.click(screen.getByLabelText('roomsPanel.membersRefreshAcl')); + await waitFor(() => { + expect(onSendRoomAdminCli).toHaveBeenCalledWith(room.node_id, 'get acl'); + }); + }); + + it('jumps to Repeaters & Rooms ops from Manage without opening CLI drawer', () => { meshcoreClearAllRoomSessions(); const room = makeRoom(0x1009, 'Admin Room'); const nodes = new Map([[room.node_id, room]]); @@ -431,6 +442,7 @@ describe('RoomsPanel', () => { adminPassword: 'password', role: 'admin', }); + const onOpenRepeaterOps = vi.fn(); render( { onLeaveRoom={vi.fn().mockResolvedValue(undefined)} onSendRoomPost={vi.fn()} onSendRoomAdminCli={vi.fn()} + onOpenRepeaterOps={onOpenRepeaterOps} />, ); fireEvent.click(screen.getByText('roomsPanel.manageRoom')); - await waitFor(() => { - expect(screen.getByText('roomsPanel.manageHeading')).toBeInTheDocument(); - }); - fireEvent.click(screen.getByLabelText('roomsPanel.closeManage')); - expect(screen.queryByText('roomsPanel.cliPlaceholder')).not.toBeInTheDocument(); + expect(onOpenRepeaterOps).toHaveBeenCalledWith(room.node_id); + expect(screen.queryByText('roomsPanel.manageHeading')).not.toBeInTheDocument(); + expect(screen.queryByPlaceholderText('roomsPanel.cliPlaceholder')).not.toBeInTheDocument(); }); it('shows delivery status badge on own room posts', () => { diff --git a/src/renderer/components/RoomsPanel.tsx b/src/renderer/components/RoomsPanel.tsx index 4f290dee9..aa76daaf8 100644 --- a/src/renderer/components/RoomsPanel.tsx +++ b/src/renderer/components/RoomsPanel.tsx @@ -26,7 +26,6 @@ import { } from 'react'; import { useTranslation } from 'react-i18next'; -import { useMeshcoreRoomAuth } from '@/renderer/hooks/useMeshcoreRoomAuth'; import { useMeshcoreRoomLoginQueueRevision } from '@/renderer/hooks/useMeshcoreRoomLoginQueueRevision'; import { useMeshcoreRoomSessionRevision } from '@/renderer/hooks/useMeshcoreRoomSessionRevision'; import { useAppWindowActivity } from '@/renderer/lib/appWindowActivity'; @@ -45,7 +44,6 @@ import { ROOM_LOGIN_PROGRESS_DOT } from '@/renderer/lib/connectionHeaderStatus'; import { errLikeToLogString } from '@/renderer/lib/errLikeToLogString'; import { ICON_MD } from '@/renderer/lib/icons/iconClass'; import { useParentIconTrigger } from '@/renderer/lib/icons/iconMotionContext'; -import type { CliHistoryEntry } from '@/renderer/lib/meshcore/meshcoreHookTypes'; import { repairMeshcoreHydrationStaleRoomSends } from '@/renderer/lib/meshcoreDbCacheHydration'; import { type MeshcoreRoomAclEntry, @@ -176,9 +174,8 @@ interface Props { onLeaveRoom: (nodeId: number) => Promise; onSendRoomPost: (nodeId: number, text: string) => Promise; onSendRoomAdminCli: (nodeId: number, command: string) => Promise; - meshcoreCliHistories?: Map; - meshcoreCliErrors?: Map; - onClearCliHistory?: (nodeId: number) => void; + /** Jump to Repeaters & Rooms ops for this room (infrastructure CLI / ACL). */ + onOpenRepeaterOps?: (nodeId: number) => void; onMessageNode?: (nodeNum: number) => void; onToggleFavorite?: (nodeId: number, favorited: boolean) => void; /** Ref for scroll-to-top (Rooms tab inner message stream). */ @@ -253,9 +250,7 @@ export default function RoomsPanel({ onLeaveRoom, onSendRoomPost, onSendRoomAdminCli, - meshcoreCliHistories, - meshcoreCliErrors, - onClearCliHistory, + onOpenRepeaterOps, onMessageNode, onToggleFavorite, scrollToTopRef, @@ -266,7 +261,6 @@ export default function RoomsPanel({ const { t } = useTranslation(); const { inactive: appWindowInactive } = useAppWindowActivity(); const parentIconTrigger = useParentIconTrigger(); - const { ensureRoomAuth, RemoteAuthModal } = useMeshcoreRoomAuth(); const [selectedRoomId, setSelectedRoomId] = useState( () => initialRoomTarget ?? null, ); @@ -277,11 +271,6 @@ export default function RoomsPanel({ const [loginErrorsByRoom, setLoginErrorsByRoom] = useState>(() => new Map()); const [leaveErrorsByRoom, setLeaveErrorsByRoom] = useState>(() => new Map()); const roomSessionRevision = useMeshcoreRoomSessionRevision(); - const [manageOpen, setManageOpen] = useState(false); - const [cliInput, setCliInput] = useState(''); - const [cliPending, setCliPending] = useState(false); - const [aclPubkey, setAclPubkey] = useState(''); - const [aclLevel, setAclLevel] = useState(1); const [rememberPassword, setRememberPassword] = useState(false); const [syncEnabled, setSyncEnabled] = useState(false); const [syncInterval, setSyncInterval] = useState(60); @@ -825,7 +814,6 @@ export default function RoomsPanel({ }); setLoginPassword(MESHCORE_ROOM_DEFAULT_GUEST_PASSWORD); setRememberPassword(false); - setManageOpen(false); loadSyncConfig(nodeId); }, [loadSyncConfig], @@ -1079,10 +1067,6 @@ export default function RoomsPanel({ useEffect(() => { const handleEscape = (e: KeyboardEvent) => { if (e.key !== 'Escape') return; - if (manageOpen) { - setManageOpen(false); - return; - } if (filterSender != null) { setFilterSender(null); return; @@ -1099,7 +1083,7 @@ export default function RoomsPanel({ return () => { document.removeEventListener('keydown', handleEscape); }; - }, [closeSearch, filterSender, manageOpen, showDatePicker, showSearch]); + }, [closeSearch, filterSender, showDatePicker, showSearch]); const starredIdSet = useMemo(() => new Set(starred.map((s) => s.starId)), [starred]); const roomStarred = useMemo( @@ -1220,7 +1204,6 @@ export default function RoomsPanel({ void leaveFn() .then(() => { if (leaveAttemptGenRef.current.get(nodeId) !== gen) return; - setManageOpen(false); setLoginErrorsByRoom((prev) => { if (!prev.has(nodeId)) return prev; const next = new Map(prev); @@ -1255,68 +1238,10 @@ export default function RoomsPanel({ startRoomLeave(selectedRoomId, () => onLeaveRoom(selectedRoomId)); }, [isConnected, onLeaveRoom, selectedRoomId, startRoomLeave]); - const handleAdminLogin = useCallback(async () => { - if (selectedRoomId == null) return; - if (manageOpen) { - setManageOpen(false); - return; - } - const nodeId = selectedRoomId; - const auth = await ensureRoomAuth( - nodeId, - 'admin', - activeRoom?.long_name ?? `Room-${nodeId.toString(16)}`, - ); - if (!auth.ok) return; - const adminPassword = auth.adminPassword.trim(); - const guestPassword = auth.guestPassword.trim(); - if (!adminPassword) { - setLoginErrorsByRoom((prev) => - new Map(prev).set(nodeId, t('roomsPanel.adminPasswordRequired')), - ); - return; - } - startRoomLogin(nodeId, async () => { - await onLoginRoom(nodeId, adminPassword, { - adminPassword, - guestPassword, - forceRelogin: true, - }); - setManageOpen(true); - }); - }, [ - activeRoom?.long_name, - ensureRoomAuth, - manageOpen, - onLoginRoom, - selectedRoomId, - startRoomLogin, - t, - ]); - - const handleCliSend = useCallback(async () => { - if (selectedRoomId == null || !cliInput.trim()) return; - setCliPending(true); - try { - await onSendRoomAdminCli(selectedRoomId, cliInput.trim()); - setCliInput(''); - } catch (e) { - console.warn('[RoomsPanel] admin CLI failed ' + errLikeToLogString(e)); - } finally { - setCliPending(false); - } - }, [cliInput, onSendRoomAdminCli, selectedRoomId]); - - const handleAclSubmit = useCallback( - async (e: React.SubmitEvent) => { - e.preventDefault(); - const normalized = aclPubkey.trim().toLowerCase(); - if (!/^[0-9a-f]{64}$/.test(normalized)) return; - await onSendRoomAdminCli(selectedRoomId!, `setperm ${normalized} ${aclLevel}`); - setAclPubkey(''); - }, - [aclLevel, aclPubkey, onSendRoomAdminCli, selectedRoomId], - ); + const handleOpenRepeaterOps = useCallback(() => { + if (selectedRoomId == null || !onOpenRepeaterOps) return; + onOpenRepeaterOps(selectedRoomId); + }, [onOpenRepeaterOps, selectedRoomId]); const loggedIn = useMemo(() => { void roomSessionRevision; @@ -1363,9 +1288,6 @@ export default function RoomsPanel({ selectedRoomId != null ? (leaveErrorsByRoom.get(selectedRoomId) ?? null) : null; const canPost = selectedRoomId != null && meshcoreRoomCanPost(selectedRoomId); const sessionRole = selectedRoomId != null ? meshcoreGetRoomSession(selectedRoomId)?.role : null; - const cliHistory = - selectedRoomId != null ? (meshcoreCliHistories?.get(selectedRoomId) ?? []) : []; - const cliError = selectedRoomId != null ? meshcoreCliErrors?.get(selectedRoomId) : undefined; const selectedRoomSecretsSummary = selectedRoomId != null ? getMeshcoreRoomSavedSecretsSummary(selectedRoomId) : null; const showLoginSavedSecretsControls = @@ -1378,7 +1300,6 @@ export default function RoomsPanel({ return (
- {RemoteAuthModal} {forgetConfirmNodeId != null && ( {t('chatPanel.starredMessages')} - + {onOpenRepeaterOps ? ( + + ) : null}
@@ -2766,145 +2684,6 @@ export default function RoomsPanel({ /> )}
- - {manageOpen && ( -
-
-

- {t('roomsPanel.manageHeading')} -

- -
-
- { - setCliInput(e.target.value); - }} - onKeyDown={(e) => { - if (e.key === 'Enter') void handleCliSend(); - }} - placeholder={t('roomsPanel.cliPlaceholder')} - disabled={!isConnected || cliPending} - className="min-w-0 flex-1 rounded border border-gray-600 bg-gray-800 px-2 py-1 text-sm text-gray-200" - aria-label={t('roomsPanel.cliPlaceholder')} - /> - - {onClearCliHistory && ( - - )} -
-
- {( - [ - ['get path.hash.mode', 'roomsPanel.pathHashCliGet'], - ['set path.hash.mode 0', 'roomsPanel.pathHashCliSet0'], - ['set path.hash.mode 1', 'roomsPanel.pathHashCliSet1'], - ['set path.hash.mode 2', 'roomsPanel.pathHashCliSet2'], - ] as const - ).map(([cmd, labelKey]) => ( - - ))} -
- {cliError &&

{cliError}

} -
- - - -
-
- {cliHistory.length === 0 ? ( -

{t('roomsPanel.cliEmpty')}

- ) : ( - cliHistory.map((entry, idx) => ( -
- {entry.type === 'sent' ? '> ' : '< '} - {entry.text} -
- )) - )} -
-
- )} )} diff --git a/src/renderer/hooks/meshcore/meshcoreConnSideEffects.test.ts b/src/renderer/hooks/meshcore/meshcoreConnSideEffects.test.ts index 3bb9e6143..735a8efa0 100644 --- a/src/renderer/hooks/meshcore/meshcoreConnSideEffects.test.ts +++ b/src/renderer/hooks/meshcore/meshcoreConnSideEffects.test.ts @@ -7,6 +7,7 @@ import type { MeshCoreConnection, RxPacketEntry, } from '@/renderer/lib/meshcore/meshcoreHookTypes'; +import * as meshcoreRepeaterRpcInFlight from '@/renderer/lib/meshcoreRepeaterRpcInFlight'; import { beginMeshcoreSilentBulkAttempt, resetMeshcoreWaitingMessagesDrainState, @@ -94,13 +95,19 @@ function makeHarness(overrides?: { handleResponseResult?: boolean }): Harness { readNodes: () => getIdentityNodeMap(ID), pendingAcksRef: ref(pendingAcks), processWaitingMessagesRef: ref< - ((options?: { showSyncBanner?: boolean }) => Promise) | null + | ((options?: { + showSyncBanner?: boolean; + force?: boolean; + incrementalOnly?: boolean; + }) => Promise) + | null >(null), pubKeyMapRef: ref(new Map()), pubKeyPrefixMapRef: ref(new Map()), rawPacketsRef: ref(rawPackets), repeaterCommandServiceRef: ref({ handleResponse, + clear: vi.fn(), parseResponseToken: (text: string) => ({ token: null, body: text }), } as never), selfInfoRef: ref(null), @@ -620,6 +627,92 @@ describe('attachMeshcoreConnSideEffects', () => { expect(h.handleConnectionLost).not.toHaveBeenCalled(); }); + it('force drain runs while CLI reply hold would defer silent drain', async () => { + const holdSpy = vi + .spyOn(meshcoreRepeaterRpcInFlight, 'meshcoreCliReplyHoldActive') + .mockReturnValue(true); + const h = makeHarness(); + detach = attachMeshcoreConnSideEffects(h.conn, h.ctx); + + await h.ctx.processWaitingMessagesRef.current?.({ + showSyncBanner: false, + force: true, + incrementalOnly: true, + }); + + expect(h.conn.getWaitingMessages).not.toHaveBeenCalled(); + expect(h.syncNextMessage).toHaveBeenCalled(); + holdSpy.mockRestore(); + }); + + it('non-force silent drain still defers during CLI reply hold', async () => { + vi.useFakeTimers(); + const holdSpy = vi + .spyOn(meshcoreRepeaterRpcInFlight, 'meshcoreCliReplyHoldActive') + .mockReturnValue(true); + const h = makeHarness(); + detach = attachMeshcoreConnSideEffects(h.conn, h.ctx); + + const pending = h.ctx.processWaitingMessagesRef.current?.({ showSyncBanner: false }); + await Promise.resolve(); + expect(h.conn.getWaitingMessages).not.toHaveBeenCalled(); + expect(h.syncNextMessage).not.toHaveBeenCalled(); + + holdSpy.mockReturnValue(false); + await vi.runAllTimersAsync(); + await pending; + expect(h.conn.getWaitingMessages).toHaveBeenCalledTimes(1); + holdSpy.mockRestore(); + }); + + it('incrementalOnly skips bulk getWaitingMessages', async () => { + const h = makeHarness(); + detach = attachMeshcoreConnSideEffects(h.conn, h.ctx); + + await h.ctx.processWaitingMessagesRef.current?.({ + showSyncBanner: false, + force: true, + incrementalOnly: true, + }); + + expect(h.conn.getWaitingMessages).not.toHaveBeenCalled(); + expect(h.syncNextMessage).toHaveBeenCalled(); + }); + + it('force follow-up after in-flight drain starts another force incremental drain', async () => { + vi.useFakeTimers(); + const h = makeHarness(); + let releaseBulk: () => void = () => undefined; + vi.mocked(h.conn.getWaitingMessages).mockImplementation( + () => + new Promise((resolve) => { + releaseBulk = () => { + resolve([]); + }; + }), + ); + detach = attachMeshcoreConnSideEffects(h.conn, h.ctx); + + const first = h.ctx.processWaitingMessagesRef.current?.({ showSyncBanner: false }); + await Promise.resolve(); + expect(h.conn.getWaitingMessages).toHaveBeenCalledTimes(1); + + void h.ctx.processWaitingMessagesRef.current?.({ + showSyncBanner: false, + force: true, + incrementalOnly: true, + }); + // Still coalesced onto the in-flight bulk (no second getWaitingMessages yet). + expect(h.conn.getWaitingMessages).toHaveBeenCalledTimes(1); + + releaseBulk(); + await first; + await vi.runAllTimersAsync(); + // Follow-up force drain uses incrementalOnly — syncNext, not a second bulk. + expect(h.syncNextMessage).toHaveBeenCalled(); + expect(h.conn.getWaitingMessages).toHaveBeenCalledTimes(1); + }); + it('flushes waiting-message node changes to nodeStore without updating the runtime node mirror', async () => { const h = makeHarness(); vi.mocked(h.conn.getWaitingMessages).mockResolvedValue([ diff --git a/src/renderer/hooks/meshcore/meshcoreConnSideEffects.ts b/src/renderer/hooks/meshcore/meshcoreConnSideEffects.ts index 4f27c03ff..35fa43d22 100644 --- a/src/renderer/hooks/meshcore/meshcoreConnSideEffects.ts +++ b/src/renderer/hooks/meshcore/meshcoreConnSideEffects.ts @@ -22,7 +22,9 @@ import type { DeviceLogEntry, MeshCoreConnection } from '../../lib/meshcore/mesh import { createMeshcoreMqttPacketLogBucket } from '../../lib/meshcore/meshcoreMqttPacketLogThrottle'; import { handleMeshcoreRfRx, type MeshcoreRfRxDeps } from '../../lib/meshcore/meshcoreRfRxRuntime'; import { processMeshcoreWaitingMessageItem } from '../../lib/meshcoreProcessWaitingMessageItem'; +import { resetMeshcoreRepeaterRpcInFlightOnDisconnect } from '../../lib/meshcoreRepeaterRpcInFlight'; import { meshcoreSortedStorePrior } from '../../lib/meshcoreStoreDedup'; +import { resetMeshcoreTracePathMultiplexOnDisconnect } from '../../lib/meshcoreTracePathMultiplex'; import { normalizeMeshcoreWaitingMessageBatch, normalizeMeshcoreWaitingMessageItem, @@ -65,11 +67,13 @@ import { MAX_DEVICE_LOGS, meshcoreDmAckKeyU32 } from './meshcoreHookPreamble'; import { getMeshcoreProcessWaitingMessagesInFlight, requestMeshcoreWaitingMessagesFollowUp, + requestMeshcoreWaitingMessagesForceFollowUp, requestMeshcoreWaitingMessagesManualFollowUp, resetMeshcoreProcessWaitingMessagesSync, resetMeshcoreWaitingMessagesSilentFollowUpChain, setMeshcoreProcessWaitingMessagesInFlight, takeMeshcoreWaitingMessagesFollowUp, + takeMeshcoreWaitingMessagesForceFollowUp, takeMeshcoreWaitingMessagesManualFollowUp, } from './meshcoreWaitingMessagesSyncState'; @@ -266,6 +270,7 @@ async function drainWaitingMessagesIncremental( conn: MeshCoreConnection, state: MeshcoreWaitingMessagesDrainState, deps: MeshcoreWaitingMessagesDrainDeps, + syncNextTimeoutMs: number = MESHCORE_SYNC_NEXT_MESSAGE_TIMEOUT_MS, ): Promise { let silentDrainExhaustedCap = false; for (let i = 0; i < MESHCORE_SYNC_NEXT_MESSAGE_MAX_PER_DRAIN; i += 1) { @@ -274,7 +279,7 @@ async function drainWaitingMessagesIncremental( try { raw = await withTimeout( conn.syncNextMessage(), - MESHCORE_SYNC_NEXT_MESSAGE_TIMEOUT_MS, + syncNextTimeoutMs, 'MeshCore syncNextMessage', ); } catch (e: unknown) { @@ -309,9 +314,11 @@ async function drainWaitingMessagesSilent( conn: MeshCoreConnection, state: MeshcoreWaitingMessagesDrainState, deps: MeshcoreWaitingMessagesDrainDeps, + opts?: { incrementalOnly?: boolean; syncNextTimeoutMs?: number }, ): Promise { - if (shouldSkipMeshcoreSilentBulkGetWaitingMessages()) { - const retrieved = await drainWaitingMessagesIncremental(conn, state, deps); + const syncNextTimeoutMs = opts?.syncNextTimeoutMs ?? MESHCORE_SYNC_NEXT_MESSAGE_TIMEOUT_MS; + if (opts?.incrementalOnly || shouldSkipMeshcoreSilentBulkGetWaitingMessages()) { + const retrieved = await drainWaitingMessagesIncremental(conn, state, deps, syncNextTimeoutMs); if (retrieved) noteMeshcoreSilentBulkSuccess(); return; } @@ -383,7 +390,7 @@ async function drainWaitingMessagesSilent( state.progressActive = true; deps.setWaitingMessagesSyncProgress({ processed: 0, total: 0 }); if (!deps.meshcoreHookMountedRef.current) return; - await drainWaitingMessagesIncremental(conn, state, deps); + await drainWaitingMessagesIncremental(conn, state, deps, syncNextTimeoutMs); return; } throw e; @@ -397,7 +404,7 @@ async function drainWaitingMessagesSilent( */ async function runMeshcoreWaitingMessagesDrain( conn: MeshCoreConnection, - options: { showSyncBanner: boolean }, + options: { showSyncBanner: boolean; incrementalOnly?: boolean; syncNextTimeoutMs?: number }, deps: MeshcoreWaitingMessagesDrainDeps, ): Promise { const state: MeshcoreWaitingMessagesDrainState = { @@ -418,7 +425,10 @@ async function runMeshcoreWaitingMessagesDrain( if (options.showSyncBanner) { await drainWaitingMessagesManual(conn, state, deps); } else { - await drainWaitingMessagesSilent(conn, state, deps); + await drainWaitingMessagesSilent(conn, state, deps, { + incrementalOnly: options.incrementalOnly, + syncNextTimeoutMs: options.syncNextTimeoutMs, + }); } } finally { if (silentDrainUiActive) { @@ -589,6 +599,19 @@ export function attachMeshcoreConnSideEffects( const maybeChainWaitingMessageFollowUp = () => { if (!meshcoreHookMountedRef.current) return; + // Force follow-up (CLI reply kicks) must run immediately — scheduleMeshcoreWaitingMessagesDrain + // defers while meshcoreCliReplyHoldActive(), which deadlocks reply delivery. + const forceFollow = takeMeshcoreWaitingMessagesForceFollowUp(); + if (forceFollow) { + void processWaitingMessages({ + showSyncBanner: false, + force: true, + incrementalOnly: forceFollow.incrementalOnly, + }).catch((e: unknown) => { + logMeshcoreWaitingMessagesDrainError('getWaitingMessages error', e, false); + }); + return; + } const manual = takeMeshcoreWaitingMessagesManualFollowUp(); const silent = takeMeshcoreWaitingMessagesFollowUp(); if (!manual && !silent) { @@ -616,7 +639,9 @@ export function attachMeshcoreConnSideEffects( const processWaitingMessages = async (options?: ProcessWaitingMessagesOptions): Promise => { if (getMeshcoreProcessWaitingMessagesInFlight()) { - if (options?.showSyncBanner !== false) { + if (options?.force) { + requestMeshcoreWaitingMessagesForceFollowUp(options.incrementalOnly === true); + } else if (options?.showSyncBanner !== false) { requestMeshcoreWaitingMessagesManualFollowUp(); } else { requestMeshcoreWaitingMessagesFollowUp(); @@ -624,7 +649,7 @@ export function attachMeshcoreConnSideEffects( return getMeshcoreProcessWaitingMessagesInFlight()!; } const showSyncBanner = options?.showSyncBanner !== false; - if (!showSyncBanner && isMeshcoreCompanionDrainDeferred()) { + if (!showSyncBanner && !options?.force && isMeshcoreCompanionDrainDeferred()) { scheduleSilentWaitingMessageDrain(() => processWaitingMessages(options).catch((e: unknown) => { logMeshcoreWaitingMessagesDrainError('getWaitingMessages error', e, false); @@ -644,12 +669,19 @@ export function attachMeshcoreConnSideEffects( setWaitingMessagesCount, setWaitingMessagesSilentDrainActive, }; - const inFlight = runMeshcoreWaitingMessagesDrain(conn, { showSyncBanner }, drainDeps).finally( - () => { - setMeshcoreProcessWaitingMessagesInFlight(null); - maybeChainWaitingMessageFollowUp(); + const inFlight = runMeshcoreWaitingMessagesDrain( + conn, + { + showSyncBanner, + incrementalOnly: options?.incrementalOnly === true, + // CLI reply polls need a short empty-queue timeout so kicks stay inside the CLI window. + syncNextTimeoutMs: options?.incrementalOnly === true ? 3_000 : undefined, }, - ); + drainDeps, + ).finally(() => { + setMeshcoreProcessWaitingMessagesInFlight(null); + maybeChainWaitingMessageFollowUp(); + }); setMeshcoreProcessWaitingMessagesInFlight(inFlight); return inFlight; }; @@ -695,8 +727,9 @@ export function attachMeshcoreConnSideEffects( ) => { const senderId = payload.senderNodeId; const service = repeaterCommandServiceRef.current; + const handled = service ? service.handleResponse(payload.text, senderId) : false; if (service) { - if (service.handleResponse(payload.text, senderId)) return; + if (handled) return; } else { console.warn( '[meshcoreConnSideEffects] CLI response received but no command service active (sender:', @@ -783,6 +816,11 @@ export function attachMeshcoreConnSideEffects( setWaitingMessagesDrainDeferred, ); resetMeshcoreWaitingMessagesDrainSchedule(); + resetMeshcoreRepeaterRpcInFlightOnDisconnect(); + if (staleConn) { + resetMeshcoreTracePathMultiplexOnDisconnect(staleConn); + } + repeaterCommandServiceRef.current?.clear(); if (staleConn && !usedDriverConnect) { void staleConn.close().catch((e: unknown) => { console.debug('[meshcoreConnSideEffects] stale conn close ' + errLikeToLogString(e)); diff --git a/src/renderer/hooks/meshcore/meshcoreConnSideEffectsCtx.ts b/src/renderer/hooks/meshcore/meshcoreConnSideEffectsCtx.ts index 258a4c194..a754a7b1c 100644 --- a/src/renderer/hooks/meshcore/meshcoreConnSideEffectsCtx.ts +++ b/src/renderer/hooks/meshcore/meshcoreConnSideEffectsCtx.ts @@ -19,6 +19,16 @@ import type { PendingDmAckEntry } from './meshcoreHookPreamble'; export interface ProcessWaitingMessagesOptions { /** When false, drain the radio queue without the ChatPanel sync spinner (proactive/periodic). */ showSyncBanner?: boolean; + /** + * When true, run the drain even if companion RF/trace would normally defer silent drains. + * Used while awaiting a 0-hop CLI_DATA reply that arrives as a waiting message. + */ + force?: boolean; + /** + * When true, skip bulk `getWaitingMessages` and only use `syncNextMessage`. + * Required for CLI reply polling — bulk often hangs 45s while CLI times out at 30s. + */ + incrementalOnly?: boolean; } /** diff --git a/src/renderer/hooks/meshcore/meshcoreWaitingMessagesSyncState.test.ts b/src/renderer/hooks/meshcore/meshcoreWaitingMessagesSyncState.test.ts index 6fa2a1250..e5df8e65e 100644 --- a/src/renderer/hooks/meshcore/meshcoreWaitingMessagesSyncState.test.ts +++ b/src/renderer/hooks/meshcore/meshcoreWaitingMessagesSyncState.test.ts @@ -6,11 +6,13 @@ import { getMeshcoreProcessWaitingMessagesInFlight, getMeshcoreWaitingMessagesSilentFollowUpChainCount, requestMeshcoreWaitingMessagesFollowUp, + requestMeshcoreWaitingMessagesForceFollowUp, requestMeshcoreWaitingMessagesManualFollowUp, resetMeshcoreProcessWaitingMessagesSync, resetMeshcoreWaitingMessagesSilentFollowUpChain, setMeshcoreProcessWaitingMessagesInFlight, takeMeshcoreWaitingMessagesFollowUp, + takeMeshcoreWaitingMessagesForceFollowUp, takeMeshcoreWaitingMessagesManualFollowUp, } from './meshcoreWaitingMessagesSyncState'; @@ -21,6 +23,21 @@ describe('meshcoreWaitingMessagesSyncState follow-up chaining', () => { resetMeshcoreWaitingMessagesSilentFollowUpChain(); }); + it('force follow-up survives in-flight coalesce and is taken once', () => { + expect(takeMeshcoreWaitingMessagesForceFollowUp()).toBeNull(); + setMeshcoreProcessWaitingMessagesInFlight(Promise.resolve()); + requestMeshcoreWaitingMessagesForceFollowUp(true); + expect(takeMeshcoreWaitingMessagesForceFollowUp()).toEqual({ incrementalOnly: true }); + expect(takeMeshcoreWaitingMessagesForceFollowUp()).toBeNull(); + }); + + it('force follow-up is cleared by clearMeshcoreWaitingMessagesFollowUp', () => { + setMeshcoreProcessWaitingMessagesInFlight(Promise.resolve()); + requestMeshcoreWaitingMessagesForceFollowUp(true); + clearMeshcoreWaitingMessagesFollowUp(); + expect(takeMeshcoreWaitingMessagesForceFollowUp()).toBeNull(); + }); + it('requests follow-up only while a drain is in flight', () => { requestMeshcoreWaitingMessagesFollowUp(); expect(takeMeshcoreWaitingMessagesFollowUp()).toBe(false); @@ -88,6 +105,7 @@ describe('meshcoreWaitingMessagesSyncState follow-up chaining', () => { setMeshcoreProcessWaitingMessagesInFlight(Promise.resolve()); requestMeshcoreWaitingMessagesFollowUp(); + requestMeshcoreWaitingMessagesForceFollowUp(true); resetMeshcoreProcessWaitingMessagesSync( setWaitingMessagesCount, @@ -99,8 +117,14 @@ describe('meshcoreWaitingMessagesSyncState follow-up chaining', () => { expect(getMeshcoreProcessWaitingMessagesInFlight()).toBeNull(); expect(takeMeshcoreWaitingMessagesFollowUp()).toBe(false); + expect(takeMeshcoreWaitingMessagesForceFollowUp()).toBeNull(); expect(getMeshcoreWaitingMessagesSilentFollowUpChainCount()).toBe(0); expect(setWaitingMessagesSilentDrainActive).toHaveBeenCalledWith(false); expect(setWaitingMessagesDrainDeferred).toHaveBeenCalledWith(false); + + // After reset, a new force follow-up must not inherit incrementalOnly from before reset. + setMeshcoreProcessWaitingMessagesInFlight(Promise.resolve()); + requestMeshcoreWaitingMessagesForceFollowUp(false); + expect(takeMeshcoreWaitingMessagesForceFollowUp()).toEqual({ incrementalOnly: false }); }); }); diff --git a/src/renderer/hooks/meshcore/meshcoreWaitingMessagesSyncState.ts b/src/renderer/hooks/meshcore/meshcoreWaitingMessagesSyncState.ts index 540dea0c3..cf25ce37e 100644 --- a/src/renderer/hooks/meshcore/meshcoreWaitingMessagesSyncState.ts +++ b/src/renderer/hooks/meshcore/meshcoreWaitingMessagesSyncState.ts @@ -5,6 +5,9 @@ import { MESHCORE_WAITING_MESSAGES_SILENT_FOLLOW_UP_CHAIN_MAX } from '../../lib/ let processWaitingMessagesInFlight: Promise | null = null; let processWaitingMessagesFollowUpRequested = false; let processWaitingMessagesManualFollowUpRequested = false; +/** CLI force-kick coalesced onto an in-flight drain — must bypass reply-hold deferral. */ +let processWaitingMessagesForceFollowUpRequested = false; +let processWaitingMessagesForceFollowUpIncremental = false; /** Silent follow-ups taken in the current 131 drain chain (reset when chain settles). */ let silentFollowUpChainCount = 0; @@ -50,9 +53,34 @@ export function takeMeshcoreWaitingMessagesManualFollowUp(): boolean { return requested; } +/** + * CLI reply path: when a force drain coalesces onto an in-flight drain, schedule another + * force+optional-incremental drain that runs immediately when the current one settles — + * without going through scheduleMeshcoreWaitingMessagesDrain (which defers while CLI hold). + */ +export function requestMeshcoreWaitingMessagesForceFollowUp(incrementalOnly = false): void { + if (!processWaitingMessagesInFlight) return; + processWaitingMessagesForceFollowUpRequested = true; + if (incrementalOnly) { + processWaitingMessagesForceFollowUpIncremental = true; + } +} + +export function takeMeshcoreWaitingMessagesForceFollowUp(): { + incrementalOnly: boolean; +} | null { + if (!processWaitingMessagesForceFollowUpRequested) return null; + processWaitingMessagesForceFollowUpRequested = false; + const incrementalOnly = processWaitingMessagesForceFollowUpIncremental; + processWaitingMessagesForceFollowUpIncremental = false; + return { incrementalOnly }; +} + export function clearMeshcoreWaitingMessagesFollowUp(): void { processWaitingMessagesFollowUpRequested = false; processWaitingMessagesManualFollowUpRequested = false; + processWaitingMessagesForceFollowUpRequested = false; + processWaitingMessagesForceFollowUpIncremental = false; } /** Reset the silent follow-up chain counter when a drain settles with no further work. */ @@ -78,6 +106,8 @@ export function resetMeshcoreProcessWaitingMessagesSync( processWaitingMessagesInFlight = null; processWaitingMessagesFollowUpRequested = false; processWaitingMessagesManualFollowUpRequested = false; + processWaitingMessagesForceFollowUpRequested = false; + processWaitingMessagesForceFollowUpIncremental = false; silentFollowUpChainCount = 0; setWaitingMessagesCount(0); setWaitingMessagesSyncActive(false); diff --git a/src/renderer/hooks/useMeshcoreRepeaterRemoteAuth.test.tsx b/src/renderer/hooks/useMeshcoreRepeaterRemoteAuth.test.tsx index 5adeb5f1d..342fcd7f8 100644 --- a/src/renderer/hooks/useMeshcoreRepeaterRemoteAuth.test.tsx +++ b/src/renderer/hooks/useMeshcoreRepeaterRemoteAuth.test.tsx @@ -2,13 +2,21 @@ import { fireEvent, render, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { useState } from 'react'; import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { axe } from 'vitest-axe'; -import { mergeAppSetting } from '@/renderer/lib/appSettingsStorage'; +import { hydrateAxeThemeColors } from '@/renderer/lib/a11yTestHelpers'; +import { APP_SETTINGS_STORAGE_KEY, mergeAppSetting } from '@/renderer/lib/appSettingsStorage'; +import { clearAllRoomEphemeralAdminPasswords } from '@/renderer/lib/meshcoreInfraAdminSecrets'; import { meshcoreRepeaterCredentialSettingForNode } from '@/renderer/lib/meshcoreRepeaterCredentialStorage'; import { clearAllMeshcoreRepeaterEphemeralPasswords, setMeshcoreRepeaterEphemeralPassword, } from '@/renderer/lib/meshcoreRepeaterSession'; +import { + getMeshcoreRoomCredential, + meshcoreRoomCredentialSettingForNode, + setMeshcoreRoomCredential, +} from '@/renderer/lib/meshcoreRoomCredentialStorage'; import { useMeshcoreRepeaterRemoteAuth } from './useMeshcoreRepeaterRemoteAuth'; @@ -27,10 +35,12 @@ vi.mock('../components/Toast', () => ({ function RepeaterAuthProbe({ nodeId, repeaterName, + hwModel, onAuthed, }: { nodeId: number; repeaterName: string; + hwModel?: string; onAuthed?: () => void; }) { const { ensureRepeaterAuth, RemoteAuthModal } = useMeshcoreRepeaterRemoteAuth(); @@ -41,7 +51,7 @@ function RepeaterAuthProbe({