diff --git a/AGENTS.md b/AGENTS.md index d06ebd3a7..2aeb9b128 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -118,7 +118,7 @@ Adding a cross-boundary feature: **Local Linux CI (optional):** Container mode — `act:ci`, `act:tests`, `act:pr`, … (needs a Docker-compatible engine + act; Podman preferred). Host mode — `act:ci:native`, `act:tests:native`, … (no container engine). See [docs/ci-cd.md](docs/ci-cd.md). macOS/Windows packaging uses native `dist:mac` / `dist:win`. **`dist:mac`** / **`dist:mac:publish`** always run **`scripts/verify-mac-packaging.mjs`** (ZIP + DMG symlink asserts, no raw `.app` CI uploads). macOS signing env (`CSC_LINK`, `CSC_KEY_PASSWORD`, `APPLE_ID`, `APPLE_APP_SPECIFIC_PASSWORD`, `APPLE_TEAM_ID`, `CSC_IDENTITY_AUTO_DISCOVERY`) is scoped to **`macos-latest`** jobs in `release.yaml` / `build.yaml`; partial-secret validation fails the release job when `CSC_LINK` is set but notarization secrets are missing. -> **Update script sync:** When adding or removing packages from `patchedDependencies` in `pnpm-workspace.yaml`, keep `WATCH_ENTRIES` in `scripts/update.sh` in sync so the script warns on version changes to every patched dependency. When adding or removing Ratspeak overlays under `reticulum-sidecar/patches/`, keep `RATSPEAK_PATCH_ENTRIES` in `scripts/update.sh` (`check_ratspeak_patches`) in sync — `pnpm run update` queries upstream PRs (rsReticulum / rsLXMF) and warns when a local overlay can be removed. It also runs `check_ratspeak_upstream` (watched releases for rsLXST / lrgp-rs / Ratspeak / LXMFace, plus new `ratspeak` org repos) — keep `RATSPEAK_RELEASE_WATCH_ENTRIES` / `RATSPEAK_KNOWN_ORG_REPOS` in sync when adopting libs. `scripts/clone-ratspeak-stack.sh` floats **rsReticulum** / **rsLXMF** / **rsNomad** / **rsLXST** / **lrgp-rs** to `origin/main` (override with `RS_RETICULUM_REF` / `RS_LXMF_REF` / `RS_NOMAD_REF` / `RS_LXST_REF` / `RS_LRGP_REF`); overlays must apply or the clone fails. Ratspeak release watch uses stub-kind `games-parity` to nudge Games tab review (`docs/reticulum-games-parity.md`). Peer default avatars use vendored **LXMFace** (`src/renderer/lib/reticulum/lxmface.ts`). `pnpm run update` also runs `rustup update` (or Homebrew `rust` on macOS without rustup) and `cargo build` in `reticulum-sidecar/` when `cargo` is on `PATH` (full-feature build includes `nomad-core` / rsNomad). +> **Update script sync:** When adding or removing packages from `patchedDependencies` in `pnpm-workspace.yaml`, keep `WATCH_ENTRIES` in `scripts/update.sh` in sync so the script warns on version changes to every patched dependency. When adding or removing Ratspeak overlays under `reticulum-sidecar/patches/`, keep `RATSPEAK_PATCH_ENTRIES` in `scripts/update.sh` (`check_ratspeak_patches`) in sync — `pnpm run update` queries upstream PRs (rsReticulum / rsLXMF) and warns when a local overlay can be removed. It also runs `check_ratspeak_upstream` (watched **published** releases for rsLXST / lrgp-rs / Ratspeak vs `reviewed-ref` pins, plus new `ratspeak` org repos) — keep `RATSPEAK_RELEASE_WATCH_ENTRIES` / `RATSPEAK_KNOWN_ORG_REPOS` in sync when adopting libs. LXMFace is not a published-release watch: its baseline is a vendored-file commit (`file:js/lxmface.js@`) compared with the latest GitHub commit that touched that file. `scripts/clone-ratspeak-stack.sh` floats **rsReticulum** / **rsLXMF** / **rsNomad** / **rsLXST** / **lrgp-rs** to `origin/main` (override with `RS_RETICULUM_REF` / `RS_LXMF_REF` / `RS_NOMAD_REF` / `RS_LXST_REF` / `RS_LRGP_REF`); overlays must apply or the clone fails. Ratspeak release watch uses stub-kind `games-parity` to nudge Games tab review when a published release is newer than the pin (`docs/reticulum-games-parity.md`). Peer default avatars use vendored **LXMFace** (`src/renderer/lib/reticulum/lxmface.ts`). `pnpm run update` also runs `rustup update` (or Homebrew `rust` on macOS without rustup) and `cargo build` in `reticulum-sidecar/` when `cargo` is on `PATH` (full-feature build includes `nomad-core` / rsNomad). **Pre-commit hook order:** diff --git a/README.md b/README.md index ff3089811..1b1202ab2 100644 --- a/README.md +++ b/README.md @@ -394,6 +394,7 @@ Architecture and API: [docs/reticulum.md](docs/reticulum.md). Games wire parity: - **Map tiles; OpenStreetMap Referer requirement**: Packaged desktop builds load the UI from the local filesystem. The main process now loads the renderer with an explicit HTTP referrer so OpenStreetMap tile requests include a valid `Referer` header and comply with the [tile usage policy](https://operations.osmfoundation.org/policies/tiles/). If you point the app at a different tile server, ensure its usage policy permits this client. - **Reticulum — no LoRa companion parity**: Reticulum does not use Meshtastic/MeshCore `ConnectionDriver`, MQTT hybrid, channel pills, Rooms BBS, or Hop Goblins diagnostics. The **Chat** tab is **DM-only**; hub room chat lives on the **RRC** tab. Interface add/edit/delete updates config on disk — **restart the stack** after changes under `rns-stack`. - **Reticulum — sidecar license**: The spawned `mesh-client-reticulum` binary is **AGPL-3.0** (separate process from the MIT Electron shell). See [docs/reticulum.md](docs/reticulum.md) and [docs/credits.md](docs/credits.md#bundled-binaries). +- **Graph / Topology visible-node cap**: Meshtastic and MeshCore **Graph** and Reticulum **Topology** render at most **400** nodes after hop filters (force-layout budget). Numeric **Max hops** is applied even when Show distant is off. Unknown hops are omitted unless Max hops is **All hops** and Show distant is on (they are not 1-hop neighbors). The nearby hop ceiling (Mesh hops > 1, Reticulum hops > 2) applies only when Max hops is **All hops**. Reticulum Topology can also filter **RF only** (RNode / KISS / BLE; hides TCP/I2P/Auto). Reticulum path-table ingest is a separate layer (renderer feed **800**, sidecar **2,000**). - **Reticulum — propagation required for offline peers**: LXMF send fails with `no_propagation_node` when the destination is not in the path table and no cascade candidates exist (enabled remotes or local-prop). Local inbox Completes (`stored_locally`) ≠ peer delivery at a remote PN. When a path exists, Direct is tried first; on Direct fail the sidecar cascades preferred remote → other enabled remotes (hop-sorted) → local-prop last. --- diff --git a/docs/agents/meshcore-repeaters.md b/docs/agents/meshcore-repeaters.md index 893acbb37..096aa6332 100644 --- a/docs/agents/meshcore-repeaters.md +++ b/docs/agents/meshcore-repeaters.md @@ -16,7 +16,7 @@ MeshCore firmware **serializes traceroutes** — one active trace cycle per RF l - **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. +- **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. - **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 4c1f82d5c..0ae3f3315 100644 --- a/docs/agents/meshcore-rooms.md +++ b/docs/agents/meshcore-rooms.md @@ -6,5 +6,6 @@ Deep subsystem reference for AI assistants. Open this when a task touches MeshCo - **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`. Connect auto-login skips rooms with `getMeshcoreRoomAutoLoginFailure`. Timeouts in `timeConstants.ts` (shorter for TCP / 0-hop). +- **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`. diff --git a/docs/agents/reticulum.md b/docs/agents/reticulum.md index ee6260a32..d18002ba2 100644 --- a/docs/agents/reticulum.md +++ b/docs/agents/reticulum.md @@ -16,11 +16,12 @@ Deep subsystem reference for AI assistants. Open this when a task touches the Re - **LXMF replies:** sidecar stamps `FIELD_REPLY_TO` / capped `FIELD_REPLY_QUOTE` before sign; renderer ingest/Chat use `reticulum_reply_to_hash` + quote preview + jump-by-hash - **RNode flasher timeouts:** `RNODE_COMMAND_TIMEOUT_MS` (30 s serial), `RNODE_BT_PAIRING_TIMEOUT_MS` (90 s BLE pairing), `ESP32_FLASH_STALL_TIMEOUT_MS` / `NRF52_DFU_STALL_TIMEOUT_MS` (60 s no-progress → `ESP32_FLASH_STALLED` / `NRF52_DFU_STALLED`); humanized via `flasherErrorHumanize.ts` - **Peer aliases / History vs Contacts:** LXMF/Nomad announce names overlay path-table peers; SQLite `reticulum_destinations.last_heard` = History, `is_contact` = Contacts (Save as contact only — inbound/outbound LXMF does **not** auto-add Contacts; sidecar `/contacts` wire rows are History hints unless SQLite `is_contact=1`); default avatars via vendored LXMFace (`lib/reticulum/lxmface.ts`); renderer refresh + `reticulumContactToNodeRecordPreservingLabel` refuse hash-prefix wipes of Chat/`nodeStore` labels; ingest stamps History via `persistReticulumHistoryFromPayload` + `stampHistoryPeer`; SQL upsert guard preserves real names over hash-prefix aliases; destination upsert requires exact 32-hex (lowercase) and omits `favorited` on icon-only patches so favorites/icons survive path/probe refresh +- **RRC `/who`:** `RrcPanel` sends hub-global `/who ` with **no `K_ROOM`** so rrcd does not treat it as room chat; never `/who` synthetic `[hub]` / `@dm` rooms. Empty inbound `K_ROOM` → `[hub]` (`resolveRrcInboundChatRoom` / `RRC_HUB_STREAM_ROOM`), never the focused room. First `/who` NOTICE per named room may appear in the transcript (`consumeWhoTranscriptSlot` / `shouldShowRrcWhoTranscript`); later snapshots update the nicklist only. User-initiated Refresh / composer `/who` bypasses that slot. - **Stores/lib:** `reticulumIdentityStore.ts` (session-global sidecar identity status shared by `useReticulumSidecarApi` — distinct from identity-scoped `identityStore`), `reticulumPeerStore.ts` (path-table `peers` + `history` + saved `contacts`; soft-TTL reads, forced `?refresh=1`, incremental `peers_updated` route-field patches, 50ms batching, name/appearance preservation, 30s/60s large-mesh poll), `reticulumDiscoveryMapStore.ts`, `reticulumRmapDiscovery.ts`, `reticulumDiscoveryMapLayout.ts`, `nomadNetworkStore.ts`, `rrcHubStore.ts` / `rrcSessionStore.ts` (RRC hubs + multi-hub sessions; hydrate/clear room history via `rrcRoomHistory.ts`; persist → SQLite `rrc_messages` via `rrcMessagePersist.ts` + `ipc/rrc-db-handlers.ts`; prefs in `rrcHubPrefs` / `rrcRoomPrefs` / `rrcRecentRooms`; notifications in `rrcInactiveNotifications` / `rrcMention`); **Remote (rnsh/rncp):** `rncpTransferStore.ts`, `rnshSessionStore.ts`, `reticulumInboundPolicyStore.ts`, `reticulumRemoteAddressStore.ts`, `rncpEnableRequestStore.ts` + lib `remoteSettingsStorage.ts`, `pushRncpListenerPolicy.ts`, `rncpInboundPolicyLists.ts`, `sendRncpRequestEnable.ts`, `rncpRequestEnableRateLimit.ts`, `applyRncpReceiveDestShare.ts` / `rncpReceiveDestSharePending.ts` (mark pending on request-enable; consume on ingest within TTL), `hooks/useRemotePathCapability.ts`, `components/remote/*`; WS events `rmap.discovery`, `lxmf_outbound_status`, `nomadnetwork.node`, `rrc.*`, `rnsh.*` / `rncp.*` in `useReticulumRuntime` (sidecar also emits `nomad.serving_start` / `nomad.serving_stop`; renderer polls serving status via HTTP, not those WS events) - **LXMF outbound delivery:** sidecar `lxmf_delivery.rs` / `lxmf_outbound.rs` / `pn_cascade.rs` (Direct-first; after Direct exhausts **multi-PN cascade**: preferred remote → other enabled remotes hop-sorted → in **Auto** only, up to 3 heard-but-not-added Discovered PNs hop-sorted → local-prop last; intermediate WS `sending` + `delivery_method: "propagated"` or `"stored_locally"`; terminal `delivered` at remote PN vs `stored_locally` for local hosted PN). **Local-prop** is a full PN (in-process cascade deposit via `accept_stamped_propagated_blob`; host peer `/offer` sync; auto Chat drain after peer ingress + post-peer silent `/get`; explicit local Sync via `drain_local_inbox`) — not an outbox; clients need not Prefer you. Propagated **link establishment timeout** advances the cascade when other PNs remain (avoids Prefer-hash timeout storms). Sync vs deposit: `PROPAGATION_SYNC_OUTBOUND_BUSY` / `PN_DEPOSIT_DEFER_ADVANCE_AFTER`. Renderer `applyReticulumOutboundDeliveryStatus.ts` (WS `lxmf_outbound_status` → Zustand + SQLite `delivery_status` + `delivery_method`; early-status buffer; hash/status allowlist), `reticulumOutboundFailureBridge.ts` (`shouldApplyLinkDeliveryTimeoutFailureBridge` skips the link-timeout Failed bridge when cascade capacity remains — remote **or** enabled local-prop; also skips `propagated` / `stored_locally` rows so cascade is not killed), `markStaleReticulumOutbound.ts`. Optimistic pending rows use `reticulum-pending-*`; send-path rekey passes `replaces_message_hash` on SQLite upsert to delete the prior pending hash. Remote PN Completes UI: **Stored at propagation node** (`ReticulumMessageStatusBadge` PN + green check); local-prop Completes: deposited on your hosted node (PN + amber house; peer sync may still propagate). Mode Off has no cascade capacity, so the link-timeout bridge fails the row. **Paper exception:** `createReticulumPaperMessage` / paper create Completes immediately (`delivery_method: paper`, `ReticulumMessageStatusBadge` **Paper**) via `lxmf_message` — no `lxmf_outbound_status`; shared `reticulumMessageTransport` / `reticulumPaperErrors` keep IPC allowlists and i18n codes aligned. - **DM path reachability:** `useReticulumDmPathProbe.ts`, `reticulumDmPathReachability.ts`, `ReticulumDmPathReachabilityBadge.tsx` — Chat **Probe** matches Peer List (sidecar running check → `/probe` → toast → refresh); `applyProbeResult(forHash, …)` applies the settle without a second `/probe` and ignores stale completions after DM switch; manual reprobe forces Checking… even when passive hops look reachable; Peers virtualizes above 100 rows via `reticulumPeerListRows.ts`; peer refresh policy in `reticulumSidecarPeerRefreshEvents.ts` - **Inbound transport labels:** `received_via` resolves the path-table interface name against local interface config type, so a TCP hub display name still renders as TCP. -- **Topology:** `via_hash` is an immediate transport id; sidecar synthesizes missing relay nodes. `ReticulumTopologyPanel` uses force layout; sidecar caps graph input at 2,000 peers and renderer caps visible peers at 800 (grid repulsion above 400). +- **Topology:** `via_hash` is an immediate transport id; sidecar synthesizes missing relay nodes. `ReticulumTopologyPanel` uses force layout; sidecar caps graph input at 2,000 peers, renderer ingest 800, drawn graph 400 after hop filters (same as LoRa Graph). Unknown hops only when All hops + Show distant. **RF only** checkbox keeps RNode/KISS/BLE spokes. - **Retention:** App defaults Reticulum destination age/count pruning to 30 days / 10,000 destinations (favorites preserved; count max 50,000); Reticulum message retention independently enabled at 4,000. RRC room history retention independently enabled by default at **10,000** messages (30-day age prune) via `rrcMessageRetention*` settings and `db:pruneRrcMessagesByCount` / `db:pruneRrcMessagesByAge`. - **Self label / header:** `reticulumSelfNodeLabel.ts` (`resolveReticulumSelfHeaderLabel` — Network display name in app header) - **Nomad errors:** `lib/nomad/nomadPageErrorHumanize.ts` (sidecar error codes → i18n); LinkClient Nomad overlay in `reticulum-sidecar/patches/` diff --git a/docs/ci-cd.md b/docs/ci-cd.md index 3e94d3662..ea6ff68da 100644 --- a/docs/ci-cd.md +++ b/docs/ci-cd.md @@ -157,7 +157,8 @@ Automated dependency updates are configured in `.github/dependabot.yml`: - **Open PRs:** `open-pull-requests-limit: 0` — Dependabot scans but does **not** open PRs. Dependency bumps are applied manually via `pnpm run update` (`scripts/update.sh`), which also runs dedupe, Ratspeak overlay PR checks, and an upstream release / new-org-repo watch - (rsLXST, lrgp-rs, Ratspeak with Games-parity nudge, LXMFace). Sibling **rsReticulum** / + (rsLXST, lrgp-rs, Ratspeak Games-parity when a newer published release exists, LXMFace + `js/lxmface.js` commit). Sibling **rsReticulum** / **rsLXMF** / **rsNomad** / **rsLXST** / **lrgp-rs** float to `origin/main` via `clone-ratspeak-stack.sh` (overlays must apply). See AGENTS.md §6. diff --git a/docs/diagnostics.md b/docs/diagnostics.md index 957427a22..6f6e25005 100644 --- a/docs/diagnostics.md +++ b/docs/diagnostics.md @@ -9,7 +9,7 @@ This document is the authoritative reference for every diagnostic output in Mesh - **NodeListPanel**: inline anomaly badges, redundancy `+N` echo count, MQTT-only node dimming, Node Health Score badge, JSON export - **MapPanel**: channel utilization halos, routing anomaly aura circles - **RF Histograms panel**: SNR, RSSI, and hop-count bar charts across all nodes -- **Peer Graph panel**: SVG force-directed graph of directly connected nodes (hops 0–1) — **Meshtastic and MeshCore only**; Reticulum uses the **Topology** tab instead +- **Peer Graph panel**: SVG force-directed graph of mesh peers with hop filters — **Meshtastic and MeshCore only**; Reticulum uses the **Topology** tab instead (same 48/400 visible-node cap) All three protocols share one **Diagnostics** sidebar tab; sections differ by `ProtocolCapabilities` (see **Multi-protocol tab scoping** below). @@ -416,9 +416,10 @@ Data is read directly from the node store; no additional telemetry required. Accessible via the graph icon in the sidebar. -SVG force-directed graph of nodes within direct reach (hops 0–1 from the connected device). +SVG force-directed graph of Meshtastic and MeshCore peers (Reticulum uses the **Topology** tab with the same hop controls and visible-node cap). -- Only nodes with `hops_away` 0 or 1 are included; avoids O(n²) edge explosion on large meshes +- **Show distant peers** (Peer Graph default **off**; Topology default **on**) and **Max hops** (Peer Graph default **2**; Topology default **All hops**) filter the node set first. Numeric **Max hops** is applied even when Show distant is off. Unknown hops are omitted unless Max hops is **All hops** and Show distant is on. The nearby hop ceiling (Mesh hops > 1, Reticulum hops > 2) applies only when Max hops is **All hops**. 1-hop Mesh nodes are not distant. +- Layout budget (known limitation): at most **400** nodes after hop filters (`FORCE_REPULSION_FULL_PAIR_CAP`). The toolbar states this limit; when the cap hides nodes the status reads “limit 400” - Both Meshtastic and MeshCore use the `hops_away` field for edge inference - Nodes are clickable; clicking opens NodeDetailModal for that node - Layout uses D3-style force simulation; positions stabilize after initial render diff --git a/docs/reticulum-games-parity.md b/docs/reticulum-games-parity.md index 86184904b..b234dce59 100644 --- a/docs/reticulum-games-parity.md +++ b/docs/reticulum-games-parity.md @@ -5,7 +5,7 @@ Living matrix for [issue #773](https://github.com/Colorado-Mesh/mesh-client/issu - `crates/ratspeak-tauri/src/commands/games.rs` - `dashboard/static/js/games_tab.js` -Update this file when Games PRs land. `pnpm run update` warns on new Ratspeak releases with stub-kind `games-parity`. +Update this file when Games PRs land. `pnpm run update` warns only when a **published** Ratspeak GitHub Release is newer than the `reviewed-ref` pin on the `games-parity` entry in `scripts/update.sh` (bump that pin after review). Tags, `main`, and RCs without a GitHub Release are ignored. **Last review:** 2026-08-08 (Ratspeak v1.0.25 — no Games protocol/API delta, CSS-only; added win confetti; LXMF `FIELD_REACTION` 0x40 interop for Chat tapbacks). Prior: 2026-08-06 (delivery_state / envelope persist / optimistic board / Chess promotion+claims). diff --git a/docs/reticulum.md b/docs/reticulum.md index 3db03ed62..c7679903b 100644 --- a/docs/reticulum.md +++ b/docs/reticulum.md @@ -135,7 +135,7 @@ The **Map** tab shows **local** RMAP v4 discovery data — interfaces your stack **Publish settings (Network → RMAP v4 discovery):** announce interval **60–1440 min** (default **360**); optional height (meters) and `reachable_on` (max 256 chars). LoRa/BLE publish auto-enables `enable_transport` and the **`rmap.world:4242`** hub. Stack restart confirm after enabling publish. -**Performance / memory:** Renderer mirrors discovery rows in `reticulumDiscoveryMapStore` (in-memory only; capped at **2,000** newest rows with client-side 7-day `last_heard` eviction). Peers tab opens use the sidecar’s soft cached peer read; manual **Refresh** forces live `GET /api/v1/peers?refresh=1`. Path-table peers apply **incremental** `peers_updated` / announce patches (50ms batch); full dumps run on connect, manual Refresh, stack restart, exceptional `peers_updated` events, and a slow safety poll (30s, or 60s above 2,000 peers). In-memory hard ceiling **100,000**; App tab destination cap defaults to **10,000** (max **50,000**) and age prune for SQLite contact meta. The sidecar selects at most **2,000** peers before topology graph construction; the renderer renders at most **800**, and its force layout uses grid repulsion above **400** nodes. Topology auto-refresh pauses above the large-mesh threshold. Leaflet uses `preferCanvas`; tile layer `keepBuffer={1}`. Stores clear on disconnect and unexpected sidecar stop. +**Performance / memory:** Renderer mirrors discovery rows in `reticulumDiscoveryMapStore` (in-memory only; capped at **2,000** newest rows with client-side 7-day `last_heard` eviction). Peers tab opens use the sidecar’s soft cached peer read; manual **Refresh** forces live `GET /api/v1/peers?refresh=1`. Path-table peers apply **incremental** `peers_updated` / announce patches (50ms batch); full dumps run on connect, manual Refresh, stack restart, exceptional `peers_updated` events, and a slow safety poll (30s, or 60s above 2,000 peers). In-memory hard ceiling **100,000**; App tab destination cap defaults to **10,000** (max **50,000**) and age prune for SQLite contact meta. The sidecar selects at most **2,000** peers before topology graph construction; the renderer feeds at most **800** path-table rows into Topology after hop filters (and RF-only when enabled). The **drawn** graph uses the same visible-node cap as Meshtastic/MeshCore Graph: **400** after hop filters (force layout switches to grid repulsion above **400**). Topology also has an **RF only** filter (RNode / KISS / BLE RNode / BLE Peer; hides TCP / I2P / Auto hubs and their peers). Hop and RF-only filters run before the 800 last-seen ingest slice. Topology auto-refresh pauses above the large-mesh threshold. Leaflet uses `preferCanvas`; tile layer `keepBuffer={1}`. Stores clear on disconnect and unexpected sidecar stop. ### App → Retention & limits @@ -297,7 +297,7 @@ IRC-style multi-pane client (`RrcPanel` + `rrcHubStore` / `rrcSessionStore`): - Discover hubs from announces, connect by hash, or favourite hubs (Nomad-style). Soft cap **8** concurrent hub sessions. - Headless hub **auto-connect** (`useRrcStartupAutoConnect`, mounted from App): polls ~**500 ms** while any preferred hub is still pending (waiting for live attach / first link), then ~**4 s** steady; also wakes immediately on `RETICULUM_CONFIGURED_EVENT`. -- Per-hub rooms, nicklists (`/who`), topics, slash commands (`/help`, `/join`, `/part`, `/list`, `/msg`, …). Hub and room **auto-join** prefs in localStorage. +- Per-hub rooms, nicklists (`/who`), topics, slash commands (`/help`, `/join`, `/part`, `/list`, `/msg`, …). Hub and room **auto-join** prefs in localStorage. Hidden `/who` omits `K_ROOM` (hub-global command). Notices with empty `K_ROOM` land in `[hub]`, not the focused room. The first `/who` snapshot may appear in the named room; later snapshots update the nicklist only. - **Per-peer DMs (`@`):** `/msg NICK text` opens an IRC-style query tab for that peer. Wire delivery is a direct NOTICE with `K_DST` and no `K_ROOM` / room JOIN — this requires the hub to advertise **`CAP_DIRECT_NOTICE`** (`capabilities.direct_notice`). When the hub does not advertise that capability, `/msg` and plain replies in a DM tab show `rrc.directNoticeUnsupported` and do not send. Sidebar/header show the nick. Leave closes that DM locally; open DMs persist in localStorage until left. Legacy `[whispers]` inbox is migrated best-effort into per-peer rooms. - Chat virtualization pins to the bottom while reading live traffic; **Jump to latest** appears when scrolled up; leaving/re-entering RRC restores the prior scroll pin when possible (`RrcChatView` + TanStack Virtual). - Unintended link drops enter **reconnecting** (backoff 2–30 s), preserve desired rooms (including join keys), and rejoin after WELCOME. Explicit **Disconnect** / **Cancel** clears that hub (`will_reconnect: false`). diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index e2faa861c..edbac95d3 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -1759,6 +1759,14 @@ Legacy SQLite rows could cross-contaminate the shared `nodes` table before proto **Fix**: Foreign-LoRa overhear tables render on the **Meshtastic** and **MeshCore** Diagnostics tabs (keyed by that protocol’s self node id). Reticulum RNode promiscuous foreign LoRa is not implemented (sidecar tap exposes parsed RNS frames only). +### Graph or Topology does not show all nodes + +**Symptoms**: MeshCore/Meshtastic **Graph** or Reticulum **Topology** says it is showing 400 of N nodes even with **Show distant peers** ticked and **Max hops** set to **All hops**. + +**Cause**: The force-directed layout has a hard visible-node budget of **400** after hop filters. Numeric **Max hops** is not gated by Show distant. Unknown hops are omitted unless Max hops is **All hops** and Show distant is on. The nearby hop ceiling applies only when Max hops is **All hops**. Leftover nodes beyond the layout budget are omitted on purpose. Reticulum Topology also ingests at most 800 path-table rows after hop filters (sidecar 2,000). + +**Fix**: This is expected. The toolbar note states the 400-node limit. Turn on **Show distant peers** and set **Max hops** to **All hops** to include multi-hop peers up to 400. On Topology, use **RF only** to drop TCP/I2P hubs if you only want RNode/KISS/BLE. Narrow **Max hops** if the graph is too dense. + ### No signal bars on some nodes **Cause**: Signal strength is only available for **direct (0-hop) RF** neighbors. Multi-hop and MQTT-heard nodes have no client-side signal strength. diff --git a/package.json b/package.json index d06a41d1f..63eb4cd54 100644 --- a/package.json +++ b/package.json @@ -146,7 +146,7 @@ "@stoprocent/noble": "^2.7.1", "@xterm/addon-fit": "^0.11.0", "@xterm/xterm": "^6.0.0", - "@zip.js/zip.js": "^2.8.36", + "@zip.js/zip.js": "^2.8.37", "builder-util-runtime": "9.7.0", "dompurify": "^3.4.13", "electron-updater": "^6.8.9", @@ -190,8 +190,8 @@ "@types/qrcode": "^1.5.6", "@types/react": "^19.2.18", "@types/react-dom": "^19.2.4", - "@typescript-eslint/eslint-plugin": "^8.66.0", - "@typescript-eslint/parser": "^8.66.0", + "@typescript-eslint/eslint-plugin": "^8.67.0", + "@typescript-eslint/parser": "^8.67.0", "@vitejs/plugin-react": "^6.0.5", "@vitest/coverage-v8": "^4.1.10", "concurrently": "^9.2.4", @@ -225,7 +225,7 @@ "sort-package-json": "^3.7.1", "tailwindcss": "^4.3.3", "typescript": "^6.0.3", - "typescript-eslint": "^8.66.0", + "typescript-eslint": "^8.67.0", "vite": "^8.2.1", "vitest": "^4.1.10", "vitest-axe": "1.0.0-pre.5", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b0169b6ac..ad51ab52a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -54,8 +54,8 @@ importers: specifier: ^6.0.0 version: 6.0.0 '@zip.js/zip.js': - specifier: ^2.8.36 - version: 2.8.36 + specifier: ^2.8.37 + version: 2.8.37 builder-util-runtime: specifier: 9.7.0 version: 9.7.0(supports-color@8.1.1) @@ -181,11 +181,11 @@ importers: specifier: ^19.2.4 version: 19.2.4(@types/react@19.2.18) '@typescript-eslint/eslint-plugin': - specifier: ^8.66.0 - version: 8.66.0(@typescript-eslint/parser@8.66.0(eslint@10.8.1(jiti@2.7.0)(supports-color@8.1.1))(supports-color@8.1.1)(typescript@6.0.3))(eslint@10.8.1(jiti@2.7.0)(supports-color@8.1.1))(supports-color@8.1.1)(typescript@6.0.3) + specifier: ^8.67.0 + version: 8.67.0(@typescript-eslint/parser@8.67.0(eslint@10.8.1(jiti@2.7.0)(supports-color@8.1.1))(supports-color@8.1.1)(typescript@6.0.3))(eslint@10.8.1(jiti@2.7.0)(supports-color@8.1.1))(supports-color@8.1.1)(typescript@6.0.3) '@typescript-eslint/parser': - specifier: ^8.66.0 - version: 8.66.0(eslint@10.8.1(jiti@2.7.0)(supports-color@8.1.1))(supports-color@8.1.1)(typescript@6.0.3) + specifier: ^8.67.0 + version: 8.67.0(eslint@10.8.1(jiti@2.7.0)(supports-color@8.1.1))(supports-color@8.1.1)(typescript@6.0.3) '@vitejs/plugin-react': specifier: ^6.0.5 version: 6.0.5(vite@8.2.1(@types/node@25.9.5)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.0)) @@ -215,7 +215,7 @@ importers: version: 7.0.0(eslint@10.8.1(jiti@2.7.0)(supports-color@8.1.1)) eslint-plugin-import: specifier: ^2.32.0 - version: 2.32.0(@typescript-eslint/parser@8.66.0(eslint@10.8.1(jiti@2.7.0)(supports-color@8.1.1))(supports-color@8.1.1)(typescript@6.0.3))(eslint@10.8.1(jiti@2.7.0)(supports-color@8.1.1))(supports-color@8.1.1) + version: 2.32.0(@typescript-eslint/parser@8.67.0(eslint@10.8.1(jiti@2.7.0)(supports-color@8.1.1))(supports-color@8.1.1)(typescript@6.0.3))(eslint@10.8.1(jiti@2.7.0)(supports-color@8.1.1))(supports-color@8.1.1) eslint-plugin-jsx-a11y: specifier: ^6.10.2 version: 6.10.2(eslint@10.8.1(jiti@2.7.0)(supports-color@8.1.1)) @@ -286,8 +286,8 @@ importers: specifier: ^6.0.3 version: 6.0.3 typescript-eslint: - specifier: ^8.66.0 - version: 8.66.0(eslint@10.8.1(jiti@2.7.0)(supports-color@8.1.1))(supports-color@8.1.1)(typescript@6.0.3) + specifier: ^8.67.0 + version: 8.67.0(eslint@10.8.1(jiti@2.7.0)(supports-color@8.1.1))(supports-color@8.1.1)(typescript@6.0.3) vite: specifier: ^8.2.1 version: 8.2.1(@types/node@25.9.5)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.0) @@ -1364,63 +1364,63 @@ packages: '@types/ws@8.18.1': resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==} - '@typescript-eslint/eslint-plugin@8.66.0': - resolution: {integrity: sha512-p088eaGrzYz1s+7cov0aMOCkNGTJlVxF4jgubf28c8L0Cv9Rloj8YBHnv4hXLq6IIEE1AsjNWavO+k+8kP2Y0A==} + '@typescript-eslint/eslint-plugin@8.67.0': + resolution: {integrity: sha512-Un7Heoyj65NREbKAyIrFxeM143NZpExWmy1Nep4DLeQOeLlTeumPjoNKnBrU5D5moWXbPJgRa5Uwcdu0faVNGQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: - '@typescript-eslint/parser': ^8.66.0 + '@typescript-eslint/parser': ^8.67.0 eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/parser@8.66.0': - resolution: {integrity: sha512-X6ypGChaWYk6PBtUg2BwuTZEFFcHJAtGTVJ9/lCTOufhZ4i9fNolQNnktq+kkMCwMj7V8Svsq7+TxSDslmhE0g==} + '@typescript-eslint/parser@8.67.0': + resolution: {integrity: sha512-fUBfTuuEulWqX6V8+O3PtScV01tzYYRUDTAirHFKoRAt7nOzoGiPt0M/bB47wWNy0coOOcgEwAMUtBpykMxl6w==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/project-service@8.66.0': - resolution: {integrity: sha512-7MthGPTt4BP69lSryqpqq8HQqxuzynssckL/jyDyk3+TNMQ3y2jFWkptCrktWvBrP+EH787Nl5N5Qpw7WZg+5g==} + '@typescript-eslint/project-service@8.67.0': + resolution: {integrity: sha512-cvE8c7ulYeXN9fYuszhCeCsbzyVEXuhrRCybnBre7TUmqb5nRmBfQAwCj0O3WJFDeyAZt4VYv51vMCC9LHSdYw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/scope-manager@8.66.0': - resolution: {integrity: sha512-8TGcH25j9zqJ/IULB/ppyhRvxA8QYfFEZ7nfbg6/BN9spDgb8fPWQXlE5l8TWBL50EtUx007uZ1o9VOwrq2/9g==} + '@typescript-eslint/scope-manager@8.67.0': + resolution: {integrity: sha512-EgvsleTwS4E+WzzSvem8fAUubLwatMNF1B5hHSLQxcvs7q2dtRhGyujHwLJSYlG41niJ7GP24Aha2+0mb1b2kg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@typescript-eslint/tsconfig-utils@8.66.0': - resolution: {integrity: sha512-9D5gLYZG4rOjcoag8MQ/fWI8WqA9wcPDyOGyWtWFhvM1lHRbliqUSPIY5J3zqCU1tvSwzXxnnjhQhz5Ne7mJ4g==} + '@typescript-eslint/tsconfig-utils@8.67.0': + resolution: {integrity: sha512-vV+LUSv5njUWsknE71fqKTlXUva+R76SaeORd6Zojcunk/6DvKFXONU3BrAs2H49mbygUXt6gbYunzwqNwlhdg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/type-utils@8.66.0': - resolution: {integrity: sha512-LG2dWfjZQQp0ADtAu/EWJVayefGL2UEZ3CDeI44D9v3rXB/WYUqE/jpO28KrEKul5AySrmI+Zh1v6v+xW2U9+g==} + '@typescript-eslint/type-utils@8.67.0': + resolution: {integrity: sha512-aVWDXbRmdXO9siTfX4ditQI1T9+zVcNazT48EJCD0v40/9RIFoUgZ05CmGEq9H2gixRpjUn/iplwvlcvutJW/Q==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/types@8.66.0': - resolution: {integrity: sha512-H6gcYaSDOyvL3AD/jHUtUFo2jqGgn/F6nuyuZSu0QTesxL+cP4dQoIMrODRofuJC09g64+WgZ6tE19Y1N2YIFQ==} + '@typescript-eslint/types@8.67.0': + resolution: {integrity: sha512-sBtgslww8nsMYUjhdPBiSyUqSzT8uR6g93A2QXnQC8+cGdjz0CyaOdqHDRJb1AtORbZCNUJBBeFA/tNR2uQmww==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@typescript-eslint/typescript-estree@8.66.0': - resolution: {integrity: sha512-8/x4INiiQb10jGgXYD7116/zQ+OL84ZIFn0za68wwFHCanT/VLbBEroWht8RV8fn0/ZCAoazHLQgwUC0UQcDfg==} + '@typescript-eslint/typescript-estree@8.67.0': + resolution: {integrity: sha512-EKQBCE9yNlRJYm7jdTW5AhDacDUmSwQb0FAJAmK2EKYrNXIsa2vxcSZx6PvJ/dEdI6lS+Y9W+EXckLj0iPFGcw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/utils@8.66.0': - resolution: {integrity: sha512-jasearZPolBw5NJNYGMwxzHMF83niVWmMU1VdHzG1CyfI2VS7f7nZltnKtHcg20hW+7Uo5GfK4MeDPoU3qI8EA==} + '@typescript-eslint/utils@8.67.0': + resolution: {integrity: sha512-U9D1FdwEWBwok3hxxSdhclMb0twvt9QnjIQ0VfQ1AiX2epnpSgv2ubVDsayOFyY8K6FX+AQ7E0FKWVG3iKsj1A==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/visitor-keys@8.66.0': - resolution: {integrity: sha512-dkKR8q+lKciskj1Y3vthHktl+3cMLWGyVUP23bRiPZ5O9BRT++4EqDDV+TVeIKBL1VXVEqrJlz8MYbcnvJcAlg==} + '@typescript-eslint/visitor-keys@8.67.0': + resolution: {integrity: sha512-fkv8dHRDqfGtTHuJeebdrQ7cX6Ad4WAS00rgHh9UGvMycF1mjBfsxry1XsLIFhWZ6Judlh6UdzK+TYlbpCXgnA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} '@vitejs/plugin-react@6.0.5': @@ -1490,8 +1490,8 @@ packages: '@yarnpkg/lockfile@1.1.0': resolution: {integrity: sha512-GpSwvyXOcOOlV70vbnzjj4fW5xW/FdUF6nQEt1ENy7m4ZCczi1+/buVUPAqmGfqznsORNFzUMjctTIp8a9tuCQ==} - '@zip.js/zip.js@2.8.36': - resolution: {integrity: sha512-3CT63BXqYh781nZhNVOktg71oM1496BZhBHDjP5aOZTDmsL2nLkldbLDJFatFJt00j94TFnsZu2pWNijL4Eorw==} + '@zip.js/zip.js@2.8.37': + resolution: {integrity: sha512-hCjUGI0e0s7KNAV/f0Cs5rKLFt9+LsRcE0hAq9zvRkUehyUrrMFBWGLvjCs0JARJjKehyv/AUVB4yr89dLZurA==} engines: {bun: '>=0.7.0', deno: '>=1.0.0', node: '>=18.0.0'} abbrev@2.0.0: @@ -2077,8 +2077,8 @@ packages: electron-publish@26.15.3: resolution: {integrity: sha512-g/2bn8YTavY4cuS5F+jOS7zmZbXXBV8KZ8yHKfJjFPoKtzBqrpCdNPxBd3tqdBwP7BVd0lGzf7Bk2s0KesWZ4Q==} - electron-to-chromium@1.5.403: - resolution: {integrity: sha512-MQsYmdaLzvaCX5j+ZZBr5Fm6uCCnPQcRtlvmvRlWqrXy+BH2O4ffXIAScF+JQznQWB9brWp4lSD9Z4yNmaf2BA==} + electron-to-chromium@1.5.404: + resolution: {integrity: sha512-3WJtd7/lVq2Jnuz6wed1l9+1ZD2u2Tet1/1NBc4Iedkmgbu+I7YuAqdAQ8T+VZtnwysMsAf3IqSq9D1gyZjA2g==} electron-updater@6.8.9: resolution: {integrity: sha512-ZhVxM9iGONUpZGI1FxdMRgJjUFXi7AYGVa5PwKlO1tV1/4zDxQmfKpXOHVztKrd6L9rLcFjERvi1Mf2vxyTkig==} @@ -4444,8 +4444,8 @@ packages: typedarray@0.0.6: resolution: {integrity: sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==} - typescript-eslint@8.66.0: - resolution: {integrity: sha512-QlEbBPz/RuJ1XUHj29nm3t0F/O/cSlEnntozqPOYHnnTGAXFamnMBu5i9Vn6vhUPHGAjR+Vl+5J8vPN/BMUrJw==} + typescript-eslint@8.67.0: + resolution: {integrity: sha512-S2udFs8tCKEKffuJ4TB1idGUZiXdCPGi3IPBGWXarbLQ5UPXORV8QEVzJ4gCRduURMb5EkpNCdjbk0eDIuI8Yg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 @@ -4485,8 +4485,8 @@ packages: unzipper@0.12.5: resolution: {integrity: sha512-tXYOi9R57Uj/2Z25SOs5RRSzq886MBQj2gY8dPL+xl/kv6s6SvByoKfAtvfVeEuhntWDgjd2o9p2lb4TVPAz0A==} - update-browserslist-db@1.3.0: - resolution: {integrity: sha512-x/M6q3w4Ybp91CNaS4S69UnliqR3BzRpOT6LWbksjth0S/+jhfaPJsWjt/TewpT8j9eLIojUf5jr29WextHroA==} + update-browserslist-db@1.3.1: + resolution: {integrity: sha512-ZZ61DsRsOnakl74HAmp3oSN4aXUmEWXf+i/yv0h7tIBfICc3VdrFErQKUUKPgu3AMsTUMbcongALEN4l6GSUrQ==} hasBin: true peerDependencies: browserslist: '>= 4.21.0' @@ -5782,14 +5782,14 @@ snapshots: dependencies: '@types/node': 25.9.5 - '@typescript-eslint/eslint-plugin@8.66.0(@typescript-eslint/parser@8.66.0(eslint@10.8.1(jiti@2.7.0)(supports-color@8.1.1))(supports-color@8.1.1)(typescript@6.0.3))(eslint@10.8.1(jiti@2.7.0)(supports-color@8.1.1))(supports-color@8.1.1)(typescript@6.0.3)': + '@typescript-eslint/eslint-plugin@8.67.0(@typescript-eslint/parser@8.67.0(eslint@10.8.1(jiti@2.7.0)(supports-color@8.1.1))(supports-color@8.1.1)(typescript@6.0.3))(eslint@10.8.1(jiti@2.7.0)(supports-color@8.1.1))(supports-color@8.1.1)(typescript@6.0.3)': dependencies: '@eslint-community/regexpp': 4.12.2 - '@typescript-eslint/parser': 8.66.0(eslint@10.8.1(jiti@2.7.0)(supports-color@8.1.1))(supports-color@8.1.1)(typescript@6.0.3) - '@typescript-eslint/scope-manager': 8.66.0 - '@typescript-eslint/type-utils': 8.66.0(eslint@10.8.1(jiti@2.7.0)(supports-color@8.1.1))(supports-color@8.1.1)(typescript@6.0.3) - '@typescript-eslint/utils': 8.66.0(eslint@10.8.1(jiti@2.7.0)(supports-color@8.1.1))(supports-color@8.1.1)(typescript@6.0.3) - '@typescript-eslint/visitor-keys': 8.66.0 + '@typescript-eslint/parser': 8.67.0(eslint@10.8.1(jiti@2.7.0)(supports-color@8.1.1))(supports-color@8.1.1)(typescript@6.0.3) + '@typescript-eslint/scope-manager': 8.67.0 + '@typescript-eslint/type-utils': 8.67.0(eslint@10.8.1(jiti@2.7.0)(supports-color@8.1.1))(supports-color@8.1.1)(typescript@6.0.3) + '@typescript-eslint/utils': 8.67.0(eslint@10.8.1(jiti@2.7.0)(supports-color@8.1.1))(supports-color@8.1.1)(typescript@6.0.3) + '@typescript-eslint/visitor-keys': 8.67.0 eslint: 10.8.1(jiti@2.7.0)(supports-color@8.1.1) ignore: 7.0.6 natural-compare: 1.4.0 @@ -5798,41 +5798,41 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/parser@8.66.0(eslint@10.8.1(jiti@2.7.0)(supports-color@8.1.1))(supports-color@8.1.1)(typescript@6.0.3)': + '@typescript-eslint/parser@8.67.0(eslint@10.8.1(jiti@2.7.0)(supports-color@8.1.1))(supports-color@8.1.1)(typescript@6.0.3)': dependencies: - '@typescript-eslint/scope-manager': 8.66.0 - '@typescript-eslint/types': 8.66.0 - '@typescript-eslint/typescript-estree': 8.66.0(supports-color@8.1.1)(typescript@6.0.3) - '@typescript-eslint/visitor-keys': 8.66.0 + '@typescript-eslint/scope-manager': 8.67.0 + '@typescript-eslint/types': 8.67.0 + '@typescript-eslint/typescript-estree': 8.67.0(supports-color@8.1.1)(typescript@6.0.3) + '@typescript-eslint/visitor-keys': 8.67.0 debug: 4.4.3(patch_hash=cf37fa96f5df733456b16c82c9e1c9054a92f6216692aa33d5e2e1e208888e37)(supports-color@8.1.1) eslint: 10.8.1(jiti@2.7.0)(supports-color@8.1.1) typescript: 6.0.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/project-service@8.66.0(supports-color@8.1.1)(typescript@6.0.3)': + '@typescript-eslint/project-service@8.67.0(supports-color@8.1.1)(typescript@6.0.3)': dependencies: - '@typescript-eslint/tsconfig-utils': 8.66.0(typescript@6.0.3) - '@typescript-eslint/types': 8.66.0 + '@typescript-eslint/tsconfig-utils': 8.67.0(typescript@6.0.3) + '@typescript-eslint/types': 8.67.0 debug: 4.4.3(patch_hash=cf37fa96f5df733456b16c82c9e1c9054a92f6216692aa33d5e2e1e208888e37)(supports-color@8.1.1) typescript: 6.0.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/scope-manager@8.66.0': + '@typescript-eslint/scope-manager@8.67.0': dependencies: - '@typescript-eslint/types': 8.66.0 - '@typescript-eslint/visitor-keys': 8.66.0 + '@typescript-eslint/types': 8.67.0 + '@typescript-eslint/visitor-keys': 8.67.0 - '@typescript-eslint/tsconfig-utils@8.66.0(typescript@6.0.3)': + '@typescript-eslint/tsconfig-utils@8.67.0(typescript@6.0.3)': dependencies: typescript: 6.0.3 - '@typescript-eslint/type-utils@8.66.0(eslint@10.8.1(jiti@2.7.0)(supports-color@8.1.1))(supports-color@8.1.1)(typescript@6.0.3)': + '@typescript-eslint/type-utils@8.67.0(eslint@10.8.1(jiti@2.7.0)(supports-color@8.1.1))(supports-color@8.1.1)(typescript@6.0.3)': dependencies: - '@typescript-eslint/types': 8.66.0 - '@typescript-eslint/typescript-estree': 8.66.0(supports-color@8.1.1)(typescript@6.0.3) - '@typescript-eslint/utils': 8.66.0(eslint@10.8.1(jiti@2.7.0)(supports-color@8.1.1))(supports-color@8.1.1)(typescript@6.0.3) + '@typescript-eslint/types': 8.67.0 + '@typescript-eslint/typescript-estree': 8.67.0(supports-color@8.1.1)(typescript@6.0.3) + '@typescript-eslint/utils': 8.67.0(eslint@10.8.1(jiti@2.7.0)(supports-color@8.1.1))(supports-color@8.1.1)(typescript@6.0.3) debug: 4.4.3(patch_hash=cf37fa96f5df733456b16c82c9e1c9054a92f6216692aa33d5e2e1e208888e37)(supports-color@8.1.1) eslint: 10.8.1(jiti@2.7.0)(supports-color@8.1.1) ts-api-utils: 2.5.0(typescript@6.0.3) @@ -5840,14 +5840,14 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/types@8.66.0': {} + '@typescript-eslint/types@8.67.0': {} - '@typescript-eslint/typescript-estree@8.66.0(supports-color@8.1.1)(typescript@6.0.3)': + '@typescript-eslint/typescript-estree@8.67.0(supports-color@8.1.1)(typescript@6.0.3)': dependencies: - '@typescript-eslint/project-service': 8.66.0(supports-color@8.1.1)(typescript@6.0.3) - '@typescript-eslint/tsconfig-utils': 8.66.0(typescript@6.0.3) - '@typescript-eslint/types': 8.66.0 - '@typescript-eslint/visitor-keys': 8.66.0 + '@typescript-eslint/project-service': 8.67.0(supports-color@8.1.1)(typescript@6.0.3) + '@typescript-eslint/tsconfig-utils': 8.67.0(typescript@6.0.3) + '@typescript-eslint/types': 8.67.0 + '@typescript-eslint/visitor-keys': 8.67.0 debug: 4.4.3(patch_hash=cf37fa96f5df733456b16c82c9e1c9054a92f6216692aa33d5e2e1e208888e37)(supports-color@8.1.1) minimatch: 10.2.6 semver: 7.8.5 @@ -5857,20 +5857,20 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/utils@8.66.0(eslint@10.8.1(jiti@2.7.0)(supports-color@8.1.1))(supports-color@8.1.1)(typescript@6.0.3)': + '@typescript-eslint/utils@8.67.0(eslint@10.8.1(jiti@2.7.0)(supports-color@8.1.1))(supports-color@8.1.1)(typescript@6.0.3)': dependencies: '@eslint-community/eslint-utils': 4.10.1(eslint@10.8.1(jiti@2.7.0)(supports-color@8.1.1)) - '@typescript-eslint/scope-manager': 8.66.0 - '@typescript-eslint/types': 8.66.0 - '@typescript-eslint/typescript-estree': 8.66.0(supports-color@8.1.1)(typescript@6.0.3) + '@typescript-eslint/scope-manager': 8.67.0 + '@typescript-eslint/types': 8.67.0 + '@typescript-eslint/typescript-estree': 8.67.0(supports-color@8.1.1)(typescript@6.0.3) eslint: 10.8.1(jiti@2.7.0)(supports-color@8.1.1) typescript: 6.0.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/visitor-keys@8.66.0': + '@typescript-eslint/visitor-keys@8.67.0': dependencies: - '@typescript-eslint/types': 8.66.0 + '@typescript-eslint/types': 8.67.0 eslint-visitor-keys: 5.0.1 '@vitejs/plugin-react@6.0.5(vite@8.2.1(@types/node@25.9.5)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.0))': @@ -5945,7 +5945,7 @@ snapshots: '@yarnpkg/lockfile@1.1.0': {} - '@zip.js/zip.js@2.8.36': {} + '@zip.js/zip.js@2.8.37': {} abbrev@2.0.0: {} @@ -6195,9 +6195,9 @@ snapshots: dependencies: baseline-browser-mapping: 2.11.13 caniuse-lite: 1.0.30001809 - electron-to-chromium: 1.5.403 + electron-to-chromium: 1.5.404 node-releases: 2.0.53 - update-browserslist-db: 1.3.0(browserslist@4.28.8) + update-browserslist-db: 1.3.1(browserslist@4.28.8) buffer-from@1.1.2: {} @@ -6598,7 +6598,7 @@ snapshots: transitivePeerDependencies: - supports-color - electron-to-chromium@1.5.403: {} + electron-to-chromium@1.5.404: {} electron-updater@6.8.9(supports-color@8.1.1): dependencies: @@ -6817,11 +6817,11 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-module-utils@2.14.0(@typescript-eslint/parser@8.66.0(eslint@10.8.1(jiti@2.7.0)(supports-color@8.1.1))(supports-color@8.1.1)(typescript@6.0.3))(eslint-import-resolver-node@0.3.10(supports-color@8.1.1))(eslint@10.8.1(jiti@2.7.0)(supports-color@8.1.1))(supports-color@8.1.1): + eslint-module-utils@2.14.0(@typescript-eslint/parser@8.67.0(eslint@10.8.1(jiti@2.7.0)(supports-color@8.1.1))(supports-color@8.1.1)(typescript@6.0.3))(eslint-import-resolver-node@0.3.10(supports-color@8.1.1))(eslint@10.8.1(jiti@2.7.0)(supports-color@8.1.1))(supports-color@8.1.1): dependencies: debug: 3.2.7(supports-color@8.1.1) optionalDependencies: - '@typescript-eslint/parser': 8.66.0(eslint@10.8.1(jiti@2.7.0)(supports-color@8.1.1))(supports-color@8.1.1)(typescript@6.0.3) + '@typescript-eslint/parser': 8.67.0(eslint@10.8.1(jiti@2.7.0)(supports-color@8.1.1))(supports-color@8.1.1)(typescript@6.0.3) eslint: 10.8.1(jiti@2.7.0)(supports-color@8.1.1) eslint-import-resolver-node: 0.3.10(supports-color@8.1.1) transitivePeerDependencies: @@ -6832,7 +6832,7 @@ snapshots: eslint: 10.8.1(jiti@2.7.0)(supports-color@8.1.1) requireindex: 1.1.0 - eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.66.0(eslint@10.8.1(jiti@2.7.0)(supports-color@8.1.1))(supports-color@8.1.1)(typescript@6.0.3))(eslint@10.8.1(jiti@2.7.0)(supports-color@8.1.1))(supports-color@8.1.1): + eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.67.0(eslint@10.8.1(jiti@2.7.0)(supports-color@8.1.1))(supports-color@8.1.1)(typescript@6.0.3))(eslint@10.8.1(jiti@2.7.0)(supports-color@8.1.1))(supports-color@8.1.1): dependencies: '@rtsao/scc': 1.1.0 array-includes: 3.1.9 @@ -6843,7 +6843,7 @@ snapshots: doctrine: 2.1.0 eslint: 10.8.1(jiti@2.7.0)(supports-color@8.1.1) eslint-import-resolver-node: 0.3.10(supports-color@8.1.1) - eslint-module-utils: 2.14.0(@typescript-eslint/parser@8.66.0(eslint@10.8.1(jiti@2.7.0)(supports-color@8.1.1))(supports-color@8.1.1)(typescript@6.0.3))(eslint-import-resolver-node@0.3.10(supports-color@8.1.1))(eslint@10.8.1(jiti@2.7.0)(supports-color@8.1.1))(supports-color@8.1.1) + eslint-module-utils: 2.14.0(@typescript-eslint/parser@8.67.0(eslint@10.8.1(jiti@2.7.0)(supports-color@8.1.1))(supports-color@8.1.1)(typescript@6.0.3))(eslint-import-resolver-node@0.3.10(supports-color@8.1.1))(eslint@10.8.1(jiti@2.7.0)(supports-color@8.1.1))(supports-color@8.1.1) hasown: 2.0.4 is-core-module: 2.16.2 is-glob: 4.0.3 @@ -6855,7 +6855,7 @@ snapshots: string.prototype.trimend: 1.0.10 tsconfig-paths: 3.15.0 optionalDependencies: - '@typescript-eslint/parser': 8.66.0(eslint@10.8.1(jiti@2.7.0)(supports-color@8.1.1))(supports-color@8.1.1)(typescript@6.0.3) + '@typescript-eslint/parser': 8.67.0(eslint@10.8.1(jiti@2.7.0)(supports-color@8.1.1))(supports-color@8.1.1)(typescript@6.0.3) transitivePeerDependencies: - eslint-import-resolver-typescript - eslint-import-resolver-webpack @@ -9286,12 +9286,12 @@ snapshots: typedarray@0.0.6: {} - typescript-eslint@8.66.0(eslint@10.8.1(jiti@2.7.0)(supports-color@8.1.1))(supports-color@8.1.1)(typescript@6.0.3): + typescript-eslint@8.67.0(eslint@10.8.1(jiti@2.7.0)(supports-color@8.1.1))(supports-color@8.1.1)(typescript@6.0.3): dependencies: - '@typescript-eslint/eslint-plugin': 8.66.0(@typescript-eslint/parser@8.66.0(eslint@10.8.1(jiti@2.7.0)(supports-color@8.1.1))(supports-color@8.1.1)(typescript@6.0.3))(eslint@10.8.1(jiti@2.7.0)(supports-color@8.1.1))(supports-color@8.1.1)(typescript@6.0.3) - '@typescript-eslint/parser': 8.66.0(eslint@10.8.1(jiti@2.7.0)(supports-color@8.1.1))(supports-color@8.1.1)(typescript@6.0.3) - '@typescript-eslint/typescript-estree': 8.66.0(supports-color@8.1.1)(typescript@6.0.3) - '@typescript-eslint/utils': 8.66.0(eslint@10.8.1(jiti@2.7.0)(supports-color@8.1.1))(supports-color@8.1.1)(typescript@6.0.3) + '@typescript-eslint/eslint-plugin': 8.67.0(@typescript-eslint/parser@8.67.0(eslint@10.8.1(jiti@2.7.0)(supports-color@8.1.1))(supports-color@8.1.1)(typescript@6.0.3))(eslint@10.8.1(jiti@2.7.0)(supports-color@8.1.1))(supports-color@8.1.1)(typescript@6.0.3) + '@typescript-eslint/parser': 8.67.0(eslint@10.8.1(jiti@2.7.0)(supports-color@8.1.1))(supports-color@8.1.1)(typescript@6.0.3) + '@typescript-eslint/typescript-estree': 8.67.0(supports-color@8.1.1)(typescript@6.0.3) + '@typescript-eslint/utils': 8.67.0(eslint@10.8.1(jiti@2.7.0)(supports-color@8.1.1))(supports-color@8.1.1)(typescript@6.0.3) eslint: 10.8.1(jiti@2.7.0)(supports-color@8.1.1) typescript: 6.0.3 transitivePeerDependencies: @@ -9326,7 +9326,7 @@ snapshots: graceful-fs: 4.2.11 node-int64: 0.4.0 - update-browserslist-db@1.3.0(browserslist@4.28.8): + update-browserslist-db@1.3.1(browserslist@4.28.8): dependencies: browserslist: 4.28.8 escalade: 3.2.0 diff --git a/scripts/update.sh b/scripts/update.sh index a3ed70759..0f5f76ab9 100755 --- a/scripts/update.sh +++ b/scripts/update.sh @@ -367,7 +367,9 @@ warn_github_api_rate_limit_once() { fi } -# Latest release summary: "tag|published_at|first_body_line" or empty. +# Latest release summary: "tag|published_at|first_body_line|four" or empty. +# four is 1 when the published release body mentions Four in a Row. +# /releases/latest ignores drafts and prereleases (in-flight tags/main/RCs). github_latest_release_summary() { local repo="$1" local json='' @@ -395,12 +397,14 @@ process.stdin.on("end", () => { } const tag = String(j.tag_name || j.name || "").replace(/\|/g, "/"); const published = String(j.published_at || "").slice(0, 10); - const body = String(j.body || "").split(/\r?\n/).find((l) => l.trim()) || ""; + const rawBody = String(j.body || ""); + const body = rawBody.split(/\r?\n/).find((l) => l.trim()) || ""; const first = body .replace(/[\u0000-\u001F\u007F]/g, "") .replace(/\|/g, "/") .slice(0, 120); - process.stdout.write(`${tag}|${published}|${first}`); + const four = /four[\s_-]*in[\s_-]*a[\s_-]*row/i.test(rawBody) ? "1" : "0"; + process.stdout.write(`${tag}|${published}|${first}|${four}`); } catch { process.stdout.write(""); } @@ -408,16 +412,108 @@ process.stdin.on("end", () => { ' 2> /dev/null || echo '' } +# Latest commit SHA that touched path, or empty. +github_file_latest_commit() { + local repo="$1" file_path="$2" + local json='' + local api_rc=0 + json="$(github_api_get "repos/${repo}/commits?path=${file_path}&per_page=1")" || api_rc=$? + if [ "${api_rc}" -eq 2 ]; then + warn_github_api_rate_limit_once + echo '' + return 0 + fi + if [ -z "${json}" ]; then + echo '' + return 0 + fi + printf '%s' "${json}" | node -e ' +let s = ""; +process.stdin.setEncoding("utf8"); +process.stdin.on("data", (c) => { s += c; }); +process.stdin.on("end", () => { + try { + const j = JSON.parse(s); + if (!Array.isArray(j) || !j[0] || !j[0].sha) { + process.stdout.write(""); + return; + } + process.stdout.write(String(j[0].sha).replace(/\|/g, "")); + } catch { + process.stdout.write(""); + } +}); +' 2> /dev/null || echo '' +} + +# 1 if GitHub compare base...head mentions Four in a Row; else 0. +github_compare_mentions_four_in_a_row() { + local repo="$1" base="$2" head="$3" + local json='' + local api_rc=0 + json="$(github_api_get "repos/${repo}/compare/${base}...${head}")" || api_rc=$? + if [ "${api_rc}" -eq 2 ]; then + warn_github_api_rate_limit_once + echo '0' + return 0 + fi + if [ -z "${json}" ]; then + echo '0' + return 0 + fi + printf '%s' "${json}" | node -e ' +let s = ""; +process.stdin.setEncoding("utf8"); +process.stdin.on("data", (c) => { s += c; }); +process.stdin.on("end", () => { + try { + const j = JSON.parse(s); + const files = Array.isArray(j.files) ? j.files : []; + const hay = files + .map((f) => `${f.filename || ""}\n${f.patch || ""}`) + .join("\n"); + process.stdout.write(/four[\s_-]*in[\s_-]*a[\s_-]*row/i.test(hay) ? "1" : "0"); + } catch { + process.stdout.write("0"); + } +}); +' 2> /dev/null || echo '0' +} + +# Compare published release tags (strip a leading v; case-insensitive). +release_refs_equal() { + node -e ' +const norm = (t) => String(t).trim().replace(/^v/i, "").toLowerCase(); +const a = norm(process.argv[1]); +const b = norm(process.argv[2]); +process.exit(a && b && a === b ? 0 : 1); +' "$1" "$2" 2> /dev/null +} + +# Compare git SHAs (lowercase hex; prefix match allowed). +commit_shas_equal() { + node -e ' +const norm = (s) => String(s).toLowerCase().replace(/[^0-9a-f]/g, ""); +const a = norm(process.argv[1]); +const b = norm(process.argv[2]); +if (!a || !b) process.exit(1); +process.exit(a === b || a.startsWith(b) || b.startsWith(a) ? 0 : 1); +' "$1" "$2" 2> /dev/null +} + # Curated release watch + known org repos (keep in sync when adopting new ratspeak libs). -# Format: "owner/repo|stub-kind-or-empty|display-label" +# Format: "owner/repo|stub-kind-or-empty|display-label|reviewed-ref" +# reviewed-ref: last published GitHub Release tag we reviewed, or +# file:@ for repos without releases (vendored file commit). +# Empty = no published release expected yet; tags/main/RCs are ignored. # stub-kind: games → warn while mesh-client still has sidecar stubs only. -# stub-kind: games-parity → non-fatal reminder to review Ratspeak Games tab vs mesh-client. +# stub-kind: games-parity → warn only when a published release is newer than reviewed-ref. # (voice/games stubs cleared after lxst-telephony / lrgp-rs integration; empty stub = informational.) RATSPEAK_RELEASE_WATCH_ENTRIES=( - 'ratspeak/rsLXST||rsLXST voice (lxst-telephony)' - 'ratspeak/lrgp-rs||lrgp-rs games (LRGP)' - 'ratspeak/Ratspeak|games-parity|Ratspeak client (review Games tab parity)' - 'ratspeak/LXMFace||LXMFace identicons (vendored in renderer)' + 'ratspeak/rsLXST||rsLXST voice (lxst-telephony)|v0.1.2' + 'ratspeak/lrgp-rs||lrgp-rs games (LRGP)|' + 'ratspeak/Ratspeak|games-parity|Ratspeak client (review Games tab parity)|v1.0.25' + 'ratspeak/LXMFace||LXMFace identicons (vendored in renderer)|file:js/lxmface.js@308a729d5bf951880633e5e174b3b7628203106b' ) RATSPEAK_KNOWN_ORG_REPOS=( @@ -454,38 +550,80 @@ print_ratspeak_upstream_catalog() { } # Surface new Ratspeak library releases and brand-new org repos (stack floats via clone). +# Warn only when a published GitHub Release (or vendored file commit) differs from reviewed-ref. check_ratspeak_upstream() { local has_upstream_warning=0 - local entry repo stub label summary tag published first url + local entry repo stub label reviewed summary tag published first four url + local file_spec file_path file_sha latest_sha short_pin short_latest cmp_four echo '' - echo 'Checking Ratspeak upstream releases and new org repos...' - echo ' (rsReticulum/rsLXMF float to origin/main via clone-ratspeak-stack.sh)' + echo 'Checking Ratspeak upstream published releases and new org repos...' + echo ' (tags/main/RCs without a GitHub Release are ignored; rsReticulum/rsLXMF float via clone-ratspeak-stack.sh)' for entry in "${RATSPEAK_RELEASE_WATCH_ENTRIES[@]}"; do - IFS='|' read -r repo stub label <<< "${entry}" + IFS='|' read -r repo stub label reviewed <<< "${entry}" url="https://github.com/${repo}/releases" + + if [[ "${reviewed}" == file:* ]]; then + file_spec="${reviewed#file:}" + file_path="${file_spec%@*}" + file_sha="${file_spec##*@}" + if [ -z "${file_path}" ] || [ -z "${file_sha}" ] || [ "${file_path}" = "${file_spec}" ]; then + continue + fi + latest_sha="$(github_file_latest_commit "${repo}" "${file_path}")" + if [ -z "${latest_sha}" ]; then + continue + fi + short_pin="${file_sha:0:12}" + short_latest="${latest_sha:0:12}" + if commit_shas_equal "${latest_sha}" "${file_sha}"; then + echo " ${label}: ${file_path} @ ${short_pin} (reviewed; current)" + continue + fi + warn_box "${label}" "${short_pin}" "${short_latest}" \ + "https://github.com/${repo}/commits?path=${file_path}" + echo " Reason tracked: vendored ${file_path} changed — compare with src/renderer/lib/reticulum/lxmface.ts" + has_upstream_warning=1 + HAS_WARNING=1 + continue + fi + summary="$(github_latest_release_summary "${repo}")" if [ -z "${summary}" ]; then - echo " ${label}: no GitHub release (or query failed) — ${url}" + if [ -n "${reviewed}" ]; then + echo " ${label}: no published GitHub release (reviewed ${reviewed})" + else + echo " ${label}: no published GitHub release" + fi + continue + fi + IFS='|' read -r tag published first four <<< "${summary}" + if [ -n "${reviewed}" ] && release_refs_equal "${tag}" "${reviewed}"; then + echo " ${label}: ${tag} (${published}; reviewed; current)" continue fi - IFS='|' read -r tag published first <<< "${summary}" echo " ${label}: ${tag} (${published}) — ${first}" + warn_box "${label}" "${reviewed:-none}" "${tag}" "${url}" if [ "${stub}" = 'voice' ] || [ "${stub}" = 'games' ]; then - warn_box "${label}" "sidecar stub" "${tag} available" "${url}" echo " Reason tracked: mesh-client still stubs this feature; review integrating ${repo} @ ${tag}" - has_upstream_warning=1 - HAS_WARNING=1 elif [ "${stub}" = 'games-parity' ]; then - warn_box "${label}" "Games parity review" "${tag} available" "${url}" echo " Reason tracked: compare Ratspeak Games tab with mesh-client:" echo " crates/ratspeak-tauri/src/commands/games.rs" echo " dashboard/static/js/games_tab.js" echo " docs/reticulum-games-parity.md" - has_upstream_warning=1 - HAS_WARNING=1 + cmp_four='0' + if [ "${four}" != '1' ] && [ -n "${reviewed}" ]; then + cmp_four="$(github_compare_mentions_four_in_a_row "${repo}" "${reviewed}" "${tag}")" + fi + if [ "${four}" = '1' ] || [ "${cmp_four}" = '1' ]; then + echo " This published release includes Four in a Row — update Games UI + docs/reticulum-games-parity.md" + fi + else + echo " Reason tracked: published release is newer than reviewed-ref ${reviewed:-none} — bump the pin after review" fi + has_upstream_warning=1 + HAS_WARNING=1 done local repos_json='' @@ -536,7 +674,7 @@ process.stdin.on("end", () => { fi if [ "${has_upstream_warning}" -eq 0 ]; then - echo ' Ratspeak upstream watch complete (no stub/new-repo warnings).' + echo ' Ratspeak upstream watch complete (reviewed baselines current; no new-repo warnings).' fi } diff --git a/scripts/update.test.mjs b/scripts/update.test.mjs index 25812f2c6..90754e2dc 100644 --- a/scripts/update.test.mjs +++ b/scripts/update.test.mjs @@ -97,14 +97,16 @@ describe('update.sh Reticulum stack functionality check', () => { const result = runUpdate([], { UPDATE_SH_TEST_HOOK: 'upstream-catalog-only' }); expect(result.status, result.stderr || result.stdout).toBe(0); expect(result.stdout).toContain('RATSPEAK_RELEASE_WATCH_ENTRIES:'); - expect(result.stdout).toContain('ratspeak/rsLXST||rsLXST voice (lxst-telephony)'); - expect(result.stdout).toContain('ratspeak/lrgp-rs||lrgp-rs games (LRGP)'); + expect(result.stdout).toContain('ratspeak/rsLXST||rsLXST voice (lxst-telephony)|v0.1.2'); + expect(result.stdout).toContain('ratspeak/lrgp-rs||lrgp-rs games (LRGP)|'); expect(result.stdout).toContain( - 'ratspeak/Ratspeak|games-parity|Ratspeak client (review Games tab parity)', + 'ratspeak/Ratspeak|games-parity|Ratspeak client (review Games tab parity)|v1.0.25', ); expect(result.stdout).toContain('ratspeak/LXMFace||'); + expect(result.stdout).toContain('file:js/lxmface.js@308a729d5bf951880633e5e174b3b7628203106b'); expect(updateScript).toContain('"${stub}" = \'games-parity\''); expect(updateScript).toContain('docs/reticulum-games-parity.md'); + expect(updateScript).toContain('reviewed-ref'); expect(result.stdout).toContain('RATSPEAK_KNOWN_ORG_REPOS:'); expect(result.stdout).toContain(' rsReticulum'); expect(result.stdout).toContain(' rsLXMF'); @@ -125,14 +127,23 @@ describe('update.sh Reticulum stack functionality check', () => { expect(upstreamCall).toBeGreaterThan(patchesCall); }); + const LXMFACE_REVIEWED_SHA = '308a729d5bf951880633e5e174b3b7628203106b'; + /** * @param {'release' | 'rate-limit' | 'malformed' | 'missing'} mode + * @param {{ + * releases?: Record + * commits?: Record + * compare?: unknown + * }} [extra] */ - function prepareUpstreamGhFixture(mode) { + function prepareUpstreamGhFixture(mode, extra = {}) { const work = mkdtempSync(path.join(os.tmpdir(), 'mesh-update-upstream-')); tempDirs.push(work); const binDir = path.join(work, 'bin'); mkdirSync(binDir, { recursive: true }); + mkdirSync(path.join(work, 'releases'), { recursive: true }); + mkdirSync(path.join(work, 'commits'), { recursive: true }); const releasePath = path.join(work, 'release.json'); const reposPath = path.join(work, 'repos.json'); if (mode === 'release') { @@ -155,6 +166,19 @@ describe('update.sh Reticulum stack functionality check', () => { writeFileSync(releasePath, JSON.stringify({ message: 'Not Found' })); writeFileSync(reposPath, '[]'); } + for (const [repo, payload] of Object.entries(extra.releases ?? {})) { + writeFileSync( + path.join(work, 'releases', `${repo.replaceAll('/', '-')}.json`), + typeof payload === 'string' ? payload : JSON.stringify(payload), + ); + } + for (const [repo, payload] of Object.entries(extra.commits ?? {})) { + writeFileSync( + path.join(work, 'commits', `${repo.replaceAll('/', '-')}.json`), + typeof payload === 'string' ? payload : JSON.stringify(payload), + ); + } + writeFileSync(path.join(work, 'compare.json'), JSON.stringify(extra.compare ?? { files: [] })); const ghPath = path.join(binDir, 'gh'); writeFileSync( ghPath, @@ -166,9 +190,33 @@ if [[ "\${1:-}" != "api" ]]; then fi path="\${2:-}" if [[ "$path" == repos/*/releases/latest ]]; then + repo="\${path#repos/}" + repo="\${repo%/releases/latest}" + slug="\${repo//\\//-}" + per=${JSON.stringify(path.join(work, 'releases'))}/"\${slug}.json" + if [[ -f "$per" ]]; then + cat "$per" + exit 0 + fi cat ${JSON.stringify(releasePath)} exit 0 fi +if [[ "$path" == repos/*/commits* ]]; then + repo="\${path#repos/}" + repo="\${repo%%/commits*}" + slug="\${repo//\\//-}" + per=${JSON.stringify(path.join(work, 'commits'))}/"\${slug}.json" + if [[ -f "$per" ]]; then + cat "$per" + exit 0 + fi + printf '%s' '[]' + exit 0 +fi +if [[ "$path" == repos/*/compare/* ]]; then + cat ${JSON.stringify(path.join(work, 'compare.json'))} + exit 0 +fi if [[ "$path" == orgs/ratspeak/repos* ]]; then cat ${JSON.stringify(reposPath)} exit 0 @@ -182,6 +230,28 @@ exit 0 return { work, binDir }; } + /** Current reviewed-ref pins: no warn_box. */ + function currentBaselineExtra() { + return { + releases: { + 'ratspeak/rsLXST': { + tag_name: 'v0.1.2', + published_at: '2026-07-26T19:52:43Z', + body: 'rsLXST v0.1.2', + }, + 'ratspeak/lrgp-rs': { message: 'Not Found' }, + 'ratspeak/Ratspeak': { + tag_name: 'v1.0.25', + published_at: '2026-07-19T05:00:50Z', + body: 'Protocol refresh: byte-exact parity with Reticulum 1.3.8 and LXMF 1.0.1', + }, + }, + commits: { + 'ratspeak/LXMFace': [{ sha: LXMFACE_REVIEWED_SHA }], + }, + }; + } + it('upstream-check-only parses a valid release non-fatally', () => { const fixture = prepareUpstreamGhFixture('release'); const result = runUpdate([], { @@ -211,7 +281,8 @@ exit 0 PATH: `${fixture.binDir}:${process.env.PATH ?? ''}`, }); expect(result.status, result.stderr || result.stdout).toBe(0); - expect(result.stdout).toContain('no GitHub release (or query failed)'); + expect(result.stdout).toContain('no published GitHub release'); + expect(result.stdout).not.toContain('query failed'); }); it('upstream-check-only tolerates missing releases', () => { @@ -221,7 +292,123 @@ exit 0 PATH: `${fixture.binDir}:${process.env.PATH ?? ''}`, }); expect(result.status, result.stderr || result.stdout).toBe(0); - expect(result.stdout).toContain('no GitHub release (or query failed)'); + expect(result.stdout).toContain('no published GitHub release'); + expect(result.stdout).not.toContain('query failed'); + expect(result.stdout).not.toContain('WARNING:'); + }); + + it('upstream-check-only stays quiet when published releases match reviewed-ref', () => { + const fixture = prepareUpstreamGhFixture('release', currentBaselineExtra()); + const result = runUpdate([], { + UPDATE_SH_TEST_HOOK: 'upstream-check-only', + PATH: `${fixture.binDir}:${process.env.PATH ?? ''}`, + }); + expect(result.status, result.stderr || result.stdout).toBe(0); + expect(result.stdout).toContain('v1.0.25'); + expect(result.stdout).toContain('reviewed; current'); + expect(result.stdout).toContain('js/lxmface.js @ 308a729d5bf9 (reviewed; current)'); + expect(result.stdout).toContain('no published GitHub release'); + expect(result.stdout).toContain( + 'Ratspeak upstream watch complete (reviewed baselines current; no new-repo warnings).', + ); + expect(result.stdout).not.toContain('WARNING:'); + expect(result.stdout).not.toContain('Four in a Row'); + expect(result.stdout).not.toContain('query failed'); + }); + + it('upstream-check-only warns when a published Ratspeak release is newer than the pin', () => { + const fixture = prepareUpstreamGhFixture('release', { + ...currentBaselineExtra(), + releases: { + ...currentBaselineExtra().releases, + 'ratspeak/Ratspeak': { + tag_name: 'v9.9.9', + published_at: '2026-08-01T00:00:00Z', + body: 'First line\nSecond', + }, + }, + }); + const result = runUpdate([], { + UPDATE_SH_TEST_HOOK: 'upstream-check-only', + PATH: `${fixture.binDir}:${process.env.PATH ?? ''}`, + }); + expect(result.status, result.stderr || result.stdout).toBe(0); + expect(result.stdout).toContain('WARNING:'); + expect(result.stdout).toContain('v1.0.25'); + expect(result.stdout).toContain('v9.9.9'); + expect(result.stdout).toContain('docs/reticulum-games-parity.md'); + expect(result.stdout).not.toContain('Four in a Row'); + }); + + it('upstream-check-only hints Four in a Row when the published release body mentions it', () => { + const fixture = prepareUpstreamGhFixture('release', { + ...currentBaselineExtra(), + releases: { + ...currentBaselineExtra().releases, + 'ratspeak/Ratspeak': { + tag_name: 'v9.9.9', + published_at: '2026-08-01T00:00:00Z', + body: 'Added Four in a Row\nOther notes', + }, + }, + }); + const result = runUpdate([], { + UPDATE_SH_TEST_HOOK: 'upstream-check-only', + PATH: `${fixture.binDir}:${process.env.PATH ?? ''}`, + }); + expect(result.status, result.stderr || result.stdout).toBe(0); + expect(result.stdout).toContain('WARNING:'); + expect(result.stdout).toContain( + 'This published release includes Four in a Row — update Games UI + docs/reticulum-games-parity.md', + ); + }); + + it('upstream-check-only hints Four in a Row when the compare diff mentions it', () => { + const fixture = prepareUpstreamGhFixture('release', { + ...currentBaselineExtra(), + releases: { + ...currentBaselineExtra().releases, + 'ratspeak/Ratspeak': { + tag_name: 'v9.9.9', + published_at: '2026-08-01T00:00:00Z', + body: 'Protocol refresh only', + }, + }, + compare: { + files: [ + { + filename: 'crates/ratspeak-tauri/src/commands/games.rs', + patch: '+ "four_in_a_row",', + }, + ], + }, + }); + const result = runUpdate([], { + UPDATE_SH_TEST_HOOK: 'upstream-check-only', + PATH: `${fixture.binDir}:${process.env.PATH ?? ''}`, + }); + expect(result.status, result.stderr || result.stdout).toBe(0); + expect(result.stdout).toContain( + 'This published release includes Four in a Row — update Games UI + docs/reticulum-games-parity.md', + ); + }); + + it('upstream-check-only warns when the vendored LXMFace file commit changes', () => { + const fixture = prepareUpstreamGhFixture('release', { + ...currentBaselineExtra(), + commits: { + 'ratspeak/LXMFace': [{ sha: 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' }], + }, + }); + const result = runUpdate([], { + UPDATE_SH_TEST_HOOK: 'upstream-check-only', + PATH: `${fixture.binDir}:${process.env.PATH ?? ''}`, + }); + expect(result.status, result.stderr || result.stdout).toBe(0); + expect(result.stdout).toContain('WARNING:'); + expect(result.stdout).toContain('308a729d5bf9'); + expect(result.stdout).toContain('aaaaaaaaaaaa'); + expect(result.stdout).toContain('src/renderer/lib/reticulum/lxmface.ts'); }); it('runs cargo clean after a successful rebuild when CLEAN_SIDECAR_TARGET=1', () => { diff --git a/src/main/index.contract.test.ts b/src/main/index.contract.test.ts index cfb028694..e19d6a3a1 100644 --- a/src/main/index.contract.test.ts +++ b/src/main/index.contract.test.ts @@ -29,6 +29,16 @@ describe('Noble BLE disconnect handling (source contract)', () => { ); }); + it('resolves meshtastic:tcp-write with no-socket instead of rejecting when the socket is gone', () => { + expect(INDEX_SOURCE).toMatch( + /meshtastic:tcp-write[\s\S]{0,800}console\.debug\('\[IPC\] meshtastic:tcp-write: no active socket'\)[\s\S]{0,80}return 'no-socket'/, + ); + expect(INDEX_SOURCE).toContain('meshtasticTcpWriteErrorIsNoSocket'); + expect(INDEX_SOURCE).toMatch(/sock\.destroyed \|\| sock\.writableEnded/); + expect(PRELOAD_SOURCE).toMatch(/result === 'no-socket'/); + expect(PRELOAD_SOURCE).toMatch(/throw new Error\('meshtastic:tcp-write: no active socket'\)/); + }); + it('returns scan_busy result instead of throwing when Reticulum holds the scan mutex', () => { expect(INDEX_SOURCE).toContain('BleScanBusyError'); expect(INDEX_SOURCE).toMatch( diff --git a/src/main/index.ts b/src/main/index.ts index 23389acac..59fa36b97 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -150,6 +150,7 @@ import { } from './log-service'; import { MeshcoreMqttAdapter } from './meshcore-mqtt-adapter'; import { decodePathPayload, isPathPacket } from './meshcore-path-decoder'; +import { meshtasticTcpWriteErrorIsNoSocket } from './meshtasticTcpWriteResult'; import { ensureMicrophoneAccess, isAllowedMicrophonePrivacySettingsUrl } from './microphoneAccess'; import { resolveMqttBrokerClientId } from './mqtt-broker-client-id'; import { type CachedNode, MQTTManager, parsePsk } from './mqtt-manager'; @@ -6397,14 +6398,23 @@ ipcMain.handle('meshtastic:tcp-write', (event, bytes: number[]) => { return Promise.reject(new Error('meshtastic:tcp-write: byte values must be integers 0-255')); } if (!meshtasticTcpSocket) { - const msg = 'meshtastic:tcp-write: no active socket'; - console.warn(`[IPC] ${msg}`); - return Promise.reject(new Error(msg)); + // Expected reconnect race — resolve so Electron does not log handler [error]. + console.debug('[IPC] meshtastic:tcp-write: no active socket'); + return 'no-socket'; } const sock = meshtasticTcpSocket; - return new Promise((resolve, reject) => { + if (sock.destroyed || sock.writableEnded) { + console.debug('[IPC] meshtastic:tcp-write: no active socket'); + return 'no-socket'; + } + return new Promise<'no-socket' | undefined>((resolve, reject) => { sock.write(new Uint8Array(bytes), (err) => { if (err) { + if (meshtasticTcpWriteErrorIsNoSocket(sock, err)) { + console.debug('[IPC] meshtastic:tcp-write: no active socket'); + resolve('no-socket'); + return; + } console.error('[IPC] meshtastic:tcp-write error:', sanitizeLogMessage(err.message)); reject(err); } else { @@ -6412,7 +6422,7 @@ ipcMain.handle('meshtastic:tcp-write', (event, bytes: number[]) => { if (meshtasticTcpSocket === sock) { noteLiveSessionWrite('meshtastic'); } - resolve(); + resolve(undefined); } }); }); diff --git a/src/main/log-service.test.ts b/src/main/log-service.test.ts index cee4d6d32..a5ad64fc1 100644 --- a/src/main/log-service.test.ts +++ b/src/main/log-service.test.ts @@ -414,6 +414,34 @@ describe('isDroppableMeshtasticSdkLogLine', () => { }); }); +describe('isDroppableRendererConsoleNoise', () => { + it('drops ResizeObserver loop completed warnings', async () => { + const { isDroppableRendererConsoleNoise } = await import('./log-service'); + expect( + isDroppableRendererConsoleNoise( + 'ResizeObserver loop completed with undelivered notifications.', + ), + ).toBe(true); + expect(isDroppableRendererConsoleNoise('ResizeObserver loop limit exceeded')).toBe(true); + expect( + isDroppableRendererConsoleNoise( + ' [Violation] ResizeObserver loop completed with undelivered notifications. ', + ), + ).toBe(true); + }); + + it('keeps other renderer errors', async () => { + const { isDroppableRendererConsoleNoise } = await import('./log-service'); + expect(isDroppableRendererConsoleNoise('Error sending packet 123')).toBe(false); + expect(isDroppableRendererConsoleNoise('ResizeObserver is not defined')).toBe(false); + expect( + isDroppableRendererConsoleNoise( + 'Send failed: ResizeObserver loop limit exceeded while laying out', + ), + ).toBe(false); + }); +}); + describe('formatRuntimeLogTag', () => { it('includes platform, arch, electron, node, packaged, and buildChannel fields', async () => { const { formatRuntimeLogTag } = await import('./log-service'); diff --git a/src/main/log-service.ts b/src/main/log-service.ts index e7a7b226f..0d90f1a1c 100644 --- a/src/main/log-service.ts +++ b/src/main/log-service.ts @@ -405,6 +405,23 @@ export function isDroppableMeshtasticSdkLogLine(message: string): boolean { return false; } +/** + * Chromium ResizeObserver loop warnings — harmless, logged as error by the renderer. + * Keep real application errors. + */ +export function isDroppableRendererConsoleNoise(message: string): boolean { + let text = message.trim(); + const violationPrefix = '[violation] '; + if (text.toLowerCase().startsWith(violationPrefix)) { + text = text.slice(violationPrefix.length).trim(); + } + const body = (text.endsWith('.') ? text.slice(0, -1) : text).toLowerCase(); + return ( + body === 'resizeobserver loop completed with undelivered notifications' || + body === 'resizeobserver loop limit exceeded' + ); +} + /** * Renderer console-message (Electron 40+): single event object with message, level, lineNumber, sourceId. * level is 'info' | 'warning' | 'error' | 'debug'. @@ -428,5 +445,6 @@ export function forwardRendererConsoleMessage(details: { : 'renderer'; const msg = sanitizeLogMessage(stripConsoleStyles(details.message)); if (isDroppableMeshtasticSdkLogLine(msg)) return; + if (isDroppableRendererConsoleNoise(msg)) return; appendLine(mapped, src, msg); } diff --git a/src/main/meshtasticTcpWriteResult.test.ts b/src/main/meshtasticTcpWriteResult.test.ts new file mode 100644 index 000000000..0df08b558 --- /dev/null +++ b/src/main/meshtasticTcpWriteResult.test.ts @@ -0,0 +1,37 @@ +import net from 'node:net'; + +import { describe, expect, it } from 'vitest'; + +import { meshtasticTcpWriteErrorIsNoSocket } from './meshtasticTcpWriteResult'; + +describe('meshtasticTcpWriteErrorIsNoSocket', () => { + it('treats a destroyed socket after the live-reference check as no-socket', () => { + const sock = new net.Socket(); + sock.destroy(); + const err = new Error('This socket has been destroyed'); + expect(meshtasticTcpWriteErrorIsNoSocket(sock, err)).toBe(true); + }); + + it('treats writableEnded sockets and closed-stream codes as no-socket', () => { + expect( + meshtasticTcpWriteErrorIsNoSocket( + { destroyed: false, writableEnded: true }, + new Error('write after end'), + ), + ).toBe(true); + const epipe = new Error('write EPIPE') as NodeJS.ErrnoException; + epipe.code = 'EPIPE'; + expect( + meshtasticTcpWriteErrorIsNoSocket({ destroyed: false, writableEnded: false }, epipe), + ).toBe(true); + }); + + it('propagates unrelated write failures on a live socket', () => { + expect( + meshtasticTcpWriteErrorIsNoSocket( + { destroyed: false, writableEnded: false }, + new Error('EACCES: permission denied'), + ), + ).toBe(false); + }); +}); diff --git a/src/main/meshtasticTcpWriteResult.ts b/src/main/meshtasticTcpWriteResult.ts new file mode 100644 index 000000000..a1ed2440e --- /dev/null +++ b/src/main/meshtasticTcpWriteResult.ts @@ -0,0 +1,31 @@ +/** Minimal Node socket fields used to classify expected TCP write races. */ +export interface MeshtasticTcpWriteSocket { + destroyed: boolean; + writableEnded: boolean; +} + +/** + * True when a write failed because the socket was already gone (reconnect race). + * Callers should resolve the IPC handler as `'no-socket'` instead of rejecting. + */ +export function meshtasticTcpWriteErrorIsNoSocket( + sock: MeshtasticTcpWriteSocket, + err: NodeJS.ErrnoException, +): boolean { + if (sock.destroyed || sock.writableEnded) return true; + const code = err.code; + if ( + code === 'EPIPE' || + code === 'ECONNRESET' || + code === 'ERR_STREAM_DESTROYED' || + code === 'ERR_SOCKET_CLOSED' + ) { + return true; + } + const msg = err.message.toLowerCase(); + return ( + msg.includes('this socket has been destroyed') || + msg.includes('write after end') || + msg.includes('ended by the other party') + ); +} diff --git a/src/preload/index.ts b/src/preload/index.ts index cc8415c4e..964865839 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -1025,7 +1025,17 @@ contextBridge.exposeInMainWorld('electronAPI', { tcp: { connect: (host: string, port: number): Promise => ipcRenderer.invoke('meshtastic:tcp-connect', host, port), - write: (bytes: number[]): Promise => ipcRenderer.invoke('meshtastic:tcp-write', bytes), + write: async (bytes: number[]): Promise => { + const result: unknown = await ipcRenderer.invoke('meshtastic:tcp-write', bytes); + // Main resolves 'no-socket' so Electron does not log handler [error]. + // Reject here so TransportTcpIpc / the SDK see a failed write (do not replay bytes). + if (result === 'no-socket') { + throw new Error('meshtastic:tcp-write: no active socket'); + } + if (result !== undefined) { + throw new Error('meshtastic:tcp-write: unexpected result'); + } + }, disconnect: (): Promise => ipcRenderer.invoke('meshtastic:tcp-disconnect'), onData: (cb: (bytes: Uint8Array) => void): (() => void) => { const handler = (_: unknown, bytes: Uint8Array) => { diff --git a/src/renderer/components/PeerGraphPanel.test.tsx b/src/renderer/components/PeerGraphPanel.test.tsx new file mode 100644 index 000000000..a203d2e78 --- /dev/null +++ b/src/renderer/components/PeerGraphPanel.test.tsx @@ -0,0 +1,158 @@ +import { fireEvent, render, screen } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; + +vi.mock('react-i18next', () => { + const t = (key: string, opts?: Record) => { + if (opts && 'distantLimit' in opts) { + return `${key}:${opts.distantLimit}`; + } + if (opts && 'shown' in opts && 'total' in opts && 'limit' in opts) { + return `${key}:${opts.shown}/${opts.total}/${opts.limit}`; + } + if (opts && 'shown' in opts && 'total' in opts) { + return `${key}:${opts.shown}/${opts.total}`; + } + if (opts && 'count' in opts) return `${key}:${opts.count}`; + return key; + }; + return { + useTranslation: () => ({ t }), + }; +}); + +vi.mock('@/renderer/lib/forceDirectedGraphLayout', async (importOriginal) => { + const actual = await importOriginal>(); + return { + ...actual, + startForceSimulationLoop: () => () => {}, + }; +}); + +import type { MeshNode } from '@/renderer/lib/types'; + +import PeerGraphPanel from './PeerGraphPanel'; + +function node(id: number, hopsAway: number): MeshNode { + return { + node_id: id, + long_name: `Node ${id}`, + short_name: `N${id}`, + hw_model: 'T-Beam', + snr: 5, + battery: 80, + last_heard: Date.now(), + latitude: null, + longitude: null, + hops_away: hopsAway, + }; +} + +function nodeMap(peerCount: number, hopsAway: number): Map { + const nodes = new Map([[1, node(1, 0)]]); + for (let i = 2; i <= peerCount + 1; i++) { + nodes.set(i, node(i, hopsAway)); + } + return nodes; +} + +describe('PeerGraphPanel', () => { + it('always shows the 400/48 limit note', () => { + render(); + expect(screen.getByText('peerGraph.visibleNodeLimitNote:400')).toBeInTheDocument(); + }); + + it('defaults to distant peers off and max hops 2', () => { + render(); + expect(screen.getByLabelText('peerGraph.showDistantPeers').checked).toBe( + false, + ); + expect(screen.getByLabelText('peerGraph.maxHopsFilter').value).toBe('2'); + }); + + it('shows the full 168-node set when distant peers are on and max hops is all', () => { + render(); + fireEvent.click(screen.getByLabelText('peerGraph.showDistantPeers')); + fireEvent.change(screen.getByLabelText('peerGraph.maxHopsFilter'), { + target: { value: 'all' }, + }); + expect(screen.queryByText(/peerGraph\.hiddenCount:/)).not.toBeInTheDocument(); + expect(screen.queryByText(/peerGraph\.hiddenCountLimit:/)).not.toBeInTheDocument(); + expect(screen.getByText(/peerGraph\.nodeCount:168/)).toBeInTheDocument(); + }); + + it('does not treat 1-hop nodes as distant: 80 one-hop nodes all show with distant off', () => { + render(); + expect(screen.getByText(/peerGraph\.nodeCount:81/)).toBeInTheDocument(); + expect(screen.queryByText(/peerGraph\.hiddenCount/)).not.toBeInTheDocument(); + }); + + it('uses graph-limit copy with 400 when distant peers are on and over the cap', () => { + render(); + fireEvent.click(screen.getByLabelText('peerGraph.showDistantPeers')); + fireEvent.change(screen.getByLabelText('peerGraph.maxHopsFilter'), { + target: { value: 'all' }, + }); + expect(screen.getByText(/peerGraph\.hiddenCountLimit:/)).toBeInTheDocument(); + expect(screen.getByText(/peerGraph\.hiddenCountLimit:/).textContent).toContain('/400'); + expect(screen.queryByText(/peerGraph\.hiddenCount:\d/)).not.toBeInTheDocument(); + }); + + it('shows 2-hop nodes at the Graph default (distant off, max hops 2)', () => { + const nodes = new Map([ + [1, node(1, 0)], + [2, node(2, 1)], + [3, node(3, 2)], + [4, node(4, 5)], + ]); + render(); + expect(screen.getByText('N2')).toBeInTheDocument(); + expect(screen.getByText('N3')).toBeInTheDocument(); + expect(screen.queryByText('N4')).not.toBeInTheDocument(); + fireEvent.change(screen.getByLabelText('peerGraph.maxHopsFilter'), { + target: { value: '8' }, + }); + expect(screen.getByText('N4')).toBeInTheDocument(); + fireEvent.change(screen.getByLabelText('peerGraph.maxHopsFilter'), { + target: { value: 'all' }, + }); + expect(screen.getByText('N2')).toBeInTheDocument(); + expect(screen.queryByText('N3')).not.toBeInTheDocument(); + expect(screen.queryByText('N4')).not.toBeInTheDocument(); + }); + + it('excludes unknown-hop nodes when max hops is numeric, even with distant on', () => { + const unknown: MeshNode = { ...node(3, 1), hops_away: undefined }; + const nodes = new Map([ + [1, node(1, 0)], + [2, node(2, 1)], + [3, unknown], + ]); + render(); + fireEvent.click(screen.getByLabelText('peerGraph.showDistantPeers')); + fireEvent.change(screen.getByLabelText('peerGraph.maxHopsFilter'), { + target: { value: '1' }, + }); + expect(screen.getByText('N2')).toBeInTheDocument(); + expect(screen.queryByText('N3')).not.toBeInTheDocument(); + fireEvent.change(screen.getByLabelText('peerGraph.maxHopsFilter'), { + target: { value: 'all' }, + }); + expect(screen.getByText('N3')).toBeInTheDocument(); + }); + + it('changes the rendered node count when max hops changes with distant peers on', () => { + const nodes = new Map([[1, node(1, 0)]]); + for (let i = 2; i <= 31; i++) nodes.set(i, node(i, 1)); + for (let i = 32; i <= 71; i++) nodes.set(i, node(i, 3)); + render(); + fireEvent.click(screen.getByLabelText('peerGraph.showDistantPeers')); + fireEvent.change(screen.getByLabelText('peerGraph.maxHopsFilter'), { + target: { value: 'all' }, + }); + expect(screen.getByText(/peerGraph\.nodeCount:71/)).toBeInTheDocument(); + fireEvent.change(screen.getByLabelText('peerGraph.maxHopsFilter'), { + target: { value: '1' }, + }); + expect(screen.getByText(/peerGraph\.nodeCount:31/)).toBeInTheDocument(); + }); +}); diff --git a/src/renderer/components/PeerGraphPanel.tsx b/src/renderer/components/PeerGraphPanel.tsx index ec9ff2f31..a1e143119 100644 --- a/src/renderer/components/PeerGraphPanel.tsx +++ b/src/renderer/components/PeerGraphPanel.tsx @@ -12,10 +12,15 @@ import { type SimNodeState, startForceSimulationLoop, } from '@/renderer/lib/forceDirectedGraphLayout'; +import { + TOPOLOGY_GRAPH_DISTANT_NODE_CAP, + topologyGraphVisibleNodeCap, +} from '@/renderer/lib/topologyGraphLimits'; import type { MeshNode } from '@/renderer/lib/types'; import { useSvgPanZoom } from '@/renderer/lib/useSvgPanZoom'; import { TopologyHopFilterControls } from './TopologyHopFilterControls'; +import { TopologyVisibleLimitNote } from './TopologyVisibleLimitNote'; interface PeerGraphPanelProps { nodes: Map; @@ -230,6 +235,11 @@ export default function PeerGraphPanel({ nodes, myNodeId, onNodeClick }: PeerGra maxHopsAllLabel={t('peerGraph.maxHopsAll')} maxHopsOptionLabel={(hops) => t('peerGraph.maxHopsOption', { count: hops })} /> +