From 99d3275d174ca8a9b533462bd7962fbdb5402f9a Mon Sep 17 00:00:00 2001 From: Joey Stanford Date: Sat, 8 Aug 2026 11:42:47 -0600 Subject: [PATCH 1/5] feat(meshcore): pace multi-chunk chat sends to reduce repeater drops Split MeshCore messages ([i/N] chunks) previously fired back-to-back, so chunk 2+ TX could overlap chunk 1's repeater rebroadcast window on a half-duplex radio and get dropped by a busy repeater (see meshcore-dev/ MeshCore #2820, #1502). Add a shared 1s inter-chunk pacing clock mirroring the existing Meshtastic pacing, applied in both ChatComposer live sends and useChatOutbox drain so they cannot race. --- src/renderer/components/ChatComposer.test.tsx | 74 +++++++++++- src/renderer/components/ChatComposer.tsx | 5 + src/renderer/hooks/useChatOutbox.test.ts | 33 +++++- src/renderer/hooks/useChatOutbox.ts | 4 + .../lib/meshcoreTextSendPacing.test.ts | 106 ++++++++++++++++++ src/renderer/lib/meshcoreTextSendPacing.ts | 55 +++++++++ src/renderer/lib/timeConstants.ts | 11 ++ 7 files changed, 286 insertions(+), 2 deletions(-) create mode 100644 src/renderer/lib/meshcoreTextSendPacing.test.ts create mode 100644 src/renderer/lib/meshcoreTextSendPacing.ts diff --git a/src/renderer/components/ChatComposer.test.tsx b/src/renderer/components/ChatComposer.test.tsx index 07e84ba68..dcc99c665 100644 --- a/src/renderer/components/ChatComposer.test.tsx +++ b/src/renderer/components/ChatComposer.test.tsx @@ -11,8 +11,12 @@ import { floodScopeOverridesStorageKey, loadFloodScopeOverridesInitial, } from '@/renderer/lib/chatPanelProtocolStorage'; +import { resetMeshcoreTextSendPacingForTests } from '@/renderer/lib/meshcoreTextSendPacing'; import { resetMeshtasticTextSendPacingForTests } from '@/renderer/lib/meshtasticTextSendPacing'; -import { MESHTASTIC_TEXT_CHUNK_SEND_INTERVAL_MS } from '@/renderer/lib/timeConstants'; +import { + MESHCORE_TEXT_CHUNK_SEND_INTERVAL_MS, + MESHTASTIC_TEXT_CHUNK_SEND_INTERVAL_MS, +} from '@/renderer/lib/timeConstants'; import { ChatComposer } from './ChatComposer'; @@ -67,6 +71,7 @@ describe('ChatComposer', () => { beforeEach(() => { localStorage.clear(); resetMeshtasticTextSendPacingForTests(); + resetMeshcoreTextSendPacingForTests(); }); it('has no axe violations when connected', async () => { @@ -411,6 +416,73 @@ describe('ChatComposer', () => { } }); + it('paces meshcore multi-chunk sends so a split message does not flood a busy repeater', async () => { + // A back-to-back split send lets chunk 2 overlap chunk 1's repeater rebroadcast + // window on a half-duplex radio; chunks must be spaced by the pacing interval. + vi.useFakeTimers(); + try { + const onSendChunk = vi.fn().mockResolvedValue(undefined); + render( + , + ); + const textarea = screen.getByRole('textbox'); + // MeshCore channel payload limit is ~158 bytes; 200 chars forces a 2-part split. + fireEvent.change(textarea, { target: { value: 'a'.repeat(200) } }); + fireEvent.click(screen.getByRole('button', { name: 'Send 2 parts' })); + + await vi.advanceTimersByTimeAsync(0); + expect(onSendChunk).toHaveBeenCalledTimes(1); + expect(onSendChunk).toHaveBeenNthCalledWith( + 1, + expect.stringContaining('[1/2]'), + expect.objectContaining({ chunkIndex: 0 }), + ); + + await vi.advanceTimersByTimeAsync(MESHCORE_TEXT_CHUNK_SEND_INTERVAL_MS - 100); + expect(onSendChunk).toHaveBeenCalledTimes(1); + + await vi.advanceTimersByTimeAsync(200); + expect(onSendChunk).toHaveBeenCalledTimes(2); + expect(onSendChunk).toHaveBeenNthCalledWith( + 2, + expect.stringContaining('[2/2]'), + expect.objectContaining({ chunkIndex: 1 }), + ); + } finally { + vi.useRealTimers(); + } + }); + + it('does not delay single-chunk meshcore sends', async () => { + vi.useFakeTimers(); + try { + const onSendChunk = vi.fn().mockResolvedValue(undefined); + render( + , + ); + const textarea = screen.getByRole('textbox'); + fireEvent.change(textarea, { target: { value: 'hello' } }); + fireEvent.click(screen.getByRole('button', { name: 'Send' })); + + await vi.advanceTimersByTimeAsync(0); + expect(onSendChunk).toHaveBeenCalledTimes(1); + } finally { + vi.useRealTimers(); + } + }); + it('hides GIF button when MeshCore Open wire compat is disabled', () => { render( { beforeEach(() => { resetMeshtasticTextSendPacingForTests(); + resetMeshcoreTextSendPacingForTests(); vi.mocked(mockOutbox.list).mockClear(); vi.mocked(mockOutbox.add).mockClear(); vi.mocked(mockOutbox.updateStatus).mockClear(); @@ -216,6 +221,32 @@ describe('useChatOutbox', () => { } }); + it('paces successive meshcore sends within one drain so a split message does not flood', async () => { + // Drained MeshCore chunks must be spaced so chunk 2 does not overlap chunk 1's + // repeater rebroadcast window on a busy mesh. + vi.useFakeTimers(); + try { + const rowA = makeEntry({ id: 40, protocol: 'meshcore', payload: 'first' }); + const rowB = makeEntry({ id: 41, protocol: 'meshcore', payload: 'second' }); + vi.mocked(mockOutbox.list).mockResolvedValue([rowA, rowB]); + const sendFn = vi.fn().mockResolvedValue(undefined); + renderHook(() => useChatOutbox({ protocol: 'meshcore', isSendAvailable: true, sendFn })); + + await vi.advanceTimersByTimeAsync(0); + expect(sendFn).toHaveBeenCalledTimes(1); + expect(sendFn).toHaveBeenNthCalledWith(1, 'first', 0, undefined, undefined); + + await vi.advanceTimersByTimeAsync(MESHCORE_TEXT_CHUNK_SEND_INTERVAL_MS - 100); + expect(sendFn).toHaveBeenCalledTimes(1); + + await vi.advanceTimersByTimeAsync(200); + expect(sendFn).toHaveBeenCalledTimes(2); + expect(sendFn).toHaveBeenNthCalledWith(2, 'second', 0, undefined, undefined); + } finally { + vi.useRealTimers(); + } + }); + it('does not drain when isSendAvailable is false', async () => { const entry = makeEntry({ id: 9 }); vi.mocked(mockOutbox.list).mockResolvedValue([entry]); diff --git a/src/renderer/hooks/useChatOutbox.ts b/src/renderer/hooks/useChatOutbox.ts index f37ae1984..884634c67 100644 --- a/src/renderer/hooks/useChatOutbox.ts +++ b/src/renderer/hooks/useChatOutbox.ts @@ -4,6 +4,7 @@ import type { MeshProtocol } from '@/renderer/lib/types'; import type { OutboxEntry, OutboxEntryInput, OutboxStatus } from '@/shared/electron-api.types'; import { registerChatOutboxDrainListener } from '../lib/chatOutboxDrain'; +import { withMeshcoreTextSendPacing } from '../lib/meshcoreTextSendPacing'; import { withMeshtasticTextSendPacing } from '../lib/meshtasticTextSendPacing'; export type { OutboxEntry }; @@ -198,6 +199,9 @@ export function useChatOutbox({ // firmware's TEXT_MESSAGE_APP RATE_LIMIT_EXCEEDED window. if (protocol === 'meshtastic') { await withMeshtasticTextSendPacing(sendRow); + } else if (protocol === 'meshcore') { + // Space MeshCore chunk sends so a drained split message does not flood a busy repeater. + await withMeshcoreTextSendPacing(sendRow); } else { await sendRow(); } diff --git a/src/renderer/lib/meshcoreTextSendPacing.test.ts b/src/renderer/lib/meshcoreTextSendPacing.test.ts new file mode 100644 index 000000000..56fca529e --- /dev/null +++ b/src/renderer/lib/meshcoreTextSendPacing.test.ts @@ -0,0 +1,106 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { + resetMeshcoreTextSendPacingForTests, + withMeshcoreTextSendPacing, +} from './meshcoreTextSendPacing'; +import { MESHCORE_TEXT_CHUNK_SEND_INTERVAL_MS } from './timeConstants'; + +describe('withMeshcoreTextSendPacing', () => { + beforeEach(() => { + resetMeshcoreTextSendPacingForTests(); + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + resetMeshcoreTextSendPacingForTests(); + }); + + it('does not delay the first send', async () => { + const send = vi.fn().mockResolvedValue('ok'); + const pending = withMeshcoreTextSendPacing(send); + await vi.advanceTimersByTimeAsync(0); + await expect(pending).resolves.toBe('ok'); + expect(send).toHaveBeenCalledTimes(1); + }); + + it('paces a second send from completion of the first, not from start', async () => { + // Regression: stamping before await send() would let a slow first write shrink the + // inter-chunk gap so chunk 2 overlaps chunk 1's repeater rebroadcast window. + const slowSend = vi.fn().mockImplementation( + () => + new Promise((resolve) => { + setTimeout(resolve, 800); + }), + ); + const second = vi.fn().mockResolvedValue(undefined); + + const firstPending = withMeshcoreTextSendPacing(slowSend); + await vi.advanceTimersByTimeAsync(800); + await firstPending; + + const secondPending = withMeshcoreTextSendPacing(second); + await vi.advanceTimersByTimeAsync(MESHCORE_TEXT_CHUNK_SEND_INTERVAL_MS - 100); + expect(second).not.toHaveBeenCalled(); + + await vi.advanceTimersByTimeAsync(200); + await secondPending; + expect(second).toHaveBeenCalledTimes(1); + }); + + it('stamps even when send rejects so the next attempt still waits', async () => { + const failing = vi.fn().mockRejectedValue(new Error('radio busy')); + const next = vi.fn().mockResolvedValue(undefined); + + const first = withMeshcoreTextSendPacing(failing); + const firstExpectation = expect(first).rejects.toThrow('radio busy'); + await vi.advanceTimersByTimeAsync(0); + await firstExpectation; + + const secondPending = withMeshcoreTextSendPacing(next); + await vi.advanceTimersByTimeAsync(MESHCORE_TEXT_CHUNK_SEND_INTERVAL_MS - 100); + expect(next).not.toHaveBeenCalled(); + + await vi.advanceTimersByTimeAsync(200); + await secondPending; + expect(next).toHaveBeenCalledTimes(1); + }); + + it('serializes concurrent callers so overlapping waits cannot both send early', async () => { + // Without a queue, Composer + outbox could both pass the gap check and stamp after + // overlapping sends — shrinking the radio-visible interval below the pacing window. + const order: string[] = []; + const makeSend = (label: string, durationMs: number) => + vi.fn().mockImplementation( + () => + new Promise((resolve) => { + order.push(`start:${label}`); + setTimeout(() => { + order.push(`end:${label}`); + resolve(); + }, durationMs); + }), + ); + + const first = makeSend('a', 100); + const second = makeSend('b', 50); + + const firstPending = withMeshcoreTextSendPacing(first); + const secondPending = withMeshcoreTextSendPacing(second); + + await vi.advanceTimersByTimeAsync(100); + await firstPending; + expect(second).not.toHaveBeenCalled(); + + await vi.advanceTimersByTimeAsync(MESHCORE_TEXT_CHUNK_SEND_INTERVAL_MS - 100); + expect(second).not.toHaveBeenCalled(); + + await vi.advanceTimersByTimeAsync(200); + await secondPending; + + expect(first).toHaveBeenCalledTimes(1); + expect(second).toHaveBeenCalledTimes(1); + expect(order).toEqual(['start:a', 'end:a', 'start:b', 'end:b']); + }); +}); diff --git a/src/renderer/lib/meshcoreTextSendPacing.ts b/src/renderer/lib/meshcoreTextSendPacing.ts new file mode 100644 index 000000000..f3d25abbb --- /dev/null +++ b/src/renderer/lib/meshcoreTextSendPacing.ts @@ -0,0 +1,55 @@ +import { MESHCORE_TEXT_CHUNK_SEND_INTERVAL_MS } from './timeConstants'; + +/** + * Shared completion timestamp for MeshCore chat chunk sends (channel / DM / room). + * Module-level so ChatComposer multi-chunk sends and useChatOutbox drain share one clock + * and cannot race into a back-to-back flood burst through busy repeaters. + */ +let lastMeshcoreTextSendAtMs = 0; + +/** + * Serializes concurrent pacing callers (Composer + outbox drain) so two waiters cannot + * both pass the gap check and hit the radio inside the inter-chunk window. + */ +let meshcoreTextSendChain: Promise = Promise.resolve(); + +/** Test-only: clear the shared pacing clock between cases. */ +export function resetMeshcoreTextSendPacingForTests(): void { + lastMeshcoreTextSendAtMs = 0; + meshcoreTextSendChain = Promise.resolve(); +} + +/** + * Wait until the MeshCore chunk-send slot is free, run `send`, then stamp completion. + * Stamping after `send` settles (not before) keeps the next gap measured from when the + * prior attempt finished — including IPC / companion TX work — so a slow write cannot + * shrink the radio-visible interval below the inter-chunk pacing window. + * + * Concurrent callers are queued on a module-level promise chain so ChatComposer and + * useChatOutbox cannot race the shared clock. + */ +export async function withMeshcoreTextSendPacing(send: () => Promise | T): Promise { + const run = async (): Promise => { + if (lastMeshcoreTextSendAtMs > 0) { + const wait = MESHCORE_TEXT_CHUNK_SEND_INTERVAL_MS - (Date.now() - lastMeshcoreTextSendAtMs); + if (wait > 0) { + await new Promise((resolve) => { + setTimeout(resolve, wait); + }); + } + } + try { + return await send(); + } finally { + lastMeshcoreTextSendAtMs = Date.now(); + } + }; + + const next = meshcoreTextSendChain.then(run, run); + // Keep the chain alive after rejections so later callers still serialize. + meshcoreTextSendChain = next.then( + () => undefined, + () => undefined, + ); + return next; +} diff --git a/src/renderer/lib/timeConstants.ts b/src/renderer/lib/timeConstants.ts index baa898428..1505ef995 100644 --- a/src/renderer/lib/timeConstants.ts +++ b/src/renderer/lib/timeConstants.ts @@ -303,6 +303,17 @@ export const NOMAD_PAGE_FETCH_DEBOUNCE_MS = 300; */ export const MESHTASTIC_TEXT_CHUNK_SEND_INTERVAL_MS = 2.5 * MS_PER_SECOND; +/** + * Minimum gap between successive MeshCore chat chunk sends (channel / DM / room). + * MeshCore firmware has no PhoneAPI rate limit like Meshtastic, but a multi-part + * split message blasted back-to-back becomes a self-inflicted mini flood storm: + * chunk 2's TX overlaps chunk 1's repeater rebroadcast window on a half-duplex + * radio, so busy repeaters can drop one part (see meshcore-dev/MeshCore #2820, + * #1502). A ~1s client-side gap reduces that overlap without needing firmware + * changes. Shared clock + serialized chain so composer and outbox drain cannot race. + */ +export const MESHCORE_TEXT_CHUNK_SEND_INTERVAL_MS = 1 * MS_PER_SECOND; + /** * Renderer safety hangup for optimistic LXST dial when WS never reaches Established. * Slightly above rsLXST `outgoing_call_timeout` (70s). From ad588df7ede66b8e9fb39394842411985671fd26 Mon Sep 17 00:00:00 2001 From: Joey Stanford Date: Sat, 8 Aug 2026 12:29:32 -0600 Subject: [PATCH 2/5] feat(meshcore): single-packet messages with soft fast-send warning Disable multi-part (chunked) chat sends on MeshCore across channel, DM, and room contexts. Repeaters routinely drop some `[i/N]` parts on a busy mesh, so recipients silently get incomplete messages; capping each send at one packet keeps messages reliable (meshcore-dev/MeshCore #1502, #2820). - chatComposerLimits: add getMaxChunks(protocol) (meshcore=1); splitChatMessage returns null for over-limit MeshCore text; totalMax excludes the [i/N] prefix. - ChatComposer: protocol-aware over-limit / hint text plus a prominent single-packet callout explaining why longer text is blocked and what to do. - Add a MeshCore-only, non-blocking fast-send advisory (meshcoreSendRateNotice.ts, MESHCORE_FAST_SEND_WARN_INTERVAL_MS = 5s) shown when sends happen back-to-back; it never blocks, disables, or delays the send. - Remove the ineffective 1s MeshCore chunk pacing (did not gate on airtime); Meshtastic send pacing is unchanged. Inbound multi-part is still merged. - Update README, parity doc, AGENTS.md, and troubleshooting; add i18n keys. --- AGENTS.md | 1 + README.md | 3 +- docs/meshcore-meshtastic-parity.md | 4 +- docs/troubleshooting.md | 2 +- src/renderer/components/ChatComposer.test.tsx | 170 +++++++++++++----- src/renderer/components/ChatComposer.tsx | 121 ++++++++++++- src/renderer/hooks/useChatOutbox.test.ts | 41 ++--- src/renderer/hooks/useChatOutbox.ts | 4 - src/renderer/lib/chatComposerLimits.test.ts | 82 +++++++-- src/renderer/lib/chatComposerLimits.ts | 45 +++-- .../lib/meshcoreSendRateNotice.test.ts | 43 +++++ src/renderer/lib/meshcoreSendRateNotice.ts | 27 +++ .../lib/meshcoreTextSendPacing.test.ts | 106 ----------- src/renderer/lib/meshcoreTextSendPacing.ts | 55 ------ src/renderer/lib/timeConstants.ts | 16 +- src/renderer/locales/cs/translation.json | 14 +- src/renderer/locales/de/translation.json | 14 +- src/renderer/locales/en/translation.json | 12 +- src/renderer/locales/es/translation.json | 14 +- src/renderer/locales/fr/translation.json | 14 +- src/renderer/locales/id/translation.json | 14 +- src/renderer/locales/it/translation.json | 14 +- src/renderer/locales/ja/translation.json | 14 +- src/renderer/locales/ko/translation.json | 14 +- src/renderer/locales/nl/translation.json | 14 +- src/renderer/locales/pl/translation.json | 14 +- src/renderer/locales/pt-BR/translation.json | 14 +- src/renderer/locales/ru/translation.json | 14 +- src/renderer/locales/tr/translation.json | 14 +- src/renderer/locales/uk/translation.json | 14 +- src/renderer/locales/zh/translation.json | 14 +- 31 files changed, 627 insertions(+), 315 deletions(-) create mode 100644 src/renderer/lib/meshcoreSendRateNotice.test.ts create mode 100644 src/renderer/lib/meshcoreSendRateNotice.ts delete mode 100644 src/renderer/lib/meshcoreTextSendPacing.test.ts delete mode 100644 src/renderer/lib/meshcoreTextSendPacing.ts diff --git a/AGENTS.md b/AGENTS.md index abf3356c1..b0c66bb28 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -281,6 +281,7 @@ Panels: `src/renderer/components/`. New tabs: `lazyTabPanels.ts` / `lazyAppPanel ### Chat Panel - **Components:** `ChatPanel.tsx` (channel/DM UI) + shared `ChatComposer.tsx` (drafts, mentions, chunking, spellcheck, emoji; also used by `RoomsPanel.tsx`). Reticulum DM **Share as paper** / **Scan paper** via `ChatDmPaperControls.tsx` + `createReticulumPaperMessage.ts`. Scroll-at-bottom helper: `chatScrollUtils.ts` (`getDistFromChatBottom`). +- **MeshCore is single-packet (no multi-part split):** `getMaxChunks('meshcore') === 1` in `chatComposerLimits.ts`, so `splitChatMessage` returns `null` for over-limit MeshCore text (channel/DM/room), the composer shows an `overMax` `meshcoreSingleNotice` callout, and Send is disabled — do **not** reintroduce MeshCore chunking (busy repeaters drop split parts: meshcore-dev/MeshCore #1502 / #2820). Meshtastic/Reticulum keep the 9-chunk auto-split. A non-blocking fast-send advisory (`meshcoreSendRateNotice.ts`, `MESHCORE_FAST_SEND_WARN_INTERVAL_MS` = 5s) warns on rapid MeshCore sends but never blocks/delays. Inbound multi-part from other clients is still merged. - **Payload / links:** `ChatPayloadText.tsx` — mention highlighting, search marks, URL linkification; link previews via `chat:fetchLinkPreview` (`src/main/fetchLinkPreview.ts`): Open Graph for HTML pages; **YouTube** watch/shorts/youtu.be via oEmbed + thumbnail; **direct image URLs** (path extension via `chatDirectImageUrl.ts` or raster `Content-Type`) return `kind: 'image'` and render as inline embeds (`ChatInlineImage` / `DirectImageEmbed`); OG/YouTube use card layout. Security: DNS-pinned undici `Agent`, private/loopback blocked, magic-byte MIME sniff (`safeRasterImageMime.ts`), HTTPS-only image embeds, 10s fetch / 3s DNS, 64 KiB HTML cap, **2 MiB** image fetch cap (256 KiB cache payload cap), LRU caches, single-flight dedup (renderer map capped). Previews load even when scrolled up. LXMF attachment rasters: `chat:readReticulumAttachmentAsDataUrl` (`reticulum-attachment-image.ts`; path jail, magic-byte MIME, SVG rejected, 2 MiB, IPC rate limit) → `ReticulumAttachmentLine`. Reply quotes: `replyPreview.ts`. - **Storage helpers:** `src/renderer/lib/chatPanelProtocolStorage.ts` — drafts (`mesh-client:drafts:`), open DM tabs, last-read, per-view mute (`mesh-client:mutedViews:`), starred (`mesh-client:starred:`, cap 200), MeshCore flood-scope overrides per chat view (`mesh-client:floodScopeOverrides:`, channel or DM `viewKey`). - **Notifications:** `src/renderer/lib/chatNotifications.ts` — `playMessageNotification(type)` via Web Audio: `channel` = single 880 Hz pulse (150 ms); `dm` / `reply` = dual pulse (587.33 Hz then 783.99 Hz, 50 ms each, 35 ms gap). Resumes suspended `AudioContext` when the window is hidden/minimized. Type selection in `chatUnreadCounts.ts` (`resolveChatNotificationType`, `pickAudibleNotificationType`; batch priority reply > dm > channel). **ChatPanel** plays when the user is on Chat but reading another view; **App** plays for other panels / backgrounded window (avoids double beep). Meshtastic hidden-window desktop notifications are visual-only (`silent: true` in `meshtasticRouterSideEffects.ts`); typed Web Audio from App owns sound. Global mute `mesh-client:notifMuted`; per-view mute in `mutedViews`. Main-process **tray** icon shows unread when chat or MeshCore Rooms traffic arrives while backgrounded (`src/main/index.ts` `buildTrayIcon`). diff --git a/README.md b/README.md index 9355bdaaa..76ef8113d 100644 --- a/README.md +++ b/README.md @@ -184,7 +184,7 @@ These sections apply to the two LoRa companion-radio stacks. Reticulum uses the - Send/receive messages across channels with per-transport delivery badges and delivery ACK / failure states - **Durable outbox**: outgoing messages are queued in SQLite and retried until delivered; survive app restarts and connection drops -- **Long message chunking**: messages over the payload limit are auto-split into sequential `[N/T]`-prefixed chunks (word-boundary split, max 9 chunks); MeshCore MQTT-only connections are guarded from sending when no RF path is available +- **Long message chunking (Meshtastic / Reticulum)**: messages over the payload limit are auto-split into sequential `[N/T]`-prefixed chunks (word-boundary split, max 9 chunks). **MeshCore is single-packet**: each message is sent as one radio packet and longer text is blocked with an explanatory notice (busy repeaters drop split parts — see [Limitations](#limitations)). MeshCore MQTT-only connections are also guarded from sending when no RF path is available - **Shared composer** (`ChatComposer`): drafts, mentions, chunking, spellcheck, and emoji picker used by **Chat** and **MeshCore Rooms**; right‑click misspelling replacements (Electron spellchecker for both protocols) - **Emoji reactions / tapbacks**: Meshtastic — 12 quick-pick reactions plus compose emoji (native panel on macOS/Windows; `emoji-picker-element` on Linux); wire tapbacks decode payload UTF-8 glyphs (flags, ZWJ sequences, and legacy index 1–12). MeshCore — same picker UX; **default** outbound tapbacks and text replies use keyless companion wire `@[Display Name] …` (inbound keyed `@[Name#key]`, Open `r:HASH:INDEX`, and `g:GIFID` also parsed). Optional **MeshCore Open compatibility** in App settings enables keyed replies, `r:` reactions, and Giphy GIF send — see [docs/meshcore-meshtastic-parity.md](docs/meshcore-meshtastic-parity.md#meshcore-emoji-reactions-tapbacks); reply-to-message with quoted preview in bubble (including room BBS posts) - **System tray**: docked/minimized on macOS and Windows shows an unread indicator when chat or MeshCore **Rooms** traffic arrives while the window is in the background @@ -383,6 +383,7 @@ Architecture and API: [docs/reticulum.md](docs/reticulum.md). Games wire parity: - **MQTT → RF (MeshCore JSON)**: Not supported; MeshCore MQTT is chat ingest only. - **Meshtastic - PKC remote admin**: Configure-node-over-MQTT is not supported; a connected local RF radio is required to reach remote nodes (firmware 2.5+). - **MeshCore - MQTT (JSON v1)**: The Connection tab can connect to an MQTT broker in MeshCore mode using a small JSON chat envelope (see [docs/meshcore-meshtastic-parity.md](docs/meshcore-meshtastic-parity.md)). This is separate from Meshtastic's protobuf MQTT pipeline. +- **MeshCore - single-packet messages (no multi-part split)**: mesh-client sends each MeshCore chat/DM/room message as a single radio packet (max ~130-160 characters depending on context and sender name) and **blocks** longer text with an explanatory notice in the composer. Splitting a long message into numbered `[i/N]` parts is unreliable on a busy mesh: repeaters routinely drop some parts, so the recipient silently gets an incomplete message. To keep messages reliable, mesh-client does not split them — shorten the text or send it as a few separate shorter messages. A non-blocking advisory also appears if you send messages faster than the mesh can relay them (within ~5s). Upstream context: repeater token-bucket / rate-limit drops [meshcore-dev/MeshCore#1502](https://github.com/meshcore-dev/MeshCore/issues/1502), busy-mesh collisions / packet loss [meshcore-dev/MeshCore#2820](https://github.com/meshcore-dev/MeshCore/issues/2820), and client anti-spam context [meshcore-dev/MeshCore#3053](https://github.com/meshcore-dev/MeshCore/issues/3053). Inbound multi-part messages from other clients are still merged for display. - **MeshCore - partial routing diagnostics**: MeshCore supports `route_flapping` / `path_instability` (PathUpdated events) and `weak_link` (when `hasPerHopSnr` and a trace is completed). Distance-based `hop_goblin` / close-in `bad_route` are Meshtastic-only (`hasDistanceBasedHopAnomalies`). Full hop-anomaly detection and Meshtastic-style LocalStats RF findings require Meshtastic packets; MeshCore provides its own RF findings (Elevated Noise Floor, Excessive Flooding) from Repeater Status packet stats. **Foreign LoRa** tables render on the Meshtastic tab only (MeshCore may record overhear internally). - **MeshCore - channel editing**: Can add/edit/delete channels (name + PSK) via the Radio tab, but does not expose Meshtastic-style full protobuf config. Radio parameters (frequency, bandwidth, spreading factor, coding rate, TX power) can be set via the Radio tab. - **MeshCore - remote telemetry availability**: `getTelemetry` requires the remote node to have environment sensors. A timeout is returned if the node has no sensor data. diff --git a/docs/meshcore-meshtastic-parity.md b/docs/meshcore-meshtastic-parity.md index 45c6a6a85..911604b03 100644 --- a/docs/meshcore-meshtastic-parity.md +++ b/docs/meshcore-meshtastic-parity.md @@ -36,7 +36,7 @@ Shared UI gates use `ProtocolCapabilities` in [`src/renderer/lib/radio/BaseRadio | 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` | Same `ChatComposer` in `ChatPanel` and `RoomsPanel` | **App** (shared) | +| 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) | @@ -54,7 +54,7 @@ Room servers (`hw_model === 'Room'`, contact type 3) are BBS nodes on the mesh. **Login:** Guest read-only uses **zero password bytes** when the server guest password is empty (**Continue read-only** on the login overlay). Admin login uses the configured password. Login RPC, queue, and path sync live under `src/renderer/lib/meshcoreRoom*.ts` (e.g. [`meshcoreRoomLoginRpc.ts`](../src/renderer/lib/meshcoreRoomLoginRpc.ts), [`meshcoreRoomLoginQueue.ts`](../src/renderer/lib/meshcoreRoomLoginQueue.ts)); timeouts are shorter on TCP and 0-hop paths ([`timeConstants.ts`](../src/renderer/lib/timeConstants.ts)). -**Posts:** Outbound room posts use plain UTF-8 (`TXT_TYPE_PLAIN`) after login. Inbound **SignedPlain** pushes include a four-byte author prefix; the **Rooms** UI strips it. Posts appear in the **Rooms** tab (channel `-2`), not Chat channel pills. +**Posts:** Outbound room posts use plain UTF-8 (`TXT_TYPE_PLAIN`) after login and are **single-packet** — mesh-client does not emit multi-part `[i/N]` room posts; over-limit text is blocked in the composer (same rationale as chat/DM). Inbound **SignedPlain** pushes include a four-byte author prefix; the **Rooms** UI strips it, and inbound multi-part from other clients is still merged for display. Posts appear in the **Rooms** tab (channel `-2`), not Chat channel pills. **Sync:** Firmware only **pushes new posts** after login (no history backfill). **Auto-sync** re-logs in on a timer while the radio stays connected (minimum 60 minutes per room, [`meshcoreRoomSyncScheduler.ts`](../src/renderer/lib/meshcoreRoomSyncScheduler.ts)). Saved passwords: SQLite `app_settings` (same pattern as Meshtastic remote admin keys). Session clears on disconnect. diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index e286de31c..c3ce86a8f 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -939,7 +939,7 @@ The client deduplicates overlapping RF and MQTT hears within **5 minutes** (cros **Long room posts show as `[1/2]`, `[2/2]`…**: -- MeshCore room wire limit is ~160 bytes per post. mesh-client splits longer text into multiple posts with `[i/N]` prefixes. The **Rooms** tab merges consecutive chunks from the same sender for display; other clients may show separate lines. +- MeshCore room wire limit is ~160 bytes per post. **mesh-client no longer splits outbound MeshCore posts** (chat, DM, or room) into `[i/N]` parts: on a busy mesh repeaters routinely drop some parts, so the recipient would silently get an incomplete message. Over-limit text is blocked in the composer with an explanatory notice — shorten it or send a few separate shorter messages (see [Limitations; MeshCore single-packet messages](../README.md#limitations)). **Inbound** multi-part posts from other clients are still merged: the **Rooms** tab merges consecutive `[i/N]` chunks from the same sender for display, though other clients may show them as separate lines. **Queue badge stuck at `Q: 255/256`**: diff --git a/src/renderer/components/ChatComposer.test.tsx b/src/renderer/components/ChatComposer.test.tsx index dcc99c665..6ddefa7ce 100644 --- a/src/renderer/components/ChatComposer.test.tsx +++ b/src/renderer/components/ChatComposer.test.tsx @@ -11,12 +11,9 @@ import { floodScopeOverridesStorageKey, loadFloodScopeOverridesInitial, } from '@/renderer/lib/chatPanelProtocolStorage'; -import { resetMeshcoreTextSendPacingForTests } from '@/renderer/lib/meshcoreTextSendPacing'; +import { resetMeshcoreSendRateForTests } from '@/renderer/lib/meshcoreSendRateNotice'; import { resetMeshtasticTextSendPacingForTests } from '@/renderer/lib/meshtasticTextSendPacing'; -import { - MESHCORE_TEXT_CHUNK_SEND_INTERVAL_MS, - MESHTASTIC_TEXT_CHUNK_SEND_INTERVAL_MS, -} from '@/renderer/lib/timeConstants'; +import { MESHTASTIC_TEXT_CHUNK_SEND_INTERVAL_MS } from '@/renderer/lib/timeConstants'; import { ChatComposer } from './ChatComposer'; @@ -34,7 +31,14 @@ vi.mock('react-i18next', () => ({ 'chatPanel.queueButton': 'Queue', 'chatPanel.replyingTo': 'Replying to', 'chatPanel.composeLimit.limitHint': `Up to ${opts?.limit} characters per message.`, + 'chatPanel.composeLimit.limitHintSingle': `Up to ${opts?.limit} characters. Single packet.`, 'chatPanel.composeLimit.splitHint': 'Sent as separate packets labeled [1/N], [2/N], …', + 'chatPanel.composeLimit.meshcoreSingleNotice.title': 'Message too long for MeshCore', + 'chatPanel.composeLimit.meshcoreSingleNotice.hint': + 'MeshCore sends one packet per message; longer messages can’t be sent.', + 'chatPanel.meshcoreFastSend.warning': + 'You’re sending faster than the mesh can relay. Leave a few seconds between messages.', + 'common.dismiss': 'Dismiss', 'chatPanel.meshcoreGifButton': 'Insert Giphy GIF', 'chatPanel.meshcoreGifPlaceholder': 'Giphy URL or id', 'chatPanel.meshcoreGifSend': 'Send GIF', @@ -59,6 +63,12 @@ vi.mock('react-i18next', () => ({ if (key === 'chatPanel.composeLimit.overMax') { return `Too long — maximum ${opts?.totalMax} characters (${opts?.maxParts} messages)`; } + if (key === 'chatPanel.composeLimit.overMaxSingle') { + return `Too long — MeshCore sends one packet per message (max ${opts?.limit} characters)`; + } + if (key === 'chatPanel.composeLimit.meshcoreSingleNotice.body') { + return `MeshCore sends each message as a single radio packet (up to ${opts?.limit} characters). Longer messages are dropped in parts, so mesh-client doesn't split them.`; + } if (key === 'chatPanel.composeLimit.sendParts') { return `Send ${opts?.count} parts`; } @@ -71,7 +81,7 @@ describe('ChatComposer', () => { beforeEach(() => { localStorage.clear(); resetMeshtasticTextSendPacingForTests(); - resetMeshcoreTextSendPacingForTests(); + resetMeshcoreSendRateForTests(); }); it('has no axe violations when connected', async () => { @@ -416,56 +426,120 @@ describe('ChatComposer', () => { } }); - it('paces meshcore multi-chunk sends so a split message does not flood a busy repeater', async () => { - // A back-to-back split send lets chunk 2 overlap chunk 1's repeater rebroadcast - // window on a half-duplex radio; chunks must be spaced by the pacing interval. - vi.useFakeTimers(); - try { - const onSendChunk = vi.fn().mockResolvedValue(undefined); - render( - , - ); - const textarea = screen.getByRole('textbox'); - // MeshCore channel payload limit is ~158 bytes; 200 chars forces a 2-part split. - fireEvent.change(textarea, { target: { value: 'a'.repeat(200) } }); - fireEvent.click(screen.getByRole('button', { name: 'Send 2 parts' })); + it('sends a single-chunk meshcore message once', async () => { + const onSendChunk = vi.fn().mockResolvedValue(undefined); + render( + , + ); + const textarea = screen.getByRole('textbox'); + fireEvent.change(textarea, { target: { value: 'hello' } }); + fireEvent.click(screen.getByRole('button', { name: 'Send' })); - await vi.advanceTimersByTimeAsync(0); + await waitFor(() => { expect(onSendChunk).toHaveBeenCalledTimes(1); - expect(onSendChunk).toHaveBeenNthCalledWith( - 1, - expect.stringContaining('[1/2]'), - expect.objectContaining({ chunkIndex: 0 }), - ); + }); + expect(onSendChunk).toHaveBeenCalledWith('hello', expect.objectContaining({ chunkIndex: 0 })); + // No single-packet callout for an in-limit message. + expect(screen.queryByText('Message too long for MeshCore')).toBeNull(); + }); + + it('rightfully fails to send an over-length meshcore message (no split, blocked)', async () => { + const onSendChunk = vi.fn().mockResolvedValue(undefined); + render( + , + ); + const textarea = screen.getByRole('textbox'); + // MeshCore channel payload limit is ~130-158 bytes; 200 chars is over one packet. + const longText = 'a'.repeat(200); + fireEvent.change(textarea, { target: { value: longText } }); + + // The single-packet callout explains why longer text is blocked. + const note = await screen.findByRole('note'); + expect(note).toHaveTextContent('Message too long for MeshCore'); + + // Send is disabled... + const sendButton = screen.getByRole('button', { name: 'Send' }); + expect(sendButton).toBeDisabled(); - await vi.advanceTimersByTimeAsync(MESHCORE_TEXT_CHUNK_SEND_INTERVAL_MS - 100); + // ...and attempting to send via Enter is a no-op (handleSend guard), never split. + fireEvent.keyDown(textarea, { key: 'Enter' }); + await Promise.resolve(); + expect(onSendChunk).not.toHaveBeenCalled(); + + // Draft is retained so the user can shorten and resend. + expect((textarea as HTMLTextAreaElement).value).toBe(longText); + }); + + it('exposes a warn-phase hint about single-packet sends', () => { + render( + , + ); + const textarea = screen.getByRole('textbox'); + // ~85% of a ~157-char channel limit → warn phase (not over), surfacing the ⓘ hint. + fireEvent.change(textarea, { target: { value: 'a'.repeat(140) } }); + expect( + screen.getByLabelText( + 'MeshCore sends one packet per message; longer messages can’t be sent.', + ), + ).toBeInTheDocument(); + }); + + it('shows a non-blocking fast-send warning when two meshcore sends happen within 5s', async () => { + const onSendChunk = vi.fn().mockResolvedValue(undefined); + render( + , + ); + const textarea = screen.getByRole('textbox'); + + fireEvent.change(textarea, { target: { value: 'first' } }); + fireEvent.click(screen.getByRole('button', { name: 'Send' })); + await waitFor(() => { expect(onSendChunk).toHaveBeenCalledTimes(1); + }); + // First send: not too fast, no warning. + expect(screen.queryByRole('status')).toBeNull(); - await vi.advanceTimersByTimeAsync(200); + fireEvent.change(textarea, { target: { value: 'second' } }); + fireEvent.click(screen.getByRole('button', { name: 'Send' })); + await waitFor(() => { expect(onSendChunk).toHaveBeenCalledTimes(2); - expect(onSendChunk).toHaveBeenNthCalledWith( - 2, - expect.stringContaining('[2/2]'), - expect.objectContaining({ chunkIndex: 1 }), - ); - } finally { - vi.useRealTimers(); - } + }); + // Second send within 5s: both sends went through (never blocked) and the advisory shows. + const warning = await screen.findByRole('status'); + expect(warning).toHaveTextContent('sending faster than the mesh'); }); - it('does not delay single-chunk meshcore sends', async () => { + it('does not show the fast-send warning for meshtastic (meshcore-only advisory)', async () => { vi.useFakeTimers(); try { const onSendChunk = vi.fn().mockResolvedValue(undefined); render( { />, ); const textarea = screen.getByRole('textbox'); - fireEvent.change(textarea, { target: { value: 'hello' } }); + fireEvent.change(textarea, { target: { value: 'first' } }); fireEvent.click(screen.getByRole('button', { name: 'Send' })); - await vi.advanceTimersByTimeAsync(0); - expect(onSendChunk).toHaveBeenCalledTimes(1); + fireEvent.change(textarea, { target: { value: 'second' } }); + fireEvent.click(screen.getByRole('button', { name: 'Send' })); + // Meshtastic send pacing delays the second send by the pacing interval; advance past it. + await vi.advanceTimersByTimeAsync(MESHTASTIC_TEXT_CHUNK_SEND_INTERVAL_MS + 100); + expect(onSendChunk).toHaveBeenCalledTimes(2); + expect(screen.queryByRole('status')).toBeNull(); } finally { vi.useRealTimers(); } diff --git a/src/renderer/components/ChatComposer.tsx b/src/renderer/components/ChatComposer.tsx index 47748e9b6..a1771d471 100644 --- a/src/renderer/components/ChatComposer.tsx +++ b/src/renderer/components/ChatComposer.tsx @@ -20,6 +20,7 @@ import { type ComposerWireContext, computeComposerLimitStatus, getComposerWireOverhead, + getMaxChunks, MAX_CHUNKS, splitChatMessage, } from '../lib/chatComposerLimits'; @@ -43,8 +44,9 @@ import { normalizeMeshcoreGifOutboundWire, parseMeshcoreGifId, } from '../lib/meshcoreGifWire'; -import { withMeshcoreTextSendPacing } from '../lib/meshcoreTextSendPacing'; +import { isMeshcoreSendTooFast, recordMeshcoreSend } from '../lib/meshcoreSendRateNotice'; import { withMeshtasticTextSendPacing } from '../lib/meshtasticTextSendPacing'; +import { MESHCORE_FAST_SEND_WARN_INTERVAL_MS } from '../lib/timeConstants'; import { HelpTooltip } from './HelpTooltip'; import MentionAutocomplete, { buildMentionCandidates } from './MentionAutocomplete'; import { useToast } from './Toast'; @@ -204,8 +206,10 @@ export function ChatComposer({ const [mentionQuery, setMentionQuery] = useState(null); const [mentionTriggerPos, setMentionTriggerPos] = useState(0); const [mentionSelectedIdx, setMentionSelectedIdx] = useState(0); + const [meshcoreFastSendWarn, setMeshcoreFastSendWarn] = useState(false); const inputRef = useRef(null); + const meshcoreFastSendWarnTimerRef = useRef | null>(null); const emojiPickerRef = useRef(null); const floodScopeMenuButtonRef = useRef(null); const floodScopeMenuRef = useRef(null); @@ -440,6 +444,34 @@ export function ChatComposer({ [protocol, viewKey], ); + const dismissMeshcoreFastSendWarn = useCallback(() => { + if (meshcoreFastSendWarnTimerRef.current) { + clearTimeout(meshcoreFastSendWarnTimerRef.current); + meshcoreFastSendWarnTimerRef.current = null; + } + setMeshcoreFastSendWarn(false); + }, []); + + // Advisory only — surface a non-blocking "sending too fast" banner that auto-dismisses. + const triggerMeshcoreFastSendWarn = useCallback(() => { + if (meshcoreFastSendWarnTimerRef.current) { + clearTimeout(meshcoreFastSendWarnTimerRef.current); + } + setMeshcoreFastSendWarn(true); + meshcoreFastSendWarnTimerRef.current = setTimeout(() => { + setMeshcoreFastSendWarn(false); + meshcoreFastSendWarnTimerRef.current = null; + }, MESHCORE_FAST_SEND_WARN_INTERVAL_MS); + }, []); + + useEffect(() => { + return () => { + if (meshcoreFastSendWarnTimerRef.current) { + clearTimeout(meshcoreFastSendWarnTimerRef.current); + } + }; + }, []); + const handleSend = useCallback(async () => { if (!input.trim() || sending || disabled) return; const draftSnapshot = input; @@ -504,6 +536,9 @@ export function ChatComposer({ setSending(true); setChatActionError(null); + // Advisory fast-send cadence: capture before recording this send so the warning reflects + // proximity to the *previous* MeshCore send. Never blocks or delays the send. + const meshcoreTooFast = protocol === 'meshcore' && isMeshcoreSendTooFast(); try { for (let i = 0; i < textsToSend.length; i++) { const sendChunk = () => @@ -522,14 +557,18 @@ export function ChatComposer({ // a second locally-originated text within ~2s (RATE_LIMIT_EXCEEDED). if (protocol === 'meshtastic') { await withMeshtasticTextSendPacing(sendChunk); - } else if (protocol === 'meshcore') { - // Space MeshCore split-message chunks so chunk 2+ does not overlap chunk 1's - // repeater rebroadcast window and get dropped by a busy repeater. - await withMeshcoreTextSendPacing(sendChunk); } else { await sendChunk(); } } + if (protocol === 'meshcore') { + recordMeshcoreSend(); + if (meshcoreTooFast) { + triggerMeshcoreFastSendWarn(); + } else { + dismissMeshcoreFastSendWarn(); + } + } rememberFloodScopeIfNeeded(floodScopeOverride); clearSentDraft(draftSnapshot); setMentionQuery(null); @@ -597,6 +636,8 @@ export function ChatComposer({ viewKey, wireOverheadFirstChunk, meshcoreOpenWireCompat, + triggerMeshcoreFastSendWarn, + dismissMeshcoreFastSendWarn, ]); const sendGifWire = useCallback( @@ -820,9 +861,13 @@ export function ChatComposer({ ? t('chatPanel.composePlaceholderMqttOnly') : t('chatPanel.composePlaceholderDefault')); - const limitHintText = t('chatPanel.composeLimit.limitHint', { - limit: limitStatus.singleMessageLimit, - }); + // MeshCore sends a single radio packet (no multi-part `[i/N]` split): over-limit text is + // blocked with an explanatory callout rather than auto-split into parts that busy repeaters drop. + const singlePacketProtocol = getMaxChunks(protocol) <= 1; + + const limitHintText = singlePacketProtocol + ? t('chatPanel.composeLimit.limitHintSingle', { limit: limitStatus.singleMessageLimit }) + : t('chatPanel.composeLimit.limitHint', { limit: limitStatus.singleMessageLimit }); const showQueueButton = allowOutbox && (!isConnected || (isMqttOnly && protocol === 'meshcore')); @@ -845,6 +890,11 @@ export function ChatComposer({ const counterMainText = (() => { if (limitStatus.phase === 'overMax') { + if (singlePacketProtocol) { + return t('chatPanel.composeLimit.overMaxSingle', { + limit: limitStatus.totalMaxChars, + }); + } return t('chatPanel.composeLimit.overMax', { totalMax: limitStatus.totalMaxChars, maxParts: MAX_CHUNKS, @@ -1039,6 +1089,29 @@ export function ChatComposer({ )} + {meshcoreFastSendWarn && ( +
+ + + {t('chatPanel.meshcoreFastSend.warning')} + + +
+ )} + {limitHintText} @@ -1407,6 +1480,38 @@ export function ChatComposer({ )} + {singlePacketProtocol && limitStatus.phase === 'warn' && ( + + + ⓘ + + + )} + + )} + + {singlePacketProtocol && limitStatus.phase === 'overMax' && ( +
+ + + + {t('chatPanel.composeLimit.meshcoreSingleNotice.title')} + + + {t('chatPanel.composeLimit.meshcoreSingleNotice.body', { + limit: limitStatus.totalMaxChars, + })} + +
)} diff --git a/src/renderer/hooks/useChatOutbox.test.ts b/src/renderer/hooks/useChatOutbox.test.ts index 3b6528c15..c6f16ae23 100644 --- a/src/renderer/hooks/useChatOutbox.test.ts +++ b/src/renderer/hooks/useChatOutbox.test.ts @@ -1,12 +1,8 @@ import { renderHook, waitFor } from '@testing-library/react'; import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { resetMeshcoreTextSendPacingForTests } from '@/renderer/lib/meshcoreTextSendPacing'; import { resetMeshtasticTextSendPacingForTests } from '@/renderer/lib/meshtasticTextSendPacing'; -import { - MESHCORE_TEXT_CHUNK_SEND_INTERVAL_MS, - MESHTASTIC_TEXT_CHUNK_SEND_INTERVAL_MS, -} from '@/renderer/lib/timeConstants'; +import { MESHTASTIC_TEXT_CHUNK_SEND_INTERVAL_MS } from '@/renderer/lib/timeConstants'; import type { OutboxEntry } from '@/shared/electron-api.types'; import { useChatOutbox } from './useChatOutbox'; @@ -38,7 +34,6 @@ describe('useChatOutbox', () => { beforeEach(() => { resetMeshtasticTextSendPacingForTests(); - resetMeshcoreTextSendPacingForTests(); vi.mocked(mockOutbox.list).mockClear(); vi.mocked(mockOutbox.add).mockClear(); vi.mocked(mockOutbox.updateStatus).mockClear(); @@ -221,30 +216,20 @@ describe('useChatOutbox', () => { } }); - it('paces successive meshcore sends within one drain so a split message does not flood', async () => { - // Drained MeshCore chunks must be spaced so chunk 2 does not overlap chunk 1's - // repeater rebroadcast window on a busy mesh. - vi.useFakeTimers(); - try { - const rowA = makeEntry({ id: 40, protocol: 'meshcore', payload: 'first' }); - const rowB = makeEntry({ id: 41, protocol: 'meshcore', payload: 'second' }); - vi.mocked(mockOutbox.list).mockResolvedValue([rowA, rowB]); - const sendFn = vi.fn().mockResolvedValue(undefined); - renderHook(() => useChatOutbox({ protocol: 'meshcore', isSendAvailable: true, sendFn })); - - await vi.advanceTimersByTimeAsync(0); - expect(sendFn).toHaveBeenCalledTimes(1); - expect(sendFn).toHaveBeenNthCalledWith(1, 'first', 0, undefined, undefined); - - await vi.advanceTimersByTimeAsync(MESHCORE_TEXT_CHUNK_SEND_INTERVAL_MS - 100); - expect(sendFn).toHaveBeenCalledTimes(1); + it('drains successive meshcore sends within one drain without client pacing', async () => { + // MeshCore chunk pacing was removed (it did not gate on airtime); a drain should send + // all eligible MeshCore rows without waiting on a client-side interval. + const rowA = makeEntry({ id: 40, protocol: 'meshcore', payload: 'first' }); + const rowB = makeEntry({ id: 41, protocol: 'meshcore', payload: 'second' }); + vi.mocked(mockOutbox.list).mockResolvedValue([rowA, rowB]); + const sendFn = vi.fn().mockResolvedValue(undefined); + renderHook(() => useChatOutbox({ protocol: 'meshcore', isSendAvailable: true, sendFn })); - await vi.advanceTimersByTimeAsync(200); + await waitFor(() => { expect(sendFn).toHaveBeenCalledTimes(2); - expect(sendFn).toHaveBeenNthCalledWith(2, 'second', 0, undefined, undefined); - } finally { - vi.useRealTimers(); - } + }); + expect(sendFn).toHaveBeenNthCalledWith(1, 'first', 0, undefined, undefined); + expect(sendFn).toHaveBeenNthCalledWith(2, 'second', 0, undefined, undefined); }); it('does not drain when isSendAvailable is false', async () => { diff --git a/src/renderer/hooks/useChatOutbox.ts b/src/renderer/hooks/useChatOutbox.ts index 884634c67..f37ae1984 100644 --- a/src/renderer/hooks/useChatOutbox.ts +++ b/src/renderer/hooks/useChatOutbox.ts @@ -4,7 +4,6 @@ import type { MeshProtocol } from '@/renderer/lib/types'; import type { OutboxEntry, OutboxEntryInput, OutboxStatus } from '@/shared/electron-api.types'; import { registerChatOutboxDrainListener } from '../lib/chatOutboxDrain'; -import { withMeshcoreTextSendPacing } from '../lib/meshcoreTextSendPacing'; import { withMeshtasticTextSendPacing } from '../lib/meshtasticTextSendPacing'; export type { OutboxEntry }; @@ -199,9 +198,6 @@ export function useChatOutbox({ // firmware's TEXT_MESSAGE_APP RATE_LIMIT_EXCEEDED window. if (protocol === 'meshtastic') { await withMeshtasticTextSendPacing(sendRow); - } else if (protocol === 'meshcore') { - // Space MeshCore chunk sends so a drained split message does not flood a busy repeater. - await withMeshcoreTextSendPacing(sendRow); } else { await sendRow(); } diff --git a/src/renderer/lib/chatComposerLimits.test.ts b/src/renderer/lib/chatComposerLimits.test.ts index 14a7dc4cc..7bdf4b06b 100644 --- a/src/renderer/lib/chatComposerLimits.test.ts +++ b/src/renderer/lib/chatComposerLimits.test.ts @@ -7,6 +7,7 @@ import { countMessageWireBytes, getChatPayloadLimit, getComposerWireOverhead, + getMaxChunks, getMeshcoreChannelPayloadLimit, getMeshcoreRoomPayloadLimit, MAX_CHUNKS, @@ -191,7 +192,27 @@ describe('computeComposerLimitStatus', () => { senderDisplayName: 'x'.repeat(32), }); expect(longName.singleMessageLimit).toBe(126); - expect(longName.phase).toBe('split'); + // MeshCore is single-packet: 130 chars over the 126-char limit is blocked (overMax), not split. + expect(longName.phase).toBe('overMax'); + expect(longName.chunkCount).toBe(0); + }); + + it('returns overMax (never split) for meshcore text longer than one packet', () => { + const status = computeComposerLimitStatus('a'.repeat(200), 'meshcore', { + composerContext: 'channel', + senderDisplayName: 'A', + }); + expect(status.phase).toBe('overMax'); + expect(status.chunkCount).toBe(0); + }); + + it('meshcore totalMaxChars excludes the [i/N] prefix (single packet)', () => { + const status = computeComposerLimitStatus('a'.repeat(10), 'meshcore', { + composerContext: 'channel', + senderDisplayName: 'A', + }); + // Single-packet: the whole payload limit is usable text, no prefix reserved. + expect(status.totalMaxChars).toBe(status.singleMessageLimit); }); it('returns overMax when text exceeds total max chars', () => { @@ -204,6 +225,30 @@ describe('computeComposerLimitStatus', () => { }); }); +describe('getMaxChunks', () => { + it('returns 1 for meshcore (single packet, no multi-part split)', () => { + expect(getMaxChunks('meshcore')).toBe(1); + }); + + it('returns MAX_CHUNKS for meshtastic and reticulum', () => { + expect(getMaxChunks('meshtastic')).toBe(MAX_CHUNKS); + expect(getMaxChunks('reticulum')).toBe(MAX_CHUNKS); + }); +}); + +describe('computeComposerTotalMaxChars', () => { + it('reserves no [i/N] prefix when maxChunks <= 1', () => { + expect(computeComposerTotalMaxChars(133, 0, 1)).toBe(133); + expect(computeComposerTotalMaxChars(133, 7, 1)).toBe(126); + }); + + it('reserves prefix + spans multiple chunks for maxChunks > 1', () => { + const total = computeComposerTotalMaxChars(MESHTASTIC_PAYLOAD_LIMIT, 0, MAX_CHUNKS); + const prefixLen = `[${MAX_CHUNKS}/${MAX_CHUNKS}] `.length; + expect(total).toBe(MAX_CHUNKS * (MESHTASTIC_PAYLOAD_LIMIT - prefixLen)); + }); +}); + describe('splitChatMessage', () => { it('returns [] when text fits in one message (meshtastic)', () => { const text = 'a'.repeat(228); @@ -215,25 +260,30 @@ describe('splitChatMessage', () => { expect(splitChatMessage(text, 'meshcore')).toEqual([]); }); - it('splits a message that exceeds the limit', () => { - const text = 'a'.repeat(200); - const chunks = splitChatMessage(text, 'meshcore'); + it('splits a message that exceeds the limit (meshtastic)', () => { + const text = 'a'.repeat(300); + const chunks = splitChatMessage(text, 'meshtastic'); expect(chunks).not.toBeNull(); expect(chunks!.length).toBe(2); expect(chunks![0].startsWith('[1/2] ')).toBe(true); expect(chunks![1].startsWith('[2/2] ')).toBe(true); const bodies = chunks!.map((c) => c.replace(/^\[\d+\/\d+\] /, '')); - expect(bodies.join('').length).toBe(200); + expect(bodies.join('').length).toBe(300); + }); + + it('returns null for meshcore text longer than one packet (no multi-part split)', () => { + // MeshCore is capped at a single packet: over-limit text is rejected, never split. + expect(splitChatMessage('a'.repeat(200), 'meshcore')).toBeNull(); }); - it('prefers word boundaries when splitting', () => { - const limit = MESHCORE_PAYLOAD_LIMIT; + it('prefers word boundaries when splitting (meshtastic)', () => { + const limit = MESHTASTIC_PAYLOAD_LIMIT; const prefixLen = '[1/2] '.length; const bodySpace = limit - prefixLen; - const chunk1Words = 'word '.repeat(25); + const chunk1Words = 'word '.repeat(50); const rest = 'overflow words here'; const text = chunk1Words + rest; - const chunks = splitChatMessage(text, 'meshcore'); + const chunks = splitChatMessage(text, 'meshtastic'); expect(chunks).not.toBeNull(); const body0 = chunks![0].replace(/^\[\d+\/\d+\] /, ''); expect(body0.endsWith(' ')).toBe(false); @@ -288,14 +338,16 @@ describe('splitChatMessage', () => { expect(bodies.join('')).toBe(text); }); - it('returns null when text requires more than MAX_CHUNKS chunks', () => { - const text = 'x'.repeat(9 * 127 + 1); - expect(splitChatMessage(text, 'meshcore')).toBeNull(); + it('returns null when text requires more than MAX_CHUNKS chunks (meshtastic)', () => { + const bodyPerChunk = MESHTASTIC_PAYLOAD_LIMIT - '[9/9] '.length; + const text = 'x'.repeat(MAX_CHUNKS * bodyPerChunk + 1); + expect(splitChatMessage(text, 'meshtastic')).toBeNull(); }); - it('returns exactly MAX_CHUNKS chunks at the boundary (not null)', () => { - const text = 'x'.repeat(9 * 127); - const chunks = splitChatMessage(text, 'meshcore'); + it('returns exactly MAX_CHUNKS chunks at the boundary (not null) (meshtastic)', () => { + const bodyPerChunk = MESHTASTIC_PAYLOAD_LIMIT - '[9/9] '.length; + const text = 'x'.repeat(MAX_CHUNKS * bodyPerChunk); + const chunks = splitChatMessage(text, 'meshtastic'); expect(chunks).not.toBeNull(); expect(chunks!.length).toBe(MAX_CHUNKS); }); diff --git a/src/renderer/lib/chatComposerLimits.ts b/src/renderer/lib/chatComposerLimits.ts index efe8a6d50..9a12a5462 100644 --- a/src/renderer/lib/chatComposerLimits.ts +++ b/src/renderer/lib/chatComposerLimits.ts @@ -11,6 +11,17 @@ export const MESHCORE_PAYLOAD_LIMIT = 133; export const RETICULUM_LXMF_PAYLOAD_LIMIT = 4096; export const MAX_CHUNKS = 9; +/** + * Max chunks a composer will split a message into, per protocol. MeshCore is capped at a + * single packet (no multi-part `[i/N]` split): on a busy mesh, repeaters routinely drop some + * split parts, so the recipient silently gets an incomplete message. Meshtastic/Reticulum keep + * the `MAX_CHUNKS` (9) auto-split. See meshcore-dev/MeshCore #1502 / #2820. Inbound multi-part + * from other clients is unaffected (we still merge `[i/N]` on receive). + */ +export function getMaxChunks(protocol: MeshProtocol): number { + return protocol === 'meshcore' ? 1 : MAX_CHUNKS; +} + export const MESHCORE_WIRE_MAX = 160; export const MESHCORE_MAX_NAME_LEN = 32; export const MESHCORE_NAME_SUFFIX_LEN = 2; // ": " @@ -132,18 +143,22 @@ function takeCharsWithinByteBudget(chars: readonly string[], byteBudget: number) return count; } -/** Max user-typed characters across MAX_CHUNKS split messages. */ +/** Max user-typed characters across `maxChunks` split messages (1 = single-packet only). */ export function computeComposerTotalMaxChars( singleMessageLimit: number, wireOverheadFirstChunk = 0, + maxChunks = MAX_CHUNKS, ): number { - const prefixLen = `[${MAX_CHUNKS}/${MAX_CHUNKS}] `.length; + if (maxChunks <= 1) { + // Single packet only: no `[i/N]` prefix is reserved, only the first-chunk wire overhead. + const singleBody = singleMessageLimit - wireOverheadFirstChunk; + return singleBody > 0 ? singleBody : 0; + } + const prefixLen = `[${maxChunks}/${maxChunks}] `.length; const firstBody = singleMessageLimit - prefixLen - wireOverheadFirstChunk; const otherBody = singleMessageLimit - prefixLen; if (firstBody <= 0) return 0; - // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- Runtime guard protects external or callback-mutated state. - if (MAX_CHUNKS <= 1) return firstBody; - return firstBody + (MAX_CHUNKS - 1) * otherBody; + return firstBody + (maxChunks - 1) * otherBody; } export function computeComposerLimitStatus( @@ -173,7 +188,11 @@ export function computeComposerLimitStatus( const trimmed = text.trim(); const charCount = countMessageChars(trimmed); const showThreshold = Math.floor(singleMessageLimit * 0.8); - const totalMaxChars = computeComposerTotalMaxChars(singleMessageLimit, wireOverheadFirstChunk); + const totalMaxChars = computeComposerTotalMaxChars( + singleMessageLimit, + wireOverheadFirstChunk, + getMaxChunks(protocol), + ); const chunks = splitChatMessage(trimmed, protocol, singleMessageLimit, wireOverheadFirstChunk); @@ -203,7 +222,8 @@ export function computeComposerLimitStatus( /** * Split text into N chunks each prefixed "[i/N] " so every chunk fits in the protocol payload * limit. Returns [] when text fits in a single message (no chunking needed). Returns null when - * the text would require more than MAX_CHUNKS chunks. + * the text would require more than the protocol's max chunks (`getMaxChunks`); MeshCore is capped + * at 1, so any over-limit MeshCore text returns null (no multi-part split). * * Splitting prefers word boundaries; hard-splits only when a single token exceeds the available * body space. @@ -219,6 +239,7 @@ export function splitChatMessage( const limit = getChatPayloadLimit(protocol, payloadLimit); const trimmed = text.trim(); const overhead = Math.max(0, wireOverheadFirstChunk); + const maxChunks = getMaxChunks(protocol); function chunkBodies(prefixLen: number): string[] { const bodies: string[] = []; @@ -257,17 +278,21 @@ export function splitChatMessage( if (countMessageWireBytes(trimmed) + overhead <= limit) return []; - const estimatedPrefixLen = `[${MAX_CHUNKS}/${MAX_CHUNKS}] `.length; + // Protocols capped at a single packet (MeshCore) never split: over-limit text is rejected + // so the composer treats it as `overMax` and blocks the send. + if (maxChunks <= 1) return null; + + const estimatedPrefixLen = `[${maxChunks}/${maxChunks}] `.length; const bodies = chunkBodies(estimatedPrefixLen); - if (bodies.length > MAX_CHUNKS) return null; + if (bodies.length > maxChunks) return null; const total = bodies.length; const actualPrefixLen = `[1/${total}] `.length; const finalBodies = actualPrefixLen === estimatedPrefixLen ? bodies : chunkBodies(actualPrefixLen); - if (finalBodies.length > MAX_CHUNKS) return null; + if (finalBodies.length > maxChunks) return null; const finalTotal = finalBodies.length; return finalBodies.map((body, i) => `[${i + 1}/${finalTotal}] ${body}`); } diff --git a/src/renderer/lib/meshcoreSendRateNotice.test.ts b/src/renderer/lib/meshcoreSendRateNotice.test.ts new file mode 100644 index 000000000..6260ce1c6 --- /dev/null +++ b/src/renderer/lib/meshcoreSendRateNotice.test.ts @@ -0,0 +1,43 @@ +import { beforeEach, describe, expect, it } from 'vitest'; + +import { + isMeshcoreSendTooFast, + recordMeshcoreSend, + resetMeshcoreSendRateForTests, +} from './meshcoreSendRateNotice'; +import { MESHCORE_FAST_SEND_WARN_INTERVAL_MS } from './timeConstants'; + +describe('meshcoreSendRateNotice', () => { + beforeEach(() => { + resetMeshcoreSendRateForTests(); + }); + + it('is not too fast on the first send (no prior send recorded)', () => { + expect(isMeshcoreSendTooFast(1_000)).toBe(false); + }); + + it('is too fast when a second send happens within the warn interval', () => { + recordMeshcoreSend(1_000); + expect(isMeshcoreSendTooFast(1_000 + MESHCORE_FAST_SEND_WARN_INTERVAL_MS - 1)).toBe(true); + }); + + it('is not too fast once the warn interval has elapsed', () => { + recordMeshcoreSend(1_000); + expect(isMeshcoreSendTooFast(1_000 + MESHCORE_FAST_SEND_WARN_INTERVAL_MS)).toBe(false); + expect(isMeshcoreSendTooFast(1_000 + MESHCORE_FAST_SEND_WARN_INTERVAL_MS + 5_000)).toBe(false); + }); + + it('tracks the most recent send for the cadence check', () => { + recordMeshcoreSend(1_000); + recordMeshcoreSend(10_000); + // Measured from the latest send (10_000), not the first. + expect(isMeshcoreSendTooFast(10_000 + 1_000)).toBe(true); + expect(isMeshcoreSendTooFast(1_000 + 1_000)).toBe(true); + }); + + it('resetMeshcoreSendRateForTests clears the shared clock', () => { + recordMeshcoreSend(1_000); + resetMeshcoreSendRateForTests(); + expect(isMeshcoreSendTooFast(1_500)).toBe(false); + }); +}); diff --git a/src/renderer/lib/meshcoreSendRateNotice.ts b/src/renderer/lib/meshcoreSendRateNotice.ts new file mode 100644 index 000000000..3afe7f850 --- /dev/null +++ b/src/renderer/lib/meshcoreSendRateNotice.ts @@ -0,0 +1,27 @@ +import { MESHCORE_FAST_SEND_WARN_INTERVAL_MS } from './timeConstants'; + +/** + * App-wide timestamp of the last MeshCore chat send (channel / DM / room). MeshCore airtime is + * shared across every chat view, so the "sending too fast" cadence is global, not per-view or + * per-composer instance — two composers must observe the same clock. + */ +let lastMeshcoreSendAtMs = 0; + +/** Test-only: clear the shared fast-send clock between cases. */ +export function resetMeshcoreSendRateForTests(): void { + lastMeshcoreSendAtMs = 0; +} + +/** + * True when a MeshCore send happened within `MESHCORE_FAST_SEND_WARN_INTERVAL_MS` (5s) of `nowMs`. + * Used to surface a non-blocking advisory — this never blocks, disables, or delays the send. + */ +export function isMeshcoreSendTooFast(nowMs: number = Date.now()): boolean { + if (lastMeshcoreSendAtMs <= 0) return false; + return nowMs - lastMeshcoreSendAtMs < MESHCORE_FAST_SEND_WARN_INTERVAL_MS; +} + +/** Record that a MeshCore send just occurred, for the next fast-send cadence check. */ +export function recordMeshcoreSend(nowMs: number = Date.now()): void { + lastMeshcoreSendAtMs = nowMs; +} diff --git a/src/renderer/lib/meshcoreTextSendPacing.test.ts b/src/renderer/lib/meshcoreTextSendPacing.test.ts deleted file mode 100644 index 56fca529e..000000000 --- a/src/renderer/lib/meshcoreTextSendPacing.test.ts +++ /dev/null @@ -1,106 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; - -import { - resetMeshcoreTextSendPacingForTests, - withMeshcoreTextSendPacing, -} from './meshcoreTextSendPacing'; -import { MESHCORE_TEXT_CHUNK_SEND_INTERVAL_MS } from './timeConstants'; - -describe('withMeshcoreTextSendPacing', () => { - beforeEach(() => { - resetMeshcoreTextSendPacingForTests(); - vi.useFakeTimers(); - }); - - afterEach(() => { - vi.useRealTimers(); - resetMeshcoreTextSendPacingForTests(); - }); - - it('does not delay the first send', async () => { - const send = vi.fn().mockResolvedValue('ok'); - const pending = withMeshcoreTextSendPacing(send); - await vi.advanceTimersByTimeAsync(0); - await expect(pending).resolves.toBe('ok'); - expect(send).toHaveBeenCalledTimes(1); - }); - - it('paces a second send from completion of the first, not from start', async () => { - // Regression: stamping before await send() would let a slow first write shrink the - // inter-chunk gap so chunk 2 overlaps chunk 1's repeater rebroadcast window. - const slowSend = vi.fn().mockImplementation( - () => - new Promise((resolve) => { - setTimeout(resolve, 800); - }), - ); - const second = vi.fn().mockResolvedValue(undefined); - - const firstPending = withMeshcoreTextSendPacing(slowSend); - await vi.advanceTimersByTimeAsync(800); - await firstPending; - - const secondPending = withMeshcoreTextSendPacing(second); - await vi.advanceTimersByTimeAsync(MESHCORE_TEXT_CHUNK_SEND_INTERVAL_MS - 100); - expect(second).not.toHaveBeenCalled(); - - await vi.advanceTimersByTimeAsync(200); - await secondPending; - expect(second).toHaveBeenCalledTimes(1); - }); - - it('stamps even when send rejects so the next attempt still waits', async () => { - const failing = vi.fn().mockRejectedValue(new Error('radio busy')); - const next = vi.fn().mockResolvedValue(undefined); - - const first = withMeshcoreTextSendPacing(failing); - const firstExpectation = expect(first).rejects.toThrow('radio busy'); - await vi.advanceTimersByTimeAsync(0); - await firstExpectation; - - const secondPending = withMeshcoreTextSendPacing(next); - await vi.advanceTimersByTimeAsync(MESHCORE_TEXT_CHUNK_SEND_INTERVAL_MS - 100); - expect(next).not.toHaveBeenCalled(); - - await vi.advanceTimersByTimeAsync(200); - await secondPending; - expect(next).toHaveBeenCalledTimes(1); - }); - - it('serializes concurrent callers so overlapping waits cannot both send early', async () => { - // Without a queue, Composer + outbox could both pass the gap check and stamp after - // overlapping sends — shrinking the radio-visible interval below the pacing window. - const order: string[] = []; - const makeSend = (label: string, durationMs: number) => - vi.fn().mockImplementation( - () => - new Promise((resolve) => { - order.push(`start:${label}`); - setTimeout(() => { - order.push(`end:${label}`); - resolve(); - }, durationMs); - }), - ); - - const first = makeSend('a', 100); - const second = makeSend('b', 50); - - const firstPending = withMeshcoreTextSendPacing(first); - const secondPending = withMeshcoreTextSendPacing(second); - - await vi.advanceTimersByTimeAsync(100); - await firstPending; - expect(second).not.toHaveBeenCalled(); - - await vi.advanceTimersByTimeAsync(MESHCORE_TEXT_CHUNK_SEND_INTERVAL_MS - 100); - expect(second).not.toHaveBeenCalled(); - - await vi.advanceTimersByTimeAsync(200); - await secondPending; - - expect(first).toHaveBeenCalledTimes(1); - expect(second).toHaveBeenCalledTimes(1); - expect(order).toEqual(['start:a', 'end:a', 'start:b', 'end:b']); - }); -}); diff --git a/src/renderer/lib/meshcoreTextSendPacing.ts b/src/renderer/lib/meshcoreTextSendPacing.ts deleted file mode 100644 index f3d25abbb..000000000 --- a/src/renderer/lib/meshcoreTextSendPacing.ts +++ /dev/null @@ -1,55 +0,0 @@ -import { MESHCORE_TEXT_CHUNK_SEND_INTERVAL_MS } from './timeConstants'; - -/** - * Shared completion timestamp for MeshCore chat chunk sends (channel / DM / room). - * Module-level so ChatComposer multi-chunk sends and useChatOutbox drain share one clock - * and cannot race into a back-to-back flood burst through busy repeaters. - */ -let lastMeshcoreTextSendAtMs = 0; - -/** - * Serializes concurrent pacing callers (Composer + outbox drain) so two waiters cannot - * both pass the gap check and hit the radio inside the inter-chunk window. - */ -let meshcoreTextSendChain: Promise = Promise.resolve(); - -/** Test-only: clear the shared pacing clock between cases. */ -export function resetMeshcoreTextSendPacingForTests(): void { - lastMeshcoreTextSendAtMs = 0; - meshcoreTextSendChain = Promise.resolve(); -} - -/** - * Wait until the MeshCore chunk-send slot is free, run `send`, then stamp completion. - * Stamping after `send` settles (not before) keeps the next gap measured from when the - * prior attempt finished — including IPC / companion TX work — so a slow write cannot - * shrink the radio-visible interval below the inter-chunk pacing window. - * - * Concurrent callers are queued on a module-level promise chain so ChatComposer and - * useChatOutbox cannot race the shared clock. - */ -export async function withMeshcoreTextSendPacing(send: () => Promise | T): Promise { - const run = async (): Promise => { - if (lastMeshcoreTextSendAtMs > 0) { - const wait = MESHCORE_TEXT_CHUNK_SEND_INTERVAL_MS - (Date.now() - lastMeshcoreTextSendAtMs); - if (wait > 0) { - await new Promise((resolve) => { - setTimeout(resolve, wait); - }); - } - } - try { - return await send(); - } finally { - lastMeshcoreTextSendAtMs = Date.now(); - } - }; - - const next = meshcoreTextSendChain.then(run, run); - // Keep the chain alive after rejections so later callers still serialize. - meshcoreTextSendChain = next.then( - () => undefined, - () => undefined, - ); - return next; -} diff --git a/src/renderer/lib/timeConstants.ts b/src/renderer/lib/timeConstants.ts index 1505ef995..47bdbd5aa 100644 --- a/src/renderer/lib/timeConstants.ts +++ b/src/renderer/lib/timeConstants.ts @@ -304,15 +304,15 @@ export const NOMAD_PAGE_FETCH_DEBOUNCE_MS = 300; export const MESHTASTIC_TEXT_CHUNK_SEND_INTERVAL_MS = 2.5 * MS_PER_SECOND; /** - * Minimum gap between successive MeshCore chat chunk sends (channel / DM / room). - * MeshCore firmware has no PhoneAPI rate limit like Meshtastic, but a multi-part - * split message blasted back-to-back becomes a self-inflicted mini flood storm: - * chunk 2's TX overlaps chunk 1's repeater rebroadcast window on a half-duplex - * radio, so busy repeaters can drop one part (see meshcore-dev/MeshCore #2820, - * #1502). A ~1s client-side gap reduces that overlap without needing firmware - * changes. Shared clock + serialized chain so composer and outbox drain cannot race. + * Cadence below which a second MeshCore chat send (channel / DM / room) triggers a + * non-blocking "sending too fast" advisory. MeshCore floods each message across every + * repeater on the path, and each hop adds airtime plus random rebroadcast backoff, so a + * message typically needs ~5s to settle across a 2-3 hop mesh. Sending again inside that + * window risks the new packet colliding with the prior message's still-propagating flood, + * which busy repeaters can drop (see meshcore-dev/MeshCore #2820, #1502). This is advisory + * only — it never blocks, disables, or delays the send. */ -export const MESHCORE_TEXT_CHUNK_SEND_INTERVAL_MS = 1 * MS_PER_SECOND; +export const MESHCORE_FAST_SEND_WARN_INTERVAL_MS = 5 * MS_PER_SECOND; /** * Renderer safety hangup for optimistic LXST dial when WS never reaches Established. diff --git a/src/renderer/locales/cs/translation.json b/src/renderer/locales/cs/translation.json index 40a731ea0..61f7823ac 100644 --- a/src/renderer/locales/cs/translation.json +++ b/src/renderer/locales/cs/translation.json @@ -506,7 +506,14 @@ "splitHint": "Odesláno jako samostatné pakety označené [1/N], [2/N], …", "overMax": "Příliš dlouhé — maximum {{totalMax}} znaků ({{maxParts}} zpráv)", "sendParts_one": "Odeslat {{count}} díl", - "sendParts_other": "Odeslat {{count}} díly" + "sendParts_other": "Odeslat {{count}} díly", + "limitHintSingle": "Až {{limit}} znaků. MeshCore odešle každou zprávu jako jeden paket.", + "overMaxSingle": "Příliš dlouhé — MeshCore odešle jeden paket na zprávu (max. {{limit}} znaků)", + "meshcoreSingleNotice": { + "title": "Zpráva je pro MeshCore příliš dlouhá", + "body": "MeshCore odešle každou zprávu jako jeden rádiový paket (až {{limit}} znaků). Delší zprávy musí být rozděleny na číslované části, ale na zaneprázdněných síťových opakovačích některé z těchto částí běžně upouštějí — takže osoba, které posíláte zprávu, by obdržela neúplnou zprávu, aniž by to mohla říct. Aby byly zprávy spolehlivé, nejsou rozdělovány automaticky. Zkraťte tuto zprávu nebo ji odešlete jako několik samostatných kratších zpráv.", + "hint": "MeshCore odešle jeden paket na zprávu; rozdělené části jsou často upuštěny zaneprázdněnými opakovači, takže delší zprávy nelze odeslat." + } }, "sendFailed": "Odeslání se nezdařilo", "outboxStatusQueued": "Ve frontě", @@ -678,7 +685,10 @@ "reticulumSendStoringLocally": "Ukládání do místní doručené pošty šíření…", "reticulumSendStoredLocally": "Uloženo ve vaší místní doručené poště šíření (nedoručeno peerovi)", "sentViaLocalPropagation": "Místní doručená pošta šíření", - "reticulumSendTimeout": "Odeslání vypršelo. Zásobník Reticulum se možná spouští nebo je zaneprázdněný — zkuste to znovu." + "reticulumSendTimeout": "Odeslání vypršelo. Zásobník Reticulum se možná spouští nebo je zaneprázdněný — zkuste to znovu.", + "meshcoreFastSend": { + "warning": "Odesíláte rychleji, než může síť přenést. Na zaneprázdněné síti mohou opakovači vyslat zprávy odeslané blízko sebe — mezi zprávami nechte několik sekund." + } }, "chatPayload": { "mention": "Zmínit {{label}}", diff --git a/src/renderer/locales/de/translation.json b/src/renderer/locales/de/translation.json index 6adf83726..7e828cab4 100644 --- a/src/renderer/locales/de/translation.json +++ b/src/renderer/locales/de/translation.json @@ -506,7 +506,14 @@ "splitHint": "Gesendet als separate Pakete mit den Bezeichnungen [1/N], [2/N], …", "overMax": "Zu lang — maximal {{totalMax}} Zeichen ({{maxParts}} Nachrichten)", "sendParts_one": "{{count}} Teil senden", - "sendParts_other": "{{count}} Teile senden" + "sendParts_other": "{{count}} Teile senden", + "limitHintSingle": "Bis zu {{limit}} Zeichen. MeshCore sendet jede Nachricht als einzelnes Paket.", + "overMaxSingle": "Zu lang — MeshCore sendet ein Paket pro Nachricht (max. {{limit}} Zeichen)", + "meshcoreSingleNotice": { + "title": "Nachricht zu lang für MeshCore", + "body": "MeshCore sendet jede Nachricht als einzelnes Funkpaket (bis zu {{limit}} Zeichen). Längere Nachrichten müssen in nummerierte Teile aufgeteilt werden, aber bei einem vielbeschäftigten Netz lassen Repeater routinemäßig einige dieser Teile fallen — so erhält die Person, die Sie benachrichtigen, eine unvollständige Nachricht ohne Möglichkeit, dies zu sagen. Um Nachrichten zuverlässig zu halten, werden sie nicht automatisch geteilt. Kürzen Sie diese Nachricht oder senden Sie sie als einige separate kürzere Nachrichten.", + "hint": "MeshCore sendet ein Paket pro Nachricht; geteilte Teile werden oft von besetzten Repeatern gelöscht, sodass keine längeren Nachrichten gesendet werden können." + } }, "sendFailed": "Senden fehlgeschlagen.", "outboxStatusQueued": "In Warteschlange", @@ -676,7 +683,10 @@ "reticulumSendStoringLocally": "Wird in Ihrem lokalen Propagations-Posteingang gespeichert…", "reticulumSendStoredLocally": "Wird in Ihrem lokalen Propagations-Posteingang aufbewahrt (nicht an den Peer geliefert)", "sentViaLocalPropagation": "Lokaler Propagations-Posteingang", - "reticulumSendTimeout": "Senden abgelaufen. Der Reticulum-Stack startet möglicherweise oder ist beschäftigt — bitte erneut versuchen." + "reticulumSendTimeout": "Senden abgelaufen. Der Reticulum-Stack startet möglicherweise oder ist beschäftigt — bitte erneut versuchen.", + "meshcoreFastSend": { + "warning": "Sie senden schneller, als das Netz weiterleiten kann. In einem ausgelasteten Netz können Repeater Nachrichten, die nahe beieinander gesendet werden, ablegen — lassen Sie ein paar Sekunden zwischen den Nachrichten." + } }, "chatPayload": { "mention": "Erwähne {{label}}", diff --git a/src/renderer/locales/en/translation.json b/src/renderer/locales/en/translation.json index d039d4928..ed119c051 100644 --- a/src/renderer/locales/en/translation.json +++ b/src/renderer/locales/en/translation.json @@ -14,7 +14,7 @@ "loadingDialog": "Loading dialog", "loadingApp": "Loading Mesh Client", "meshtasticQueueTooltip": "Transmit queue: packets waiting to be sent. Green = low, amber = filling up, red = congested.", - "meshcoreQueueTooltip": "Because MeshCore sends messages rapidly, and we poll every 30 seconds, this should always be 0. If not 0, there is congestion.", + "meshcoreQueueTooltip": "Because MeshCore sends messages rapidly, and we poll every 30 seconds, this should always be 0. If not 0, there is congestion or program error.", "takRunning": "TAK running", "takClientLost": "TAK client lost", "takStopped": "TAK stopped", @@ -603,13 +603,23 @@ "replyingTo": "Replying to", "composeLimit": { "limitHint": "Up to {{limit}} characters per message. Longer text is split automatically.", + "limitHintSingle": "Up to {{limit}} characters. MeshCore sends each message as a single packet.", "approaching": "{{count}} / {{limit}}", "split": "{{count}} characters · {{parts}} messages", "splitHint": "Sent as separate packets labeled [1/N], [2/N], …", "overMax": "Too long — maximum {{totalMax}} characters ({{maxParts}} messages)", + "overMaxSingle": "Too long — MeshCore sends one packet per message (max {{limit}} characters)", + "meshcoreSingleNotice": { + "title": "Message too long for MeshCore", + "body": "MeshCore sends each message as a single radio packet (up to {{limit}} characters). Longer messages have to be split into numbered parts, but on a busy mesh repeaters routinely drop some of those parts — so the person you're messaging would receive an incomplete message with no way to tell. To keep messages reliable, they aren't split automatically. Shorten this message, or send it as a few separate shorter messages.", + "hint": "MeshCore sends one packet per message; split parts are often dropped by busy repeaters, so longer messages can't be sent." + }, "sendParts_one": "Send {{count}} part", "sendParts_other": "Send {{count}} parts" }, + "meshcoreFastSend": { + "warning": "You're sending faster than the mesh can relay. On a busy mesh, repeaters may drop messages sent close together — leave a few seconds between messages." + }, "copyMessage": "Copy message", "filterBySender": "Filter by sender", "clearSenderFilter": "Clear filter", diff --git a/src/renderer/locales/es/translation.json b/src/renderer/locales/es/translation.json index 4f17c368f..91a536711 100644 --- a/src/renderer/locales/es/translation.json +++ b/src/renderer/locales/es/translation.json @@ -506,7 +506,14 @@ "splitHint": "Enviado como paquetes separados etiquetados [1/N], [2/N],...", "overMax": "Demasiado largo: máximo {{totalMax}} caracteres ({{maxParts}} mensajes)", "sendParts_one": "Enviar {{count}} pieza", - "sendParts_other": "Enviar {{count}} piezas" + "sendParts_other": "Enviar {{count}} piezas", + "limitHintSingle": "Hasta {{limit}} caracteres. MeshCore envía cada mensaje como un solo paquete.", + "overMaxSingle": "Demasiado largo: MeshCore envía un paquete por mensaje (máx. {{limit}} caracteres)", + "meshcoreSingleNotice": { + "title": "Mensaje demasiado largo para MeshCore", + "body": "MeshCore envía cada mensaje como un solo paquete de radio (hasta {{limit}} caracteres). Los mensajes más largos deben dividirse en partes numeradas, pero en una malla ocupada, los repetidores dejan caer rutinariamente algunas de esas partes, por lo que la persona a la que estás enviando el mensaje recibiría un mensaje incompleto sin forma de saberlo. Para que los mensajes sean fiables, no se dividen automáticamente. Acorta este mensaje o envíalo como unos pocos mensajes más cortos por separado.", + "hint": "MeshCore envía un paquete por mensaje; las partes divididas a menudo son eliminadas por repetidores ocupados, por lo que no se pueden enviar mensajes más largos." + } }, "sendFailed": "Envío Fallido", "outboxStatusQueued": "En cola", @@ -676,7 +683,10 @@ "reticulumSendStoringLocally": "Guardando en su bandeja de entrada de propagación local...", "reticulumSendStoredLocally": "Se mantiene en su bandeja de entrada de propagación local (no se entrega al peer)", "sentViaLocalPropagation": "Bandeja de entrada de propagación local", - "reticulumSendTimeout": "El envío ha caducado. La pila Reticulum puede estar iniciándose o ocupada; inténtelo de nuevo." + "reticulumSendTimeout": "El envío ha caducado. La pila Reticulum puede estar iniciándose o ocupada; inténtelo de nuevo.", + "meshcoreFastSend": { + "warning": "Estás enviando más rápido de lo que la malla puede transmitir. En una malla ocupada, los repetidores pueden dejar caer mensajes enviados muy juntos; deje unos segundos entre mensajes." + } }, "chatPayload": { "mention": "Mencionar {{label}}", diff --git a/src/renderer/locales/fr/translation.json b/src/renderer/locales/fr/translation.json index dd2037bb4..54c08f824 100644 --- a/src/renderer/locales/fr/translation.json +++ b/src/renderer/locales/fr/translation.json @@ -506,7 +506,14 @@ "splitHint": "Envoyé sous forme de paquets séparés étiquetés [1/N], [2/N], …", "overMax": "Trop long — maximum {{totalMax}} caractères (messages {{maxParts}})", "sendParts_one": "Envoyer {{count}} pièce", - "sendParts_other": "Envoyer {{count}} pièces" + "sendParts_other": "Envoyer {{count}} pièces", + "limitHintSingle": "Jusqu'à {{limit}} caractères. MeshCore envoie chaque message sous la forme d'un seul paquet.", + "overMaxSingle": "Trop long — MeshCore envoie un paquet par message (maximum {{limit}} caractères)", + "meshcoreSingleNotice": { + "title": "Message trop long pour MeshCore", + "body": "MeshCore envoie chaque message sous la forme d'un seul paquet radio (jusqu'à {{limit}} caractères). Les messages plus longs doivent être divisés en parties numérotées, mais sur un maillage occupé, les répéteurs laissent régulièrement tomber certaines de ces parties — de sorte que la personne que vous envoyez recevrait un message incomplet sans aucun moyen de le dire. Pour que les messages restent fiables, ils ne sont pas divisés automatiquement. Raccourcissez ce message ou envoyez-le sous forme de quelques messages plus courts séparés.", + "hint": "MeshCore envoie un paquet par message ; les parties fractionnées sont souvent supprimées par des répéteurs occupés, de sorte que des messages plus longs ne peuvent pas être envoyés." + } }, "sendFailed": "Échec de l'envoi", "outboxStatusQueued": "En file d'attente", @@ -676,7 +683,10 @@ "reticulumSendStoringLocally": "Enregistrement dans votre boîte de réception de propagation locale…", "reticulumSendStoredLocally": "Conservé dans votre boîte de réception de propagation locale (non livré à l'homologue)", "sentViaLocalPropagation": "Boîte de réception de propagation locale", - "reticulumSendTimeout": "Envoi expiré. La pile Reticulum est peut-être en cours de démarrage ou occupée — réessayez." + "reticulumSendTimeout": "Envoi expiré. La pile Reticulum est peut-être en cours de démarrage ou occupée — réessayez.", + "meshcoreFastSend": { + "warning": "Vous envoyez plus vite que le maillage ne peut relayer. Sur un maillage occupé, les répéteurs peuvent déposer des messages envoyés à proximité les uns des autres — laissez quelques secondes entre les messages." + } }, "chatPayload": { "mention": "Mention {{label}}", diff --git a/src/renderer/locales/id/translation.json b/src/renderer/locales/id/translation.json index 23e70000c..c0c2db2bb 100644 --- a/src/renderer/locales/id/translation.json +++ b/src/renderer/locales/id/translation.json @@ -507,7 +507,14 @@ "splitHint": "Dikirim sebagai paket terpisah berlabel [1/N], [2/N], …", "overMax": "Terlalu panjang — maksimum {{totalMax}} karakter ({{maxParts}} pesan)", "sendParts_one": "Kirim {{count}} bagian", - "sendParts_other": "Kirim {{count}} bagian" + "sendParts_other": "Kirim {{count}} bagian", + "limitHintSingle": "Hingga {{limit}} karakter. MeshCore mengirimkan setiap pesan sebagai satu paket.", + "overMaxSingle": "Terlalu panjang — MeshCore mengirimkan satu paket per pesan (maks {{limit}} karakter)", + "meshcoreSingleNotice": { + "title": "Pesan terlalu panjang untuk MeshCore", + "body": "MeshCore mengirimkan setiap pesan sebagai satu paket radio (hingga {{limit}} karakter). Pesan yang lebih panjang harus dipecah menjadi beberapa bagian yang diberi nomor, namun pada mesh yang sibuk, repeater secara rutin membuang beberapa bagian tersebut — sehingga orang yang Anda kirimi pesan akan menerima pesan yang tidak lengkap dan tidak ada cara untuk mengatakannya. Agar pesan tetap dapat diandalkan, pesan tidak dibagi secara otomatis. Persingkat pesan ini, atau kirimkan sebagai beberapa pesan pendek terpisah.", + "hint": "MeshCore mengirimkan satu paket per pesan; bagian yang terpisah sering kali dijatuhkan oleh repeater yang sibuk, sehingga pesan yang lebih panjang tidak dapat dikirim." + } }, "sendFailed": "Pengiriman gagal", "outboxStatusQueued": "Dalam antrean", @@ -676,7 +683,10 @@ "reticulumSendStoringLocally": "Menyimpan ke kotak masuk propagasi lokal Anda…", "reticulumSendStoredLocally": "Disimpan di kotak masuk propagasi lokal Anda (tidak dikirim ke peer)", "sentViaLocalPropagation": "Kotak masuk propagasi lokal", - "reticulumSendTimeout": "Waktu pengiriman habis. Stack Reticulum mungkin sedang mulai atau sibuk — coba lagi." + "reticulumSendTimeout": "Waktu pengiriman habis. Stack Reticulum mungkin sedang mulai atau sibuk — coba lagi.", + "meshcoreFastSend": { + "warning": "Anda mengirim lebih cepat daripada yang dapat disampaikan oleh mesh. Pada mesh yang sibuk, repeater dapat menjatuhkan pesan yang dikirim secara berdekatan — menyisakan beberapa detik di antara pesan." + } }, "chatPayload": { "mention": "Sebutkan {{label}}", diff --git a/src/renderer/locales/it/translation.json b/src/renderer/locales/it/translation.json index 69e230d5e..1f855e7e2 100644 --- a/src/renderer/locales/it/translation.json +++ b/src/renderer/locales/it/translation.json @@ -506,7 +506,14 @@ "splitHint": "Inviati come pacchetti separati etichettati [1/N], [2/N],...", "overMax": "Troppo lungo — massimo {{totalMax}} caratteri ({{maxParts}} messaggi)", "sendParts_one": "Invia parte {{count}}", - "sendParts_other": "Invia parti {{count}}" + "sendParts_other": "Invia parti {{count}}", + "limitHintSingle": "Fino a {{limit}} caratteri. MeshCore invia ogni messaggio come un singolo pacchetto.", + "overMaxSingle": "Troppo lungo — MeshCore invia un pacchetto per messaggio (max {{limit}} caratteri)", + "meshcoreSingleNotice": { + "title": "Messaggio troppo lungo per MeshCore", + "body": "MeshCore invia ogni messaggio come un singolo pacchetto radio (fino a {{limit}} caratteri). I messaggi più lunghi devono essere suddivisi in parti numerate, ma su un ripetitore mesh occupato di solito alcune di queste parti cadono, quindi la persona a cui stai inviando messaggi riceverebbe un messaggio incompleto senza modo di dirlo. Per mantenere i messaggi affidabili, non vengono divisi automaticamente. Accorcia questo messaggio o invialo come pochi messaggi più brevi separati.", + "hint": "MeshCore invia un pacchetto per messaggio; le parti divise vengono spesso rilasciate da ripetitori occupati, quindi non è possibile inviare messaggi più lunghi." + } }, "sendFailed": "Invio non riuscito", "outboxStatusQueued": "In coda", @@ -676,7 +683,10 @@ "reticulumSendStoringLocally": "Salvataggio nella tua casella di posta di propagazione locale in corso...", "reticulumSendStoredLocally": "Conservato nella tua casella di posta di propagazione locale (non consegnato al peer)", "sentViaLocalPropagation": "Posta in arrivo propagazione locale", - "reticulumSendTimeout": "Timeout dell'invio. Lo stack Reticulum potrebbe essere in avvio o occupato. Riprova." + "reticulumSendTimeout": "Timeout dell'invio. Lo stack Reticulum potrebbe essere in avvio o occupato. Riprova.", + "meshcoreFastSend": { + "warning": "Stai inviando più velocemente di quanto la rete possa trasmettere. Su una mesh occupata, i ripetitori possono rilasciare i messaggi inviati vicini — lasciare alcuni secondi tra i messaggi." + } }, "chatPayload": { "mention": "Menziona {{label}}", diff --git a/src/renderer/locales/ja/translation.json b/src/renderer/locales/ja/translation.json index 09f4a0b3e..13c514a25 100644 --- a/src/renderer/locales/ja/translation.json +++ b/src/renderer/locales/ja/translation.json @@ -507,7 +507,14 @@ "splitHint": "[1/N]、[2/N]、…とラベル付けされた別々のパケットとして送信されます", "overMax": "長すぎます — 最大{{totalMax}}文字({{maxParts}}メッセージ)", "sendParts_one": "{{count}}件を送信", - "sendParts_other": "{{count}}件を送信" + "sendParts_other": "{{count}}件を送信", + "limitHintSingle": "最大{{limit}}文字。MeshCoreは、各メッセージを1つのパケットとして送信します。", + "overMaxSingle": "長すぎます— MeshCoreはメッセージごとに1パケットを送信します(最大{{limit}}文字)", + "meshcoreSingleNotice": { + "title": "MeshCoreのメッセージが長すぎます", + "body": "MeshCoreは、各メッセージを単一の無線パケット(最大{{limit}}文字)として送信します。長いメッセージは番号付きの部分に分割する必要がありますが、忙しいメッシュリピーターでは定期的にそれらの部分の一部が落とされるため、メッセージを送っている人は不完全なメッセージを受け取り、それを伝える方法がありません。信頼性を維持するため、メッセージは自動的に分割されません。このメッセージを短くするか、いくつかの短いメッセージとして送信してください。", + "hint": "MeshCoreはメッセージごとに1つのパケットを送信します。分割されたパーツはしばしばビジーリピーターによってドロップされるため、より長いメッセージを送信することはできません。" + } }, "sendFailed": "送信に失敗しました", "outboxStatusQueued": "待機リストに追加済み", @@ -676,7 +683,10 @@ "reticulumSendStoringLocally": "ローカルの伝播受信トレイに保存しています…", "reticulumSendStoredLocally": "ローカルの伝播受信トレイに保存されています(ピアには配信されません)", "sentViaLocalPropagation": "ローカル伝播受信トレイ", - "reticulumSendTimeout": "送信がタイムアウトしました。Reticulum スタックが起動中またはビジーの可能性があります — 再試行してください。" + "reticulumSendTimeout": "送信がタイムアウトしました。Reticulum スタックが起動中またはビジーの可能性があります — 再試行してください。", + "meshcoreFastSend": { + "warning": "メッシュが中継できるよりも速く送信しています。ビジーメッシュでは、リピーターが近くに送信されたメッセージをドロップすることがあります。メッセージの間に数秒残します。" + } }, "chatPayload": { "mention": "{{label}} について言及してください", diff --git a/src/renderer/locales/ko/translation.json b/src/renderer/locales/ko/translation.json index 40880182c..64010214d 100644 --- a/src/renderer/locales/ko/translation.json +++ b/src/renderer/locales/ko/translation.json @@ -507,7 +507,14 @@ "splitHint": "[1/N], [2/N], … 라벨이 붙은 별도의 패킷으로 전송됨", "overMax": "너무 깁니다 — 최대 {{totalMax}}자 ({{maxParts}}메시지)", "sendParts_one": "{{count}}개 보내기", - "sendParts_other": "{{count}}개 보내기" + "sendParts_other": "{{count}}개 보내기", + "limitHintSingle": "최대 {{limit}} 자. MeshCore는 각 메시지를 단일 패킷으로 전송합니다.", + "overMaxSingle": "너무 김 — MeshCore가 메시지당 하나의 패킷을 전송함 (최대 {{limit}} 자)", + "meshcoreSingleNotice": { + "title": "MeshCore에 보내기에 메시지가 너무 깁니다", + "body": "MeshCore는 각 메시지를 단일 무선 패킷으로 전송합니다 (최대 {{limit}} 자). 더 긴 메시는 번호가 매겨진 부분으로 나눠야 하지만, 바쁜 메시 리피터에서는 이러한 부분 중 일부를 일상적으로 드롭하므로 메시지를 보내는 사람에게 알릴 방법이 없는 불완전한 메시지가 전송됩니다. 메시지를 안정적으로 유지하기 위해 메시지는 자동으로 분할되지 않습니다. 이 메시지를 짧게 줄이거나 짧은 메시지 몇 개로 따로 보내세요.", + "hint": "MeshCore는 메시지당 하나의 패킷을 전송합니다. 바쁜 리피터가 분할 부분을 드롭하는 경우가 많으므로 더 긴 메시지를 보낼 수 없습니다." + } }, "sendFailed": "전송 실패", "outboxStatusQueued": "대기 중", @@ -676,7 +683,10 @@ "reticulumSendStoringLocally": "로컬 전파 받은 편지함에 저장 중...", "reticulumSendStoredLocally": "로컬 전파 받은 편지함에 보관됨 (피어에게 전달되지 않음)", "sentViaLocalPropagation": "로컬 전파 메시지함", - "reticulumSendTimeout": "전송 시간이 초과되었습니다. Reticulum 스택이 시작 중이거나 사용 중일 수 있습니다 — 다시 시도하세요." + "reticulumSendTimeout": "전송 시간이 초과되었습니다. Reticulum 스택이 시작 중이거나 사용 중일 수 있습니다 — 다시 시도하세요.", + "meshcoreFastSend": { + "warning": "메시가 릴레이할 수 있는 속도보다 빠르게 전송하고 있습니다. 바쁜 메시에서는 반복자가 서로 가깝게 보낸 메시지를 드롭할 수 있습니다. 메시지 사이에 몇 초를 남겨두세요." + } }, "chatPayload": { "mention": "{{label}}을(를) 언급하세요", diff --git a/src/renderer/locales/nl/translation.json b/src/renderer/locales/nl/translation.json index 8ded062b5..23489107e 100644 --- a/src/renderer/locales/nl/translation.json +++ b/src/renderer/locales/nl/translation.json @@ -506,7 +506,14 @@ "splitHint": "Verzonden als afzonderlijke pakketten met het label [1/N], [2/N], …", "overMax": "Te lang — maximaal {{totalMax}} tekens ({{maxParts}} berichten)", "sendParts_one": "Verstuur {{count}} onderdeel", - "sendParts_other": "Verstuur {{count}} onderdelen" + "sendParts_other": "Verstuur {{count}} onderdelen", + "limitHintSingle": "Tot {{limit}} tekens. MeshCore verzendt elk bericht als een enkel pakket.", + "overMaxSingle": "Te lang — MeshCore verzendt één pakket per bericht (max {{limit}} tekens)", + "meshcoreSingleNotice": { + "title": "Bericht te lang voor MeshCore", + "body": "MeshCore verzendt elk bericht als een enkel radiopakket (maximaal {{limit}} tekens). Langere berichten moeten worden opgesplitst in genummerde delen, maar op een druk netwerk laten repeaters routinematig een aantal van die delen vallen, zodat de persoon die je een bericht stuurt een onvolledig bericht ontvangt zonder het te weten. Om berichten betrouwbaar te houden, worden ze niet automatisch gesplitst. Verkort dit bericht of stuur het als een paar afzonderlijke kortere berichten.", + "hint": "MeshCore verzendt één pakket per bericht; gesplitste delen worden vaak gedropt door drukke repeaters, dus langere berichten kunnen niet worden verzonden." + } }, "sendFailed": "Verzenden mislukt", "outboxStatusQueued": "In wachtrij geplaatst", @@ -676,7 +683,10 @@ "reticulumSendStoringLocally": "Opslaan in uw lokale propagatie-inbox…", "reticulumSendStoredLocally": "Bewaard in uw lokale propagatie-inbox (niet afgeleverd bij de peer)", "sentViaLocalPropagation": "Lokale propagatie-inbox", - "reticulumSendTimeout": "Time-out voor verzenden. De Reticulum-stack is mogelijk aan het starten of bezet — probeer het opnieuw." + "reticulumSendTimeout": "Time-out voor verzenden. De Reticulum-stack is mogelijk aan het starten of bezet — probeer het opnieuw.", + "meshcoreFastSend": { + "warning": "Je verzendt sneller dan de mesh kan doorgeven. Op een drukke mesh kunnen herhalers berichten die dicht bij elkaar zijn verzonden laten vallen — laat een paar seconden tussen berichten." + } }, "chatPayload": { "mention": "Vermeld {{label}}", diff --git a/src/renderer/locales/pl/translation.json b/src/renderer/locales/pl/translation.json index d04fa6ad8..03c4462d8 100644 --- a/src/renderer/locales/pl/translation.json +++ b/src/renderer/locales/pl/translation.json @@ -506,7 +506,14 @@ "splitHint": "Wysyłane jako oddzielne pakiety oznaczone [1/N], [2/N], …", "overMax": "Zbyt długie — maksymalnie {{totalMax}} znaków ({{maxParts}} wiadomości)", "sendParts_one": "Wyślij część {{count}}", - "sendParts_other": "Wyślij części {{count}}" + "sendParts_other": "Wyślij części {{count}}", + "limitHintSingle": "Maksymalnie {{limit}} znaków. MeshCore wysyła każdą wiadomość jako pojedynczy pakiet.", + "overMaxSingle": "Zbyt długi — MeshCore wysyła jedną paczkę na wiadomość (maks. {{limit}} znaków)", + "meshcoreSingleNotice": { + "title": "Wiadomość zbyt długa dla MeshCore", + "body": "MeshCore wysyła każdą wiadomość jako pojedynczy pakiet radiowy (do {{limit}} znaków). Dłuższe wiadomości muszą być podzielone na ponumerowane części, ale na zatłoczonych wtórnikach siatkowych rutynowo upuszczają niektóre z tych części — aby osoba, z którą wysyłasz wiadomość, otrzymała niekompletną wiadomość bez możliwości jej sprawdzenia. Aby wiadomości były wiarygodne, nie są dzielone automatycznie. Skróć tę wiadomość lub wyślij ją jako kilka oddzielnych krótszych wiadomości.", + "hint": "MeshCore wysyła jeden pakiet na wiadomość; podzielone części są często upuszczane przez zajęte wtórniki, więc dłuższe wiadomości nie mogą być wysyłane." + } }, "sendFailed": "Wysyłanie nieudane", "outboxStatusQueued": "W kolejce", @@ -680,7 +687,10 @@ "reticulumSendStoringLocally": "Zapisywanie w skrzynce odbiorczej lokalnej propagacji…", "reticulumSendStoredLocally": "Przechowywane w skrzynce odbiorczej lokalnej propagacji (niedostarczone do peera)", "sentViaLocalPropagation": "Skrzynka odbiorcza propagacji lokalnej", - "reticulumSendTimeout": "Przekroczono limit czasu wysyłania. Stos Reticulum może się uruchamiać lub być zajęty — spróbuj ponownie." + "reticulumSendTimeout": "Przekroczono limit czasu wysyłania. Stos Reticulum może się uruchamiać lub być zajęty — spróbuj ponownie.", + "meshcoreFastSend": { + "warning": "Wysyłasz szybciej niż siatka może przekazywać. Na zatłoczonej siatce wtórniki mogą wysyłać wiadomości wysyłane blisko siebie — pozostaw kilka sekund między wiadomościami." + } }, "chatPayload": { "mention": "Wspomnij o {{label}}", diff --git a/src/renderer/locales/pt-BR/translation.json b/src/renderer/locales/pt-BR/translation.json index b484c681a..a268e8265 100644 --- a/src/renderer/locales/pt-BR/translation.json +++ b/src/renderer/locales/pt-BR/translation.json @@ -507,7 +507,14 @@ "splitHint": "Enviado como pacotes separados rotulados [1/N], [2/N], …", "overMax": "Muito longo — máximo de {{totalMax}} caracteres ({{maxParts}} mensagens)", "sendParts_one": "Enviar peça {{count}}", - "sendParts_other": "Enviar {{count}} peças" + "sendParts_other": "Enviar {{count}} peças", + "limitHintSingle": "Até {{limit}} caracteres. O MeshCore envia cada mensagem como um único pacote.", + "overMaxSingle": "Muito longo — MeshCore envia um pacote por mensagem (máximo de {{limit}} caracteres)", + "meshcoreSingleNotice": { + "title": "Mensagem muito longa para MeshCore", + "body": "O MeshCore envia cada mensagem como um único pacote de rádio (até {{limit}} caracteres). Mensagens mais longas precisam ser divididas em partes numeradas, mas em um repetidor de malha ocupado, solte rotineiramente algumas dessas partes — para que a pessoa que você está enviando a mensagem receba uma mensagem incompleta sem nenhuma maneira de dizer. Para manter as mensagens confiáveis, elas não são divididas automaticamente. Encurte esta mensagem ou envie-a como algumas mensagens curtas separadas.", + "hint": "O MeshCore envia um pacote por mensagem; as partes divididas geralmente são descartadas por repetidores ocupados, portanto, mensagens mais longas não podem ser enviadas." + } }, "sendFailed": "Falha no envio", "outboxStatusQueued": "Na fila", @@ -676,7 +683,10 @@ "reticulumSendStoringLocally": "Salvando na sua caixa de entrada de propagação local...", "reticulumSendStoredLocally": "Mantido na sua caixa de entrada de propagação local (não entregue ao peer)", "sentViaLocalPropagation": "Caixa de entrada de propagação local", - "reticulumSendTimeout": "O envio expirou. A pilha Reticulum pode estar iniciando ou ocupada — tente novamente." + "reticulumSendTimeout": "O envio expirou. A pilha Reticulum pode estar iniciando ou ocupada — tente novamente.", + "meshcoreFastSend": { + "warning": "Você está enviando mais rápido do que a malha pode retransmitir. Em uma malha ocupada, os repetidores podem soltar mensagens enviadas juntas — deixe alguns segundos entre as mensagens." + } }, "chatPayload": { "mention": "Mencionar {{label}}", diff --git a/src/renderer/locales/ru/translation.json b/src/renderer/locales/ru/translation.json index af5b58285..37200adc6 100644 --- a/src/renderer/locales/ru/translation.json +++ b/src/renderer/locales/ru/translation.json @@ -507,7 +507,14 @@ "splitHint": "Отправляется отдельными пакетами с пометками [1/N], [2/N], …", "overMax": "Слишком долго — максимум {{totalMax}} символов ({{maxParts}} сообщений)", "sendParts_one": "Отправить {{count}} деталь", - "sendParts_other": "Отправьте {{count}} деталей" + "sendParts_other": "Отправьте {{count}} деталей", + "limitHintSingle": "До {{limit}} символов. MeshCore отправляет каждое сообщение как один пакет.", + "overMaxSingle": "Слишком долго — MeshCore отправляет один пакет на сообщение (максимум {{limit}} символов)", + "meshcoreSingleNotice": { + "title": "Сообщение слишком длинное для MeshCore", + "body": "MeshCore отправляет каждое сообщение в виде одного радиопакета (до {{limit}} символов). Более длинные сообщения должны быть разделены на пронумерованные части, но на занятых повторителях сетки обычно отбрасываются некоторые из этих частей, поэтому человек, которому вы отправляете сообщение, получит неполное сообщение без возможности сказать. Чтобы сообщения оставались надежными, они не разделяются автоматически. Сократите это сообщение или отправьте его в виде нескольких отдельных коротких сообщений.", + "hint": "MeshCore отправляет один пакет на сообщение; разделенные части часто отбрасываются занятыми повторителями, поэтому более длинные сообщения не могут быть отправлены." + } }, "sendFailed": "Сбой отправки", "outboxStatusQueued": "В очереди", @@ -678,7 +685,10 @@ "reticulumSendStoringLocally": "Сохранение в локальный почтовый ящик распространения...", "reticulumSendStoredLocally": "Хранится в локальном почтовом ящике распространения (не доставляется одноранговому узлу)", "sentViaLocalPropagation": "Локальный почтовый ящик распространения", - "reticulumSendTimeout": "Время ожидания отправки истекло. Стек Reticulum, возможно, запускается или занят — повторите попытку." + "reticulumSendTimeout": "Время ожидания отправки истекло. Стек Reticulum, возможно, запускается или занят — повторите попытку.", + "meshcoreFastSend": { + "warning": "Вы отправляете быстрее, чем сеть может ретранслировать. На загруженной сети повторители могут отбрасывать сообщения, отправленные близко друг к другу, — оставьте несколько секунд между сообщениями." + } }, "chatPayload": { "mention": "Упоминание {{label}}", diff --git a/src/renderer/locales/tr/translation.json b/src/renderer/locales/tr/translation.json index 5fc5a1339..5fe23cefd 100644 --- a/src/renderer/locales/tr/translation.json +++ b/src/renderer/locales/tr/translation.json @@ -507,7 +507,14 @@ "splitHint": "[1/N], [2/N], … etiketli ayrı paketler olarak gönderilir", "overMax": "Çok uzun — maksimum {{totalMax}} karakter ({{maxParts}} mesaj)", "sendParts_one": "{{count}} parça gönder", - "sendParts_other": "{{count}} parça gönder" + "sendParts_other": "{{count}} parça gönder", + "limitHintSingle": "{{limit}} karaktere kadar. MeshCore her mesajı tek bir paket olarak gönderir.", + "overMaxSingle": "Çok uzun — MeshCore mesaj başına bir paket gönderir (maks. {{limit}} karakter)", + "meshcoreSingleNotice": { + "title": "Mesaj MeshCore için çok uzun", + "body": "MeshCore her mesajı tek bir radyo paketi olarak gönderir ({{limit}} karaktere kadar). Daha uzun mesajların numaralandırılmış parçalara bölünmesi gerekir, ancak meşgul bir örgü tekrarlayıcıda bu parçalardan bazıları rutin olarak düşürülür, böylece mesajlaştığınız kişi bunu anlamanın bir yolu olmayan eksik bir mesaj alır. Mesajları güvenilir tutmak için otomatik olarak bölünmezler. Bu mesajı kısaltın veya birkaç ayrı kısa mesaj olarak gönderin.", + "hint": "MeshCore mesaj başına bir paket gönderir; bölünmüş parçalar genellikle meşgul tekrarlayıcılar tarafından bırakılır, bu nedenle daha uzun mesajlar gönderilemez." + } }, "sendFailed": "Gönderilemedi", "outboxStatusQueued": "Kuyrukta", @@ -676,7 +683,10 @@ "reticulumSendStoringLocally": "Yerel yayılım gelen kutunuza kaydediliyor…", "reticulumSendStoredLocally": "Yerel yayılım gelen kutunuzda tutulur (akranınıza teslim edilmez)", "sentViaLocalPropagation": "Yerel yayılım gelen kutusu", - "reticulumSendTimeout": "Gönderim zaman aşımına uğradı. Reticulum yığını başlıyor veya meşgul olabilir — tekrar deneyin." + "reticulumSendTimeout": "Gönderim zaman aşımına uğradı. Reticulum yığını başlıyor veya meşgul olabilir — tekrar deneyin.", + "meshcoreFastSend": { + "warning": "Ağın aktarabileceğinden daha hızlı gönderiyorsun. Yoğun bir ağda tekrarlayıcılar birbirine yakın gönderilen mesajları bırakabilir; mesajlar arasında birkaç saniye bırakın." + } }, "chatPayload": { "mention": "{{label}}'dan bahsedin", diff --git a/src/renderer/locales/uk/translation.json b/src/renderer/locales/uk/translation.json index 345d0040a..0628df7f3 100644 --- a/src/renderer/locales/uk/translation.json +++ b/src/renderer/locales/uk/translation.json @@ -507,7 +507,14 @@ "splitHint": "Надіслано як окремі пакети з маркуванням [1/N], [2/N], …", "overMax": "Задовго — максимум {{totalMax}} символів ({{maxParts}} повідомлень)", "sendParts_one": "Надіслати {{count}} деталь", - "sendParts_other": "Надіслати запчастини ({{count}})" + "sendParts_other": "Надіслати запчастини ({{count}})", + "limitHintSingle": "До {{limit}} символів. MeshCore надсилає кожне повідомлення як один пакет.", + "overMaxSingle": "Задовгий — MeshCore надсилає один пакет на повідомлення (макс. {{limit}} символів)", + "meshcoreSingleNotice": { + "title": "Повідомлення задовге для MeshCore", + "body": "MeshCore надсилає кожне повідомлення як один радіопакет (до {{limit}} символів). Більш довгі повідомлення потрібно розділити на пронумеровані частини, але на зайнятій сіті повторювачі зазвичай скидають деякі з цих частин — тому людина, якій ви надсилаєте повідомлення, отримає неповне повідомлення без можливості розповісти. Щоб повідомлення були надійними, вони не розділяються автоматично. Скоротіть це повідомлення або надішліть його у вигляді кількох окремих коротких повідомлень.", + "hint": "MeshCore надсилає один пакет на повідомлення; розділені частини часто скидаються зайнятими ретрансляторами, тому довші повідомлення не можна надсилати." + } }, "sendFailed": "Не вдалося надіслати", "outboxStatusQueued": "В черзі", @@ -678,7 +685,10 @@ "reticulumSendStoringLocally": "Збереження до локальної скриньки поширення…", "reticulumSendStoredLocally": "Зберігається у локальній скриньці поширення (не доставлено одноранговому вузлу)", "sentViaLocalPropagation": "Локальна скринька поширення", - "reticulumSendTimeout": "Тайм-аут надсилання. Стек Reticulum може запускатися або бути зайнятий — спробуйте ще раз." + "reticulumSendTimeout": "Тайм-аут надсилання. Стек Reticulum може запускатися або бути зайнятий — спробуйте ще раз.", + "meshcoreFastSend": { + "warning": "Ви надсилаєте швидше, ніж мережа може передавати. На завантаженій сіті повторювачі можуть відкидати повідомлення, надіслані близько один до одного, — залишайте кілька секунд між повідомленнями." + } }, "chatPayload": { "mention": "Згадайте {{label}}", diff --git a/src/renderer/locales/zh/translation.json b/src/renderer/locales/zh/translation.json index 9a3e98855..57d217830 100644 --- a/src/renderer/locales/zh/translation.json +++ b/src/renderer/locales/zh/translation.json @@ -507,7 +507,14 @@ "splitHint": "作为标记为 [1/N]、[2/N]、… 的单独数据包发送", "overMax": "太长 — 最多 {{totalMax}} 个字符({{maxParts}} 条消息)", "sendParts_one": "发送 {{count}} 部分", - "sendParts_other": "发送 {{count}} 部分" + "sendParts_other": "发送 {{count}} 部分", + "limitHintSingle": "最多{{limit}}个字符。MeshCore将每条消息作为单个数据包发送。", + "overMaxSingle": "太长— MeshCore每条消息发送一个数据包(最多{{limit}}个字符)", + "meshcoreSingleNotice": { + "title": "MeshCore的消息太长", + "body": "MeshCore将每条消息作为单个无线电数据包发送(最多{{limit}}个字符)。较长的消息必须分成编号部分,但在繁忙的网格中继器上,经常会丢弃其中一些部分,因此您发送消息的人会收到一条不完整的消息,无法分辨。为了保持消息的可靠性,它们不会自动拆分。请缩短此消息,或单独发送几条较短的消息。", + "hint": "MeshCore每条消息发送一个数据包;拆分部分通常由繁忙的中继器丢弃,因此无法发送更长的消息。" + } }, "sendFailed": "发送失败", "outboxStatusQueued": "已在队列中", @@ -676,7 +683,10 @@ "reticulumSendStoringLocally": "正在保存到本地传播收件箱…", "reticulumSendStoredLocally": "保存在您的本地传播收件箱中(未送达对端)", "sentViaLocalPropagation": "本地传播收件箱", - "reticulumSendTimeout": "发送超时。Reticulum 堆栈可能正在启动或忙碌—请重试。" + "reticulumSendTimeout": "发送超时。Reticulum 堆栈可能正在启动或忙碌—请重试。", + "meshcoreFastSend": { + "warning": "您发送的速度超过了网格可以中继的速度。在繁忙的网格上,中继器可能会丢弃一起发送的消息—在消息之间留几秒钟。" + } }, "chatPayload": { "mention": "提及{{label}}", From b181e3b8242a70172c8d856cdafd98d940d67a45 Mon Sep 17 00:00:00 2001 From: Joey Stanford Date: Sat, 8 Aug 2026 12:43:03 -0600 Subject: [PATCH 3/5] docs: split AGENTS.md subsystem reference into docs/agents/ AGENTS.md is injected on every prompt but Section 8 (Subsystem Quick Reference) was ~77% of the file (~73KB), forcing agents to carry deep Reticulum/MeshCore/Chat/BLE detail even for unrelated tasks. Move that detail into on-demand docs/agents/ topic files and replace Section 8 with a "when working on X, read Y" index plus a handful of hard invariants. AGENTS.md drops from ~95KB to ~26KB with no guidance lost. - Add docs/agents/{reticulum,ble-serial,renderer-hooks,meshtastic,mqtt, chat,meshcore-repeaters,meshcore-rooms,diagnostics,i18n, connection-panel,common-issues,README}.md - Retarget moved-anchor links in ARCHITECTURE.md and docs/index.md to docs/agents/*; add agents-folder link to the docs landing page --- AGENTS.md | 258 +++--------------------------- ARCHITECTURE.md | 6 +- docs/agents/README.md | 20 +++ docs/agents/ble-serial.md | 43 +++++ docs/agents/chat.md | 15 ++ docs/agents/common-issues.md | 43 +++++ docs/agents/connection-panel.md | 8 + docs/agents/diagnostics.md | 9 ++ docs/agents/i18n.md | 14 ++ docs/agents/meshcore-repeaters.md | 22 +++ docs/agents/meshcore-rooms.md | 9 ++ docs/agents/meshtastic.md | 13 ++ docs/agents/mqtt.md | 5 + docs/agents/renderer-hooks.md | 34 ++++ docs/agents/reticulum.md | 34 ++++ docs/index.md | 3 +- 16 files changed, 300 insertions(+), 236 deletions(-) create mode 100644 docs/agents/README.md create mode 100644 docs/agents/ble-serial.md create mode 100644 docs/agents/chat.md create mode 100644 docs/agents/common-issues.md create mode 100644 docs/agents/connection-panel.md create mode 100644 docs/agents/diagnostics.md create mode 100644 docs/agents/i18n.md create mode 100644 docs/agents/meshcore-repeaters.md create mode 100644 docs/agents/meshcore-rooms.md create mode 100644 docs/agents/meshtastic.md create mode 100644 docs/agents/mqtt.md create mode 100644 docs/agents/renderer-hooks.md create mode 100644 docs/agents/reticulum.md diff --git a/AGENTS.md b/AGENTS.md index b0c66bb28..d06ebd3a7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,6 +1,6 @@ # AGENTS.md: Coding Guidelines for AI Assistants -This file is self-contained. ARCHITECTURE.md and CONTRIBUTING.md are human references; read them only if you need deep subsystem detail beyond what's here. +This file holds the always-on hard rules (workflow, security, style, testing, CI, git). **Subsystem detail lives in [`docs/agents/`](docs/agents/README.md) — open the matching file when a task touches that area** (see §8). ARCHITECTURE.md and CONTRIBUTING.md are human references; read them only if you need deep subsystem detail beyond what's here. ## 1. Scope & Workflow @@ -140,237 +140,31 @@ Conventional Commits (`feat:`, `fix:`, `docs:`, `chore:`, `refactor:`, `test:`). ## 8. Subsystem Quick Reference -### Reticulum - -- **Sidecar:** `reticulum-sidecar/` (AGPL Rust binary `mesh-client-reticulum`; path deps under repo-local `.rsstack/` via `scripts/clone-ratspeak-stack.sh` — `rsReticulum`/`rsLXMF`/`rsNomad`/`rsLXST`/`lrgp-rs`); dev: `pnpm run reticulum:sidecar:dev`. **Listen-first:** HTTP binds before `attach_live`; `/api/v1/status` `status: ok` = listening; `rns_ready`/`lxmf_ready` false until live. PN messagestore load deferred; local-prop serve waits for load. LXMF send/reaction fail closed with live-required errors until live. -- **IPC:** `reticulum:*` main handlers — `start` / `stop` / `getStatus` / `syncInterfaceIssueScope`, `proxyGet` / `proxyPost` / `proxyPut` / `proxyDelete`, **`factoryReset`** (blocked on generic proxy), config file read/import dialog, `showNomadContentSourceDialog`, `setNomadContentSource`, Remote `rncpSend` / `rncpFetch` / `setRncpListener` / `showRncpOpenFileDialog` / `showRncpSaveDirectoryDialog` / `revealInFolder`. Also `media:ensureCameraAccess`, `gps:exportGpx`, `db:setReticulumDestinationVerified`, Remote DB `db:listReticulumRemoteAddresses` / upsert / delete and `db:listReticulumInboundPolicy` / upsert / delete (`src/main/ipc/reticulum-db-handlers.ts`), `mesh-client:openUrl` / `electronAPI.deepLink.onOpenUrl`. Renderer uses `electronAPI.reticulum` proxy (no direct localhost). `ReticulumStackPanel` + `useReticulumInterfaceSnapshot` sync enabled interface names after hydrate so TCP/TX issue banners clear when hubs are disabled; `reticulumSidecarIssueTracker` keeps that enabled set sticky while reading sidecar logs. -- **Panels:** `ReticulumStackPanel` (Connection — stack lifecycle, interfaces, issue banner), `ReticulumNetworkPanel` (Network — identity **slots** + QR share/ingest, stack/announce settings, Propagation mode Off/Auto/Manual + rename/delete, config import), `ChatDmPaperControls` (Chat DM **Share as paper** + **Scan paper**), `ReticulumMapPanel` (Map — RMAP v4 discovery), `ReticulumRmapDiscoveryControls` / `ReticulumRmapConnectionStatus` (RMAP publish: Network enable-all eligible interfaces; Connection **X of Y** status), `ReticulumAdminPanel` (Admin — RNode flasher, factory reset), `ReticulumPeerListPanel` (Peers — **Peers / History / Contacts / Favorites** sub-tabs; path request + probe + verified badge; LXMFace avatars; History = messaged `last_heard`, Contacts = explicit `is_contact` / Save as contact only), `NomadNetworkPanel` (Nomad — browse + **My Pages** watched-folder static host via `NomadPageServerPanel`/rsNomad; `nomad_serving_enabled` + `nomad_serving_content_source` restore hosting after live stack start; lazy-mount keep-alive, dual-axis page scroll; fit-width default and open-width toggle), `ReticulumRemotePanel` (Remote — rnsh multi-session shell + rncp send/receive/fetch; Saved addresses + inbound policy; Chat DM send-file via `ChatDmRncpControl`), `RrcPanel` (RRC — multi-hub relay chat) -- **Deep links / QR:** OS scheme is **`lxm://`** (not `mesh-client://`); `MeshClientDeepLinkHost`, `meshClientDeepLink.ts` (`lxmPaperMessage` kind + `looksLikeLxmPaperBlob`; Games `lxm://game/` / Ratspeak `lrgp:` → `lxmGameSession`), `handleReticulumQrIngest.ts` (shared Network/Chat/OS paper + in-app contact ingest), `applyLxmPaperIngest` → `POST /api/v1/lxmf/paper/ingest`, `QrIngestControl` / `QrCodeImage`. OS contact / MeshCore imports confirm before upsert; **paper OS deep links ingest without confirm**; Games session links open Reticulum Games tab via `openReticulumGameSession`. -- **Decommissioned hubs:** `src/shared/reticulumDecommissionedHubs.ts` (Amsterdam only) — stack-start auto-disable + **Add default backbones** disables matching enabled TCP rows; UI badge + enable-block in `ReticulumInterfacesPanel.tsx` (`isDecommissionedReticulumTcpInterfaceRow`); keep TS↔Rust synced via `pnpm run check:reticulum-decommissioned-hubs`. Default backbone picker + region-grouped interface list (Primary & Global / North America / Europe / Asia & Oceania / Specialty / User Defined) in `reticulumDefaultHubPresets.ts` + `ReticulumDefaultHubsPickerModal.tsx`; muted disabled rows + checkbox bulk delete; `countEnabledDefaultHubPresets` / >3 enable warning -- **BLE RNode RSSI:** `useReticulumBleRnodeRssiMap` gates on sidecar **running** (not api-ready), burst-then-steady scans via nested `acquireReticulumBleScan`, clears sticky targets immediately when all BLE RNodes are disabled -- **Propagation mode / sync:** Network → Propagation nodes owns Off/Auto/Manual (default **Off**; persisted values including legacy App-panel `auto` are honored). Auto one-time syncs the best Discovered PN by destination hash (no Add, no Preferred write) via `startPropagationSyncCascade` + sidecar `destination_hash` sync, then configured remotes, then local-prop (skips remotes when no enabled interfaces); runtime hook `useReticulumPropagationAutoSync`. Manual uses Preferred, else picks the best configured remote **for that sync only** (no Preferred write), then the remaining remotes, then local-prop. Off = **no PN support**: `startPropagationSyncCascade` returns early (per-row Sync is disabled in UI), `hasEffectiveReticulumPropagationTarget` / `hasReticulumPnCascadeCapacity` are false, `ReticulumPropagationNotice` is hidden, and the sidecar disarms the outbound PN plus empties cascade candidates (`propagation_mode` in `mesh_client_stack.json`, `POST /api/v1/propagation/mode`, `candidates_for_propagation_mode`); renderer pushes the mode on change and on sidecar-ready. `reticulumPropagationStore` / `reticulumPropagationSync.ts` — Complete on HaveAll, Establishing stall (~45s) + hard ceiling (~180s), auto-sync interval from last success with failure cooldown, error keys for identity / non-PN / peering stamp; stamps `lastPropagationSyncAttemptAt` / `activePropagationSyncAttemptAt` for WS correlation. **Nothing-to-sync is not a failure:** when the cascade contacts no node it writes `syncNoTarget` / `syncLocalLoading` (never overwriting a real error from an attempted node), the local row reports sidecar `status: "loading"` while the messagestore reads (`local_propagation_status` + `PropagationBridge::messagestore_load_pending`, per-row Sync disabled), and the 30 s tick calls `refreshFromSidecar` while `hasPropagationCascadeCandidate` is false so a fresh stack recovers on its own — `refreshFromSidecar` must **not** clear the active attempt while `sync.active`. Debug snapshot `propagationClient` exposes mode/preferred/autoTarget/resolvedSyncTargetId. **Auto also deposits on Discovered PNs:** sidecar `auto_discovered_candidates` (`pn_cascade.rs`, Auto only, cap 3, hop-sorted, skips inactive / self / already-configured / over `max_peering_cost`) appends after configured remotes and before local-prop, rebuilt from the shared `rebuild_pn_cascade_candidates` helper in `live.rs` (called by `refresh_pn_cascade_candidates` **and** the PN announce handler); `hasEffectiveReticulumPropagationTarget` / `hasReticulumPnCascadeCapacity` therefore count discovered rows in Auto, so the Chat notice hides and the link-timeout failure bridge holds off. **Chat notice dismiss:** `chatNoticeDismissed` (`mesh-client:reticulumPropagationNoticeDismissed`) with **Don't show again** on the banner and **Show propagation reminder in Chat** in the Network section. **Named sync target:** `startSync` stamps `syncTargetId`; progress line, inline error, and Sync toasts resolve it with `resolveReticulumPropagationTargetLabel`; the cascade clears it when nothing was contacted so `syncNoTarget` / `syncLocalLoading` stay unprefixed. **Attempts settle before the cascade advances:** `startSync` returns `accepted` | `deferred` | `failed` (not a boolean) — only sidecar _acceptance_ starts `awaitPropagationSyncSettled` (terminal WS frame or stall/ceiling watchdog). `failed` advances with ~15 min session-memory omit via `reticulumPropagationSyncBackoff.ts`; `deferred` (`PROPAGATION_SYNC_OUTBOUND_BUSY` — outbound deposit owns the PN link) advances **without** backoff so the next tick may retry; `cancelled` (user Cancel) stops; `success` ends the run. Remote steps are capped by `PROPAGATION_CASCADE_BUDGET_MS` (5 min) then fall through to local-prop; each remote attempt is capped by `PROPAGATION_CASCADE_ATTEMPT_TIMEOUT_MS` (~60s); local fallback refreshes nodes when local looks disabled; the cascade is single-flight (`resetPropagationSyncCascadeState` is the test seam) so overlapping 30 s ticks join one run while an explicit per-row Sync supersedes it. Auto `/api/v1/interfaces` probe **fails open** (assumes interfaces enabled) so a broken proxy still tries remotes before local. -- **PN hosting:** Network **Advanced PN hosting** / `ReticulumPnHostingDangerZone`; shared `pnHostingPolicy.ts` + sidecar `pn_hosting_policy.rs` / `pn_hosting_apply.rs`; `POST /api/v1/propagation/hosting-policy`; rsLXMF policy-setters overlay ([ratspeak/rsLXMF#6](https://github.com/ratspeak/rsLXMF/pull/6)). Messagestore loads in background on live attach; enabled `local-prop` serve/announce waits until load completes. -- **Interface modes:** rnsd `mode` via `reticulumInterfaceMode.ts` + sidecar `normalize_interface_mode` (keep catalogs in sync — `pnpm run check:reticulum-interface-modes` in pre-commit/`release.sh`); add defaults TCP/UDP/I2P → `boundary`, RNode → `access_point`; UI in `ReticulumInterfacesPanel`; default hub presets add/repair missing mode to `boundary` (do not overwrite valid non-boundary). See [docs/reticulum.md#interface-modes](docs/reticulum.md#interface-modes). -- **Share instance defaults:** missing keys bootstrap to `share_instance = No` / `instance_name = mesh-client` (does not overwrite explicit Yes/`default`); SharedInstanceClient banner + `disable_share_instance` repair; offline lint via `reticulum:validateConfig` / Network **Check config** / `pnpm run reticulum:config:check` -- **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 -- **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 inbox); 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: local inbox, not peer-delivered (PN + amber house). 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). -- **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/` -- **LXST voice:** `hasLxstVoice` gates Call buttons (Peers + Chat DM). Session helpers in `reticulumVoiceSession.ts` (dial/answer/hangup + mic PCM); UI store `reticulumVoiceStore.ts`; overlay `ReticulumVoiceOverlay` (App mount). Dedicated IPC `reticulum:voiceSendAudio` + push channel `reticulum:voiceAudio` (`/ws/voice`; preload `onVoiceAudio`); control via `electronAPI.reticulum.voice.*`. Runtime WS: `voice.update` / `voice.incoming` / `voice.stats` / `voice.terminated` / `voice.error` (errors should carry `link_id` when known; match by link/generation/remote). **Establish-only media:** Answer warms AudioContext; mic capture/TX starts only after `established`; sidecar soft-drops pre-establish PCM (`not_established`). Outbound progress tones: dial → peer DTMF fold → UK double-ring (`reticulumVoiceCallTones.ts` / `reticulumVoiceOutcome.ts` / `reticulumVoiceFeedback.ts`); media-start coalesces by `callGeneration` to avoid Answer mic thrash. Terminal reasons: treat sidecar `established`/`terminated` as completed (not fail). -- **LRGP games:** `hasLrgpGames` gates Games tab + Challenge (Peers / Chat DM). Sidecar `games_session` + `LrgpStore`; companion `games_outbound.db` persists last envelope + `delivery_state` (LXMF outbound bridge → session chips / Resend). Dedicated IPC `electronAPI.reticulum.games.*` / `reticulum:games*` (proxy rejects `/api/v1/games/*`); WS `games.update` / `games.action_result`. Parity: [docs/reticulum-games-parity.md](docs/reticulum-games-parity.md). -- **Gating:** `hasReticulumDiscoveryMap` (Map tab); `hasReticulumRemotePanel` / `hasRncpTransfer` (Remote tab + Chat DM rncp); `hasRrcPanel` (RRC tab); `hasLxstVoice` (LXST Call); `hasLrgpGames` (Games); `hasReticulumInterfaceConfig` / `hasReticulumNetworkPanel` / `ProtocolCapabilities` -- **rnsh/rncp:** sidecar `stack/{rnsh_session,rncp_transfer,path_speed,link_task}.rs` + HTTP `/api/v1/rnsh/*`, `/api/v1/rncp/*`, `/api/v1/remote/*`; typed `electronAPI.reticulum.rnsh|rncp|remote`; picker-gated send/fetch paths in `reticulum-remote-paths.ts`; LXMF enable-request sentinel `mesh-client:request-rncp-receive:v1` (`rncpRequestEnable.ts`); peer reply `mesh-client:rncp-receive-dest:v1:` autofills via `applyRncpReceiveDestShare` (prefer pending from `markRncpReceiveDestSharePending` / `sendRncpRequestEnable`; still apply without pending for older peers); enable-request modal + dest-share side effects deduped by LXMF `message_hash` (`rncpLxmfControlSideEffectDedup`) so catch-up cannot re-fire; already-listening auto-share is once per peer per request-enable cooldown; inbound listener config persists (`rncp_listener_*` in `mesh_client_stack.json`) and restores on live stack start -- **Runtime:** `useReticulumRuntime`, `lib/sessions/reticulumSession.ts`, `lib/ingest/reticulumIngest.ts`; connect starts sidecar, not `ConnectionDriver` RF — marks **configured** when HTTP + identity ready (live attach may still run); `RETICULUM_CONFIGURED_EVENT` wakes RRC. Cancel/stop is fire-and-forget vs cargo/BLE (`START_ABORTED` checkpoints; next start does not rejoin a doomed promise). LXMF/RRC proxy sends: **15 s** `RETICULUM_IPC_SEND_TIMEOUT_MS`. RRC auto-connect (`useRrcStartupAutoConnect`): ~**500 ms** while hubs pending, ~4 s steady. Sidecar RRC: `rrc_codec` / `rrc_link` / `rrc_session` / `api/rrc.rs` -- **Diagnostics:** `ReticulumDiagnosticEngine.ts` (Reticulum-native rows; no LoRa hop-goblin semantics) — includes `reticulum/sidecar-unhealthy` (60s grace; HTTP health, not listen-first ready lag), `reticulum/rns-not-ready` / `reticulum/lxmf-not-ready`, `reticulum/propagation-sync-stuck`, `reticulum/propagation-sync-failing` (1h TTL) -- **No Noble/MQTT** for Reticulum's own connections (sidecar owns BLE RNode via `btleplug`); gate UI with `hasReticulumInterfaceConfig` / `hasReticulumNetworkPanel` / `ProtocolCapabilities`. On macOS/Windows, connecting a Reticulum BLE RNode may still **suspend/yield Noble** so it does not contend with the sidecar's BLE scan — see **Multi-protocol BLE** below. -- **Multi-protocol BLE:** Meshtastic, MeshCore, and Reticulum (BLE Peer + `ble://` RNode) may connect to **different** BLE devices at once on all platforms. Coexistence: `ble-coexistence-coordinator.ts` (peripheral MAC registry + scan-only mutex); Linux mesh uses Web Bluetooth + sidecar `btleplug`. Same MAC rejected; scans serialized—never disconnect unrelated GATT for scans. **Reticulum BLE RNode** on macOS/Windows may **suspend Noble** (`suspendNobleForReticulumBleConnect`, `reticulum-ble-rnode-config.ts`, `reticulumNobleBleYield.ts`, `useReticulumNobleBleYieldWatcher`) — main kicks yield **after** sidecar HTTP health (not during cargo/spawn) so Cancel does not yank LoRa BLE; while yield holds the scan, Noble connect is rejected; post-grace yield stops re-contending (**~60s** grace aligns with OS passkey window); disconnect timeout fails closed (releases scan). Noble yield sync is **only** in `useReticulumNobleBleYieldWatcher` (always mounted from `useReticulumRuntime`, including `connecting`); **`useReticulumInterfaceSnapshot` must not release yield** (mid-pair release caused CoreBluetooth “Event receiver died”). Shared grace clock: `reticulumBleConnectGrace.ts` (watcher + snapshot). Watcher uses `AbortSignal` to avoid stale inactive release; renews grace on stack restart when main re-holds `scanOwner=reticulum` and local yield is inactive. Meshtastic/MeshCore RF autostart waits `awaitReticulumBleCoexistenceClear()` (`reticulumStartupAutostartGate.ts`, default **~65 s** = 60 s grace + 5 s buffer). Sidecar may latch **`bleBondRemoved`** (stale OS bonds) or **`blePairingTimedOut`** (passkey not entered) — Forget/re-pair; Admin Start pairing shows PIN in-panel over USB (not radio display; never Meshtastic `123456`). Release dispatches `mesh-client:nobleBleYieldReleased` for Meshtastic/MeshCore reconnect. -- **Docs:** [docs/reticulum.md](docs/reticulum.md), [docs/reticulum-sidecar-ipc.md](docs/reticulum-sidecar-ipc.md) - -### Diagnostics - -- **Engines:** `src/renderer/lib/diagnostics/`; `RoutingDiagnosticEngine.ts`, `RFDiagnosticEngine.ts` (includes MeshCore **High Companion TX Queue** when `queueLen > 200`), `RemediationEngine.ts`, `ReticulumDiagnosticEngine.ts`. -- **Store:** `src/renderer/stores/diagnosticsStore.ts`; routing/RF rows, foreign LoRa, MQTT ignore, redundancy. -- **Tab scoping:** `filterDiagnosticRowsForProtocol()` — Meshtastic/MeshCore tabs show LoRa rows only; Reticulum tab shows `reticulum/*` only. Foreign-LoRa tables UI is on Meshtastic and MeshCore tabs (keyed by that protocol’s self node id). -- **Extend:** adjust `DiagnosticRow` in `src/renderer/lib/types.ts`, add detector, wire `replaceRoutingRowsFromMap` / `replaceRfRowsForNode`; TTL defaults in `diagnosticRows.ts` (routing 24h, RF 1h). -- **Full reference:** [docs/diagnostics.md](docs/diagnostics.md). - -### Renderer hook architecture (multi-protocol) - -See **Renderer: hooks vs runtime vs lib** (layout map above). Legacy `useDevice` / `useMeshCore` are removed ([#375](https://github.com/Colorado-Mesh/mesh-client/issues/375), [#377](https://github.com/Colorado-Mesh/mesh-client/issues/377)). Default rules for new UI: - -| Concern | Use | -| ----------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Orchestration (App tab) | `useProtocolFacade(protocol)` — connection, `useConnectionView`, panel bundle, nodes, messages | -| Active protocol identity | `useActiveMeshIdentity(protocol)` — focused `identityId` per tab; prefer `capabilities` over `protocol ===` | -| LoRa dual-protocol panel bundles (App) | `useAllProtocolPanelActions` (Meshtastic + MeshCore + Reticulum); prefer this over per-protocol panel-action hooks at the App shell | -| Reads (nodes, messages, connection fields) | Zustand stores + `useNodes` / `useMessages` / `useConnectionView` / `useConnectionStatus` | -| Writes (configure, send, admin, panel callbacks) | `usePanelActions(protocol, identityId, …)` / `useProtocolFacade(protocol).panel` or `useSendMessage(identityId)` | -| Connect / disconnect / auto-connect | `useProtocolConnectionActions(protocol)` (`useProtocolConnect` + `useProtocolDisconnect` + `lib/sessions/*Session.ts`); Meshtastic/MeshCore via `ConnectionDriver`; Reticulum via sidecar start/stop. Launch RF auto-connect (serial/BLE/TCP/HTTP) via `ProtocolAutoConnectCoordinator` + `useProtocolRfAutoConnect` + `protocolRfAutoConnectGate` (`cancelProtocolRfAutoConnect` before manual Connect). LoRa reconnect single-owner: `rfReconnectController` | -| Wire subscriptions, MQTT IPC, reconnect, DB hydration | `useMeshtasticRuntime` / `useMeshcoreRuntime` / `useReticulumRuntime` in `runtime/` — mount **once** from `App.tsx` via context providers | - -Do **not** remount protocol runtimes in child components. Do **not** compare `protocol === 'meshcore'` for feature gates; use `ProtocolCapabilities` / `useRadioProvider(protocol)`. - -Protocol SDK adapters: `src/renderer/lib/protocols/`. Connection lifecycle: `ConnectionDriver` (`lib/drivers/`); inbound domain events: Protocol → `PacketRouter` → identity stores, then side-effect listeners (ingest already applied). **Meshtastic post-router side effects:** `lib/ingest/meshtasticIngest.ts`, `meshtasticRouterSideEffects.ts` (MQTT uplink / notifications / device_log), `meshtasticNodeSideEffects.ts`, `meshtasticRawPacketSideEffects.ts`, `meshtasticTraceSideEffects.ts`, `meshtasticModulePortSideEffects.ts`, `meshtasticStoreForwardSideEffects.ts`; `meshtasticTransportSideEffects.ts` handles transport-state cleanup, while lifecycle-only SDK attach remains in `meshtasticRuntimeWireEffects.ts` (DeviceStatus / MyNodeInfo / FromRadio / heartbeat / config / remote-admin — not a second packet decode path). **MeshCore post-router side effects:** `lib/ingest/meshcoreIngest.ts` (chat persist, `last_heard`, path-updated), `hooks/meshcore/meshcoreConnSideEffects.ts` + `MeshcoreConnSideEffectsCtx` (`meshcoreConnSideEffectsCtx.ts`) (DM ack 130, waiting drain 131, RF RX 136, CLI, disconnect), `lib/meshcore/meshcoreLiveContactPersist.ts` (SQLite contact rows), `lib/meshcore/meshcorePubKeyRegistry.ts` (DM/trace pubkeys). Live UI nodes/messages read `nodeStore` / `messageStore` via `identityStoreReads` (`getIdentityNode` / `getIdentityChatMessages`); runtimes do not keep hook-local node/message wire mirrors. Transport params / Protocol attach helpers: `meshIdentityBridge`. **Favorites:** `setNodeFavorited` patches `meshcoreIdentityIdRef` (fallback `getIdentityIdForProtocol('meshcore')`). **Dedup windows:** cross-transport and channel RF **5 min**; room/tapback **60 s**. Path-updated (129) for existing contacts does not bump SQLite `last_advert` until the next advert (128). - -**Identity-scoped UI stores:** `identityStore`, `nodeStore`, `messageStore`, `connectionStore` — nodes/messages keyed by `identityId`. **MQTT status bridge:** `mirrorMqttStatusToConnection` copies main-process `mqtt.onStatus` IPC into `connectionStore.mqttStatus` from runtime handlers until MQTT moves fully into `ConnectionDriver`. **SQLite → UI:** `lib/hydrateIdentityStoresFromDb.ts` (coordinator: `identityHydrationCoordinator.ts`; Meshtastic node map: `meshtasticDbCacheHydration.ts`; message cap: `meshtasticMessageLoadLimit.ts`); manual refresh via `hooks/useDbRefresh.ts`. Identity-scoped Zustand hydration is the canonical UI path ([#375]). **MeshCore contacts DB:** `meshcore_contacts.last_advert` is Unix **seconds**; age prune uses `src/shared/meshcoreContactAgeCutoff.ts` (do not compare in ms). - -### Protocol entry points - -- **Meshtastic:** `src/renderer/lib/protocols/MeshtasticProtocol.ts`, `useMeshtasticRuntime` (side effects), `src/renderer/lib/connection.ts` (`createConnection`) -- **MeshCore:** `src/renderer/lib/protocols/MeshCoreProtocol.ts`, `useMeshcoreRuntime` (side effects), `@liamcottle/meshcore.js` - -### Database - -WAL SQLite; `user_version` in `database.ts`; migrations as `migration_N()`; `db-compat.ts` over `node:sqlite`. After schema changes: `pnpm run check:db-migrations`. **Startup maintenance:** `lib/startupDbPrune.ts` — single-flight per session from `App.tsx` (node/message retention, RF stub migration); do not re-invoke from unstable effect deps. - -### BLE and serial - -Meshtastic and MeshCore share LoRa BLE reconnect contracts whenever possible (platform + protocol parity). **macOS/Windows (Noble):** both use `noble-ble-manager.ts` session ids `meshtastic` / `meshcore`. **Linux:** Web Bluetooth (`webbluetooth-ble-manager.ts` / MeshCore Web BT path) — no Noble GATT manager, no `withNobleBleConnectMutex`. Serial: `connection.ts`, `serialPortSignature.ts`. Meshtastic BLE open: `connection.ts` / `TransportManager`. Reticulum BLE RNode uses sidecar `btleplug` (not Noble connect); see Multi-protocol BLE coexistence above. - -**LoRa BLE reconnect parity (Meshtastic + MeshCore):** - -- **`rfReconnectController`** (`lib/rfReconnectController.ts`): single-owner link-lost / schedule / endAttempt for both runtimes (MeshCore TCP uses the same owner; conn side effects must not call `handleConnectionLost` for TCP). -- **`gattSetupInflight`** in `noble-ble-manager` (both session ids): after `connectAsync`, mid-GATT disconnect rejects in-flight `connect()` promptly (Noble only). -- **Deferred Noble disconnect** while connect/reconnect open is in flight; **flush in reconnect `finally`** in `useMeshtasticRuntime` and `useMeshcoreRuntime` so edge-of-range drops keep retrying. -- **`NOBLE_BLE_RECONNECT_ATTEMPT_BUDGET_MS`** (`timeConstants.ts`) + `raceWithDeadline` (`bleReconnectHelper.ts`): hard ceiling per BLE reconnect open+handshake attempt on **all platforms** (unsticks configure/attach hangs; does not add a Linux cross-protocol mutex). -- **Mutex:** Noble IPC connects still go through `withNobleBleConnectMutex()` — budget must not leave that mutex held (timeout clears in-flight flags; late open losers are ignored via attempt-active / generation guards). -- MeshCore must **not** start the runtime reconnect loop on disconnect before the first successful configure — ConnectionPanel `reconnectBleWithScan` owns initial retries (`meshcoreEverConfiguredRef`). **Manual disconnect** (`connectionStore.disconnectIntent`) must **not** auto-reconnect — covered by `useMeshcoreRuntime.reconnect.test.ts`, `useMeshtasticRuntime.reconnect-hardening.test.ts`, and `useReticulumRuntime.reconnect-hardening.test.ts`. - -**Meshtastic USB serial vendor patches:** `@jsr/meshtastic__core` and `@jsr/meshtastic__transport-web-serial` are patched via pnpm `patchedDependencies` (`patches/@jsr__meshtastic__core@*.patch`, `patches/@jsr__meshtastic__transport-web-serial@*.patch`) so Web Serial streams abort cleanly on disconnect (avoids “port is already open” on reconnect). Re-hash patches after JSR bumps; see `docs/troubleshooting.md`. - -**ATT MTU / writes:** Noble `toRadio` writes in `noble-ble-manager.ts` are chunked using negotiated `peripheral.mtu` (sanitized via `src/shared/bleAttWriteLimit.ts`; values below spec min 23 are coerced—NobleMac may log `MTU updated: 20` before a full exchange). Linux Web Bluetooth uses `webbluetooth-ble-manager.ts`; when Chromium exposes `maximumWriteValueLength`, writes are chunked—there is no standard Web API for negotiated MTU ([WebBluetoothCG#383](https://github.com/WebBluetoothCG/web-bluetooth/issues/383)). - -**Meshtastic transport writes:** `meshtasticTransportLossDetection.ts` wraps `transport.toDevice` with `createSerializedWritableStream` on **serial, BLE, HTTP, and TCP** so concurrent SDK `getWriter()` calls (ping, Store & Forward, queue) do not throw `WritableStream is locked`. Meshtastic **WiFi/TCP (fast)** uses `TransportTcpIpc` in the renderer with main-process `meshtastic:tcp-*` IPC (`net.Socket` on port **4403**). After configure, `getMetadata` retries once after `MESHTASTIC_GET_METADATA_AFTER_CONFIGURE_RETRY_MS` when NodeDB traffic starves BLE. **`meshtasticSdkRoutingErrorConsoleHook.ts`** intercepts SDK `console.error`/`warn` routing failures, logs matched lines at **`console.debug`**, and applies **`applyMeshtasticOutboundRoutingErrorFromLog`** / **`FromRejection`** to mark outbound chat rows failed; unmatched queue rejections log as `[meshtasticSdkRoutingErrorLog]` (timeouts may log via `warn` in queue.js). - -**Linux Web Bluetooth (Meshtastic):** `webbluetooth-ble-manager.ts` subscribes to **fromNum** GATT notify for unsolicited mesh traffic, runs a **3 s background fromRadio poll** between write cycles, and uses **multi-shot read probes** instead of a single post-write safety read (LoRa latency). MeshCore BLE echo filtering: `meshcoreCompanionTxEchoFilter.ts` (Noble + Web Bluetooth). Chooser sessions are generation-scoped; Connect/Reconnect **await** `cancelBluetoothSelection` before `requestDevice()` (see [troubleshooting](docs/troubleshooting.md#ble-known-issues)). Linux uses `bleCoexistenceWebBt` scan/register helpers — not Noble `gattSetupInflight` or dual-radio Noble startup deferral. - -**Dual-radio Noble BLE startup (macOS/Windows):** When both Meshtastic and MeshCore have **different** saved BLE peripherals, the renderer must serialize auto-connect and manual Noble connects. Coordinator: `src/renderer/lib/meshcoreDualNobleBleInit.ts`; UI wiring: `ConnectionPanel.tsx` (both panels stay mounted from `App.tsx`). - -| Rule | Detail | -| -------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Init timing | Call `initNobleBleDualRadioStartup()` from **`App.tsx` `useLayoutEffect`** (not `useEffect`). Child ConnectionPanel auto-connect `useEffect` runs after layout effects — initializing in parent `useEffect` races and leaves primary unset. | -| Primary order | `mesh-client:protocol` localStorage (`meshcore` / `meshtastic`; Reticulum or missing → Meshtastic). Single-radio installs skip peer deferral. | -| Primary notify | Primary calls `notifyNobleBlePrimaryRfLinkReady()` when GATT + handshake succeed (MeshCore transport + Meshtastic `createBleConnection`), or `notifyNobleBlePrimaryAutoConnectSettled()` on first attempt failure — **not** after full configure or scan fallback. | -| Secondary wait | Secondary waits only on `awaitNobleBlePrimaryAutoConnectSettled()` — do **not** add `awaitNobleBleProtocolSettle()` here (mutex + post-config defer handles configure overlap). | -| Mutex | All Noble IPC connects go through `withNobleBleConnectMutex()` (Meshtastic + MeshCore). No-op on Linux Web Bluetooth. | -| LoRa reconnect | Shared: `gattSetupInflight`, deferred disconnect + reconnect `finally` flush, `NOBLE_BLE_RECONNECT_ATTEMPT_BUDGET_MS`. MeshCore: no runtime reconnect loop before first configure (`meshcoreEverConfiguredRef`). Manual disconnect must not auto-reconnect — `useMeshcoreRuntime.reconnect.test.ts`, `useMeshtasticRuntime.reconnect-hardening.test.ts`, `useReticulumRuntime.reconnect-hardening.test.ts`. | -| Tests | Protocol-neutral unit tests: `meshcoreDualNobleBleInit.test.ts`. Meshtastic + MeshCore defer paths: `ConnectionPanel.test.tsx` (`active-protocol-first BLE auto-connect`). | - -Do **not** reintroduce Meshtastic-only startup gates, child-before-parent init, or runtime-side secondary auto-connect subscriptions — ConnectionPanel owns auto-connect for both protocols. - -### Meshtastic channel URLs & Store & Forward - -- **Config apply (Radio / Modules / Security):** Firmware `setConfig` / `setModuleConfig` replace full protobuf structs. UI must merge cached device slices with form edits via `meshtasticConfigApply.ts` (`mergeMeshtasticConfigApplyValue`, `buildMeshtasticModuleApplyValue`); slices live in `deviceStore.meshtasticConfigSlices` and `moduleConfigs` (PacketRouter + runtime lifecycle wire effects). Module-specific validation: `meshtasticMqttModuleApply.ts`, `meshtasticSerialModuleApply.ts`. Apply failures surface `clientNotification` text within 8s (`meshtasticClientNotification.ts` → `formatMeshtasticModuleApplyError`); inline status via `ConfigApplyNotice.tsx`. Forms re-sync after reboot via `useSyncFormFromConfig`. -- **Administration tab:** `AdminPanel.tsx` — device commands and Danger Zone (reboot, shutdown, factory reset, NodeDB reset, OTA/DFU); shared `ConfirmModal.tsx` with Radio/Modules destructive flows. Local-only OTA/DFU disabled when **Configure node** targets a remote node. -- **Remote admin module snapshot:** `meshtasticRemoteAdminModuleFetches.ts` — canonical list/count of `ModuleConfig` reads during remote snapshot (`REMOTE_ADMIN_MODULE_CONFIG_FETCHES`). -- **Channel URLs:** `src/shared/meshtasticUrlEncoder.ts` (parse/generate), `src/shared/meshtasticChannelApply.ts` (replace vs add-only apply); Radio panel UI; Meshtastic-only. -- **S&F chat history:** `src/renderer/lib/meshtasticBacklogUtils.ts` — `CLIENT_HISTORY` on primary router heartbeat after RF configure (auto: 50-msg cap, 120 min window cap, 15 min per-server cooldown, 5 min offline gate; `storeForwardAutoFetchHistory` opt-out; `storeForwardHistoryProfile: 'conservative' | 'aggressive'` in `defaultAppSettings.ts` tunes offline gate / cooldown / cap aggressiveness; manual catch-up in Chat). Protobuf decode for replayed text, `via_store_forward` on messages; do not await SDK queue for history (async replay). -- **MQTT broker clientId:** `src/main/mqtt-broker-client-id.ts` — stable per-install IDs in `app_settings` (`meshtasticMqttClientId`, `meshcoreMqttClientId`); MeshCore LetsMesh `v1_` username unchanged as clientId. -- **PKC remote admin (firmware 2.5+):** `meshtasticRemoteAdmin.ts` — PKI-wrapped `AdminMessage` via `MeshDevice.sendRaw()` (`pkiEncrypted: true`, channel omitted on wire); session passkeys (~300s); tab-scoped snapshot routes in `meshtasticRemoteAdminSnapshot.ts` (Channels-first LoRa load). Per-node keys: `meshtasticRemoteAdminKeyStorage.ts` (`meshtasticRemoteAdminKey:` in `app_settings`; base64 / `base64:` / 64-char hex paste). Dest public key: NodeDB hex first, stored admin-key base64 fallback. `useMeshtasticRuntime`: `configureTargetNodeNum`, `remoteConfigSnapshot`, `runRemoteAdminOp` (errors → UI + toast); serialize admin reads with S&F (`remoteAdminReadsActiveCount` in `meshtasticBacklogUtils.ts`). **Requires connected local radio** (MQTT-only cannot admin). UI: `ConfigureNodeSelector.tsx`; NodeDetailModal admin key + **Configure node remotely**; SecurityPanel **Copy** public key. Persist last target in `meshtasticConfigureTargetNodeNum`. Gate with `hasRemoteAdmin`. Legacy admin channel (PSK + `"admin"`) out of scope. -- **Meshtastic last heard:** `meshtasticLastHeard.ts` — bump `last_heard` on live RF packets (not only text); `computeNodeInfoLastHeardMs` prevents configure replay from regressing fresher client timestamps. -- **Static GPS:** `src/renderer/lib/gpsSource.ts` — App tab static coordinates sync to self-node, map, and radio `setPosition`. - -### MQTT - -Meshtastic: `mqtt-manager.ts` (AES-128/256-CTR, Meshtastic nonce layout, channel keys, protobuf, dedup); inbound **TEXT_MESSAGE** ingest prefers **topic channel name** → `channelNameToIndex` (receiver-local slot); `MeshPacket.channel` is fallback when topic absent — sampled log when they disagree (`mqtt-channel-topic-mismatch:*`); Connection panel **Channel PSKs** `ChannelName@index=` for MQTT-only slot mapping; `meshtasticMqttPublish.ts`; `meshtasticChannelPskInput.ts` + `src/shared/meshtasticChannelPskLine.ts`; `meshtasticMqttSettingsStorage.ts`; `meshtasticMqttIdentity.ts` (MQTT-only `from`); `mqtt-broker-client-id.ts`. After RF configure, `useMeshtasticRuntime` must **re-push** `resolvedChannelConfigs` via `mqtt.updateChannelKeys` (not only on MQTT status change) so cold-start MQTT before deviceStore channels still gets correct topic→slot maps (`[Meshtastic MQTT] channelNameToIndex updated`). MeshCore: `meshcore-mqtt-adapter.ts` (JSON v1); LetsMesh JWT `letsMeshJwt.ts`. **Sticky MeshCore BLE “Blue” suppress:** `connectedMeshcoreBleMac.ts` persists a valid MeshCore BLE MAC and pre-arms Meshtastic NodeDB ghost suppression across cold start, failed reconnect, and user disconnect; clear only on Forget or switching MeshCore to a non-BLE transport. - -### 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. - -### i18n / Localization - -- **Framework:** i18next + react-i18next; static JSON bundles loaded at startup; `fallbackLng: 'en'`. -- **Locale files:** `src/renderer/locales/{en,es,uk,de,zh,pt-BR,fr,it,pl,cs,ja,ru,nl,ko,tr,id}/translation.json` — English is source of truth (`pnpm run check:i18n` reports key count). -- **Locale persistence:** `locale` key in `app_settings` SQLite table (canonical) and `mesh-client:appSettings` localStorage (fast startup read); reconciled in `App.tsx` on mount. -- **Reduce motion:** `reduceMotion` boolean in the same `app_settings` / localStorage bundle; toggled in **App → Appearance** ([`AppPanel.tsx`](src/renderer/components/AppPanel.tsx)). When true, non-essential UI motion (animated icons, decorative CSS pulses) is suppressed; loading spinners and connection status pulses remain. Does not auto-sync to OS `prefers-reduced-motion` after first-run init — see [`docs/accessibility-checklist.md`](docs/accessibility-checklist.md). -- **24-hour time:** `use24HourTime` beside Reduce motion in **App → Appearance** (`timeFormatStore`, `formatDisplayTime`; SQLite `app_settings` + `mesh-client:appSettings` localStorage). When on, chat/diagnostics clocks force 24-hour; when off, follow system locale. -- **Adding strings:** add to `src/renderer/locales/en/translation.json`, use `t('your.key')` in components; `check:i18n` enforces all call sites resolve to English keys and **fails on unused English keys** (no static `t()`, registered dynamic prefix, quoted literal in `src/`, or `tabs.*` from `TAB_SLOT_IDS`). -- **Removing strings:** delete the key from `en/translation.json` and run `pnpm run i18n:prune-unused -- --write` to drop it from every locale (or remove manually). `check:i18n` blocks orphaned English keys. -- **Auto-translate:** `pnpm run i18n:auto-translate` uses MyMemory (default) or LibreTranslate (`LIBRETRANSLATE_URL`). With git, the default run **only** fills keys that are **new in English vs `HEAD`** and still missing from each locale (pre-commit uses this). Use **`pnpm run i18n:auto-translate --all`** or **`I18N_TRANSLATE_ALL=1`** to backfill every key missing from a locale vs English. Use **`--audit`** (or `I18N_AUDIT=1`) to additionally retranslate any key whose locale value is still identical to English (i.e. never actually translated). Existing translated entries are never overwritten. MyMemory sends contact `info@coloradomesh.org` by default for the 50 k words/day quota; override with `MYMEMORY_EMAIL` if needed. -- **Key check:** `pnpm run check:i18n` — hard fails on missing English keys and unused English keys; warns (does not fail) on incomplete locale coverage so rate-limit gaps don't block commits. Also runs locale quality rules via `scripts/check-i18n-quality.mjs` (mojibake, `meshtastic://` spacing, false friends, **boot-sequence** transport labels, **Reticulum hub/stack** wording, **RRC** slash-command token preservation and room false friends, **Repeaters CLI danger confirm** action text, **`repeatersPanel.cliMultiHopHint`** auto-ping semantics). Unused-key detection lives in `scripts/i18n-unused-keys.mjs`; `pnpm run check:i18n:branch` skips the unused pass and only runs quality rules on keys new/changed vs `HEAD`. -- **Language selector:** `src/renderer/components/LanguageSelector.tsx` — globe-icon dropdown in the header; calls `i18n.changeLanguage()` + `mergeAppSetting('locale', ...)` + `electronAPI.appSettings.set('locale', ...)`. - -### Chat Panel - -- **Components:** `ChatPanel.tsx` (channel/DM UI) + shared `ChatComposer.tsx` (drafts, mentions, chunking, spellcheck, emoji; also used by `RoomsPanel.tsx`). Reticulum DM **Share as paper** / **Scan paper** via `ChatDmPaperControls.tsx` + `createReticulumPaperMessage.ts`. Scroll-at-bottom helper: `chatScrollUtils.ts` (`getDistFromChatBottom`). -- **MeshCore is single-packet (no multi-part split):** `getMaxChunks('meshcore') === 1` in `chatComposerLimits.ts`, so `splitChatMessage` returns `null` for over-limit MeshCore text (channel/DM/room), the composer shows an `overMax` `meshcoreSingleNotice` callout, and Send is disabled — do **not** reintroduce MeshCore chunking (busy repeaters drop split parts: meshcore-dev/MeshCore #1502 / #2820). Meshtastic/Reticulum keep the 9-chunk auto-split. A non-blocking fast-send advisory (`meshcoreSendRateNotice.ts`, `MESHCORE_FAST_SEND_WARN_INTERVAL_MS` = 5s) warns on rapid MeshCore sends but never blocks/delays. Inbound multi-part from other clients is still merged. -- **Payload / links:** `ChatPayloadText.tsx` — mention highlighting, search marks, URL linkification; link previews via `chat:fetchLinkPreview` (`src/main/fetchLinkPreview.ts`): Open Graph for HTML pages; **YouTube** watch/shorts/youtu.be via oEmbed + thumbnail; **direct image URLs** (path extension via `chatDirectImageUrl.ts` or raster `Content-Type`) return `kind: 'image'` and render as inline embeds (`ChatInlineImage` / `DirectImageEmbed`); OG/YouTube use card layout. Security: DNS-pinned undici `Agent`, private/loopback blocked, magic-byte MIME sniff (`safeRasterImageMime.ts`), HTTPS-only image embeds, 10s fetch / 3s DNS, 64 KiB HTML cap, **2 MiB** image fetch cap (256 KiB cache payload cap), LRU caches, single-flight dedup (renderer map capped). Previews load even when scrolled up. LXMF attachment rasters: `chat:readReticulumAttachmentAsDataUrl` (`reticulum-attachment-image.ts`; path jail, magic-byte MIME, SVG rejected, 2 MiB, IPC rate limit) → `ReticulumAttachmentLine`. Reply quotes: `replyPreview.ts`. -- **Storage helpers:** `src/renderer/lib/chatPanelProtocolStorage.ts` — drafts (`mesh-client:drafts:`), open DM tabs, last-read, per-view mute (`mesh-client:mutedViews:`), starred (`mesh-client:starred:`, cap 200), MeshCore flood-scope overrides per chat view (`mesh-client:floodScopeOverrides:`, channel or DM `viewKey`). -- **Notifications:** `src/renderer/lib/chatNotifications.ts` — `playMessageNotification(type)` via Web Audio: `channel` = single 880 Hz pulse (150 ms); `dm` / `reply` = dual pulse (587.33 Hz then 783.99 Hz, 50 ms each, 35 ms gap). Resumes suspended `AudioContext` when the window is hidden/minimized. Type selection in `chatUnreadCounts.ts` (`resolveChatNotificationType`, `pickAudibleNotificationType`; batch priority reply > dm > channel). **ChatPanel** plays when the user is on Chat but reading another view; **App** plays for other panels / backgrounded window (avoids double beep). Meshtastic hidden-window desktop notifications are visual-only (`silent: true` in `meshtasticRouterSideEffects.ts`); typed Web Audio from App owns sound. Global mute `mesh-client:notifMuted`; per-view mute in `mutedViews`. Main-process **tray** icon shows unread when chat or MeshCore Rooms traffic arrives while backgrounded (`src/main/index.ts` `buildTrayIcon`). -- **Meshtastic dedup:** `meshtasticMessageDedup.ts` — merges delayed RF/MQTT duplicates (**10-minute** content window) in `useMeshtasticRuntime` ingest. -- **Hop badges:** `MessageRecord.rxHops` / `viaStoreForward` round-trip via `storeRecordAdapters.ts` and `meshtasticDbCacheHydration.ts` (`hopCount` on `MessageRecord` bridges to `rxHops` for Meshtastic PacketRouter rows); Chat hop pills read `ChatMessage.rxHops`. **MeshCore (primary):** companion `pathLen` on events 7/8 → `meshcoreCompanionRxPathLenToHopCount` → `DomainEvent.payload.hopCount` (`MeshCoreProtocol`, `meshcoreDirectMessageDecode`); waiting-message drain also sets `rxHops` from `pathLen`. **MeshCore (fallback):** raw-log correlation via `resolveMeshcoreIngestRxHops` / `MESHCORE_CHAT_CORRELATE_WINDOW_MS` (3000ms); `rawPacketsRef` synced inside event-136 `setRawPackets` updater for same-tick ingest (`meshcoreConnSideEffects`). **Meshtastic:** `MeshtasticProtocol` uses `meshtasticComputedRfHopsAway` — omit hops for `viaMqtt` and `hopStart === 0`; else `hopStart - hopLimit` when `hopStart > 0 && hopLimit <= hopStart`. -- **MeshCore dedup:** `meshcoreStoreDedup.ts` — RF/MQTT merge, companion TX echo, tapback self-echo, room BBS paths in `useMeshcoreRuntime` ingest (**5-minute** cross-transport window; distinct from Meshtastic’s 10-minute window). -- **Reactions / tapbacks:** `reactions.ts` (Meshtastic protobuf) + `meshcoreChannelText.ts` (MeshCore default outbound keyless `@[Name] emoji` / `@[Name] body`; opt-in **MeshCore Open compatibility** in App enables keyed replies, `r:` reactions, and `g:` GIF send via `meshcoreOpenWireCompatEnabled` in `appSettingsStorage.ts`; inbound keyed/sec↔ms parent match via `meshcoreMessageMatchesReplyKey`; inbound emoji-only replies promoted via `meshcorePromoteEmojiOnlyReplyToTapback`; inbound Open `r:HASH:INDEX` in `meshcoreOpenReaction.ts`); `ChatPanel.tsx` attaches tapbacks via `replyId` + runtime/panel `sendReaction`. -- **Mention segments:** `src/renderer/lib/chatMentionSegments.ts` — parse/build `@[Name]` tokens; `MentionAutocomplete.tsx` renders the dropdown. -- **Export IPC:** `chat:export` — renderer calls `window.electronAPI.chat.export(messages)`; main opens a Save dialog and writes a `.txt` file. -- **Support bundle IPC:** `support:exportBundle` — `exportSupportBundle.ts` → `window.electronAPI.support.exportBundle(mode, json)`; main `support-bundle.ts` writes zip (`github` = logs + debug snapshot including Reticulum diagnostic JSON and Meshtastic channel layout triage via `debugSnapshotMeshtasticContext.ts`; `developer` = SQLite plus redacted `reticulum/config` and `reticulum/mesh_client_stack.json`). Modes in `support-bundle.types.ts`. - -### MeshCore Repeaters admin (Ping / trace) - -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`. -- **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. -- **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 [docs/meshcore-meshtastic-parity.md](docs/meshcore-meshtastic-parity.md#serialized-traceroutes-protocol-requirement). - -### MeshCore Rooms (BBS) - -- **UI:** `RoomsPanel.tsx` — login overlay, post composer (`ChatComposer`), admin CLI, auto-sync toggles; sidebar badge via `meshcoreRoomsUnread.ts` (`mesh-client:meshcoreRoomsUnread`). -- **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). -- **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`. - -### Connection panel helpers - -- **Error humanization:** `connectionPanelErrorHumanize.ts` — serial/HTTP/BLE user-facing hints (i18n); uses `electronAPI.getPlatform()`. -- **Last connection / reconnect rehydrate:** `lastConnectionStorage.ts` — `mesh-client:lastConnection:` and BLE fallback keys; rebuild RF params after wake or Noble disconnect. -- **Storage migrations:** `connectionPanelStorageMigrations.ts` — idempotent localStorage fixes on ConnectionPanel mount and from `main.tsx` before React mount (MeshCore MQTT preset reconcile, Colorado port 443 migration, Colorado region-ack auto-launch gate, and IATA topic-prefix normalization). -- **MeshCore chat channel filter:** `meshcoreConfiguredChatChannels.ts` — zero-PSK slots excluded from unread badges and chat channel pills. - -### Common issues - -| Symptom | Where to check | -| ------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Connection fails | `ConnectionDriver`, `useProtocolConnection.ts`, `runtime/useMeshtasticRuntime.ts`, `runtime/useMeshcoreRuntime.ts` | -| Empty chat/nodes offline | `hydrateIdentityStoresFromDb`, connect-time cache in runtimes, `useDbRefresh`; identity split — [troubleshooting](docs/troubleshooting.md#chat-stuck-new-traffic-in-logsdb-but-messages-do-not-appear) | -| Chat stuck / badge moves, no new rows | `identityByProtocol`, `useActiveMeshIdentity`, `mergeOfflineIdentityStore`; **Export for GitHub** — [troubleshooting](docs/troubleshooting.md#reporting-bugs-export-for-github-app-tab) | -| BLE timeout | `noble-ble-manager.ts`, `bleConnectErrors` | -| Reticulum sidecar won't start | `reticulum-sidecar-manager.ts`, `ipc/reticulum-handlers.ts`, [troubleshooting](docs/troubleshooting.md#reticulum-sidecar-wont-start-or-health-poll-times-out) | -| Nomad hosting enabled but not serving | `nomadServingApi.ts`, `reticulum-sidecar/src/stack/nomad_server.rs` / `nomad_content_source.rs`, `[nomad-serving]` logs, `nomadPageErrorHumanize.ts` — [troubleshooting](docs/troubleshooting.md#nomad-my-pages-hosting-enabled-but-not-serving) | -| Reticulum interface CRUD fails | `ReticulumInterfacesPanel.tsx` / `ReticulumStackPanel.tsx`, `proxyPut`/`proxyDelete` — [troubleshooting](docs/troubleshooting.md#reticulum-interface-addeditdelete-fails) | -| Reticulum Remote transfer / inbound policy | `RemoteTransferSection.tsx` / `RemoteSettingsSection.tsx`, `rncpTransferStore.ts` / `reticulumInboundPolicyStore.ts`, `pushRncpListenerPolicy.ts` — [troubleshooting](docs/troubleshooting.md#reticulum-remote-transfer-fails-or-path_constrained) | -| Reticulum LXST voice fails / silent | `reticulumVoiceSession.ts`, `reticulumVoiceStore.ts`, sidecar `voice_session.rs`; [troubleshooting](docs/troubleshooting.md#reticulum-lxst-voice-call-fails-or-is-silent) | -| Reticulum LXMF hangs with Auto + LAN hub | sidecar `auto_path_policy.rs` / `lxmf_outbound.rs`; [troubleshooting](docs/troubleshooting.md#reticulum-local-dms-hang-with-autointerface--private-tcp-hub) | -| Serial port auto-rediscovery | `serialPortAutoRediscovery.ts` (60 s window, 5 s poll) — [troubleshooting](docs/troubleshooting.md#serial-port-auto-rediscovery-after-reconnect-exhaustion) | -| Meshtastic MQTT text on wrong channel tab | `mqtt-manager.ts` (`resolveMqttInboundTextChannelIndex`), debug snapshot `meshtastic.channelPills` / `channelConfigsSummary` / `mqttChannelKeyEntryCount` — [troubleshooting](docs/troubleshooting.md#meshtastic-inbound-messages-on-the-wrong-channel-tab) | -| Chat export fails | `chat:export` handler in `src/main/index.ts` | -| Support export fails | `support:exportBundle` in `src/main/support-bundle.ts`; App tab **Export for GitHub** / **Export for Developer** | -| Draft not restored | `chatPanelProtocolStorage.ts`, `viewKey` logic | -| Mention picker missing | `MentionAutocomplete.tsx`, `buildMentionCandidates` | -| Link preview missing | `fetchLinkPreview.ts`, `chat:fetchLinkPreview` IPC; also check direct-image extension/MIME, YouTube oEmbed, and magic-byte sniff failures; previews always fetch (including while reading history) | -| Duplicate RF+MQTT msg | `meshtasticMessageDedup.ts`, Meshtastic runtime ingest | -| MeshCore duplicate/echo | `meshcoreStoreDedup.ts`, `useMeshcoreRuntime.ts` | -| Room login/post fails | `meshcoreRoomLoginRpc.ts`, `meshcoreRoomPostRpc.ts`, [troubleshooting](docs/troubleshooting.md#meshcore-room-server-login-posts-and-windows-10) | -| Rooms unread vs Chat | `meshcoreRoomsUnread.ts` — Rooms tab badge only; orphan room SQL filtered by known Room contacts; contact delete cascades room messages (`deleteMeshcoreContactOn`); tombstones in `meshcoreLocallyDeletedContacts.ts` | -| MQTT decrypt / sender | `mqtt-manager.ts`, `meshtasticMqttIdentity.ts` | -| Remote admin fails | `meshtasticRemoteAdmin.ts`, key storage | -| S&F history garbled | `meshtasticBacklogUtils.ts` decode, heartbeat trigger | -| Garbled TEXT_MESSAGE | `meshtasticBacklogUtils.ts` readable-text filter | -| Channel URL apply | `meshtasticChannelApply.ts`, `meshtasticUrlEncoder.ts` | -| Header red on loss | `connectionHeaderStatus.ts`, `mqttDisconnectIntent.ts` | -| Sleep/wake reconnect | `usePowerRecovery`, `systemPowerState`, `bleReconnectHelper`, `rfReconnectHelper`, runtimes; Meshtastic ~4s + MeshCore ~8s stagger + up to 30s dual-Noble settle | -| MeshCore contact prune | `meshcoreContactAgeCutoff.ts`, `database.ts` (`last_advert` seconds); favorited exempt | -| MQTT transient after wake | `src/shared/networkTransientErrors.ts`, `mqtt:powerSuspend` / `mqtt:powerResume` IPC | -| MeshCore ping no route / priming | `meshcoreTraceRoutePrime.ts`, `meshcoreHookPreamble.ts`, `meshcore.errors.pingNoRoute`; [troubleshooting](docs/troubleshooting.md#meshcore-trace-route-or-ping-trace-times-out) | -| Repeater CLI danger / auto-ping | `meshcoreRepeaterCliDanger.ts`, `RepeatersPanel.tsx` (`ensureCliRoutePrimed`); `repeatersPanel.cliMultiHopHint` | -| Room vs repeater LoginFail | `meshcoreRoomLoginRpc.ts` (fail fast) vs `meshcoreRepeaterLoginRpc.ts` + `meshcoreRepeaterPrefixPushRpc.ts` (wait for LoginSuccess) | -| Renderer hung after wake | `rendererHeartbeatWatchdog.ts`, `useRendererHeartbeat`; visible stall + export `mainLiveness`; [troubleshooting](docs/troubleshooting.md#macos-sleep--wake-and-auto-reconnect) — quit fully if no `[usePowerRecovery]` after resume watchdog | -| MeshCore TCP mid-init peer FIN | `useMeshcoreRuntime` initConn / `meshcore:tcp-*`; [troubleshooting](docs/troubleshooting.md#meshcore-tcp-connect-stuck-or-reconnect-loop-on-openhop) | - -| Chat hop pills missing | MeshCore: `meshcoreCompanionRxPathLenToHopCount` / `MeshCoreProtocol` / `meshcoreRawPacketCorrelate` / `meshcoreIngest`; Meshtastic: `meshtasticRfHops.ts` (`viaMqtt` / `hopStart===0` omit by design) | -| Meshtastic SDK routing console noise | `meshtasticSdkRoutingErrorConsoleHook.ts`, `meshtasticSdkRoutingErrorLog.ts` | +Deep, file-level subsystem detail now lives in [`docs/agents/`](docs/agents/README.md) so it loads on demand instead of on every prompt. **Open the matching file when a task touches that area.** + +| When working on… | Read | +| -------------------------------------------------------------------------------- | ------------------------------------------------------------------------ | +| Reticulum sidecar, LXMF, propagation, Remote/rnsh/rncp, Nomad, RRC, voice, games | [`docs/agents/reticulum.md`](docs/agents/reticulum.md) | +| LoRa BLE/serial, Noble reconnect, dual-radio startup, BLE coexistence | [`docs/agents/ble-serial.md`](docs/agents/ble-serial.md) | +| Renderer hooks/runtimes/stores, protocol entry points, DB, tab wiring | [`docs/agents/renderer-hooks.md`](docs/agents/renderer-hooks.md) | +| Meshtastic config/admin, channel URLs, Store & Forward, remote admin, GPS | [`docs/agents/meshtastic.md`](docs/agents/meshtastic.md) | +| MQTT ingest, channel key mapping, sticky BLE suppress | [`docs/agents/mqtt.md`](docs/agents/mqtt.md) | +| Chat panel, composer, link previews, notifications, dedup, hop badges, export | [`docs/agents/chat.md`](docs/agents/chat.md) | +| MeshCore Repeaters admin (ping/trace/neighbors/CLI/waiting drain) | [`docs/agents/meshcore-repeaters.md`](docs/agents/meshcore-repeaters.md) | +| MeshCore Rooms (BBS) login/post/sync/wire text | [`docs/agents/meshcore-rooms.md`](docs/agents/meshcore-rooms.md) | +| Diagnostics engines, rows, tab scoping | [`docs/agents/diagnostics.md`](docs/agents/diagnostics.md) | +| i18n / localization workflow, auto-translate, language selector | [`docs/agents/i18n.md`](docs/agents/i18n.md) | +| Connection panel helpers (error hints, rehydrate, storage migrations) | [`docs/agents/connection-panel.md`](docs/agents/connection-panel.md) | +| Symptom → where-to-check index | [`docs/agents/common-issues.md`](docs/agents/common-issues.md) | + +**Always-remember invariants** (details in the linked files): + +- Gate features with `ProtocolCapabilities` / `useRadioProvider(protocol)` — never `protocol === 'meshcore'`. +- Mount protocol runtimes **once** from `App.tsx`; do not remount in children. New protocol logic goes in `lib/` + thin runtime wiring, not monolithic runtimes. +- Prefer `useProtocolFacade` and identity-scoped stores (`identityStore` / `nodeStore` / `messageStore` / `connectionStore`, keyed by `identityId`); SQLite→UI via `hydrateIdentityStoresFromDb`. +- MeshCore zero-hop Status/Telemetry/Neighbors are pubkey-framed (no contact-list gate); multi-hop ping needs a hash-segment path (≥2 bytes), never the full destination pubkey. Do not change behavior guarded by `meshcoreZeroHopRepeaterWorkingState.test.ts` without explicit user request — see [`docs/agents/meshcore-repeaters.md`](docs/agents/meshcore-repeaters.md). +- Reticulum connect = sidecar start (not `ConnectionDriver` RF); no Noble/MQTT for Reticulum's own stack (sidecar owns BLE RNode via `btleplug`); a Reticulum BLE RNode may yield Noble on macOS/Windows — see [`docs/agents/ble-serial.md`](docs/agents/ble-serial.md) and [`docs/agents/reticulum.md`](docs/agents/reticulum.md). +- LoRa BLE reconnect is single-owner via `rfReconnectController`; manual disconnect must not auto-reconnect. Dual-radio Noble startup is serialized from `App.tsx` `useLayoutEffect` — see [`docs/agents/ble-serial.md`](docs/agents/ble-serial.md). ## 9. Cursor / Claude indexing diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 29fa0b318..5037bd754 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -31,7 +31,7 @@ Path alias `@/*` maps to `src/*` (see `tsconfig.json`). ## Multi-protocol (Meshtastic + MeshCore + Reticulum) -All three stacks can run at once: independent sessions, header switcher for focus (green / cyan / amber), inactive protocols stay connected, per-protocol unread badges. Meshtastic and MeshCore use `ConnectionDriver` for RF/MQTT; Reticulum uses the AGPL sidecar (`useReticulumRuntime`; no Noble/MQTT for Reticulum's own connections — the sidecar owns BLE RNode via `btleplug`). A Reticulum BLE RNode connect on macOS/Windows may still briefly suspend/yield Noble so it does not contend with the sidecar's BLE scan (see [AGENTS.md](AGENTS.md) **Multi-protocol BLE**). Capabilities differ (e.g. Meshtastic: full Security PKI/Modules/TAK; MeshCore: partial Security backup/restore, Repeaters, **Rooms** BBS; Reticulum: LXMF DMs, **Remote** rnsh/rncp (`hasReticulumRemotePanel`), **Nomad Network / My Pages** (`hasNomadNetworkPanel`), **Peers**, **RRC** hub chat (`hasRrcPanel`), propagation, RNode flasher, **Map** (RMAP v4 discovery), Topology). Sidebar tab slots are fixed in `src/renderer/lib/tabSlotIds.ts`; visibility is computed in `src/renderer/lib/appTabMappings.ts` (`computeTabMappings()` consumed from `App.tsx`); **Rooms** requires `hasRoomServersPanel`, Reticulum panels gate on `hasReticulumNetworkPanel` / `hasReticulumInterfaceConfig` / `hasReticulumDiscoveryMap` / `hasRrcPanel` / `hasReticulumRemotePanel` / `hasNomadNetworkPanel`, **Security**/`TAK` require capability flags (~16 visible tabs per LoRa protocol; MeshCore hides TAK; Reticulum hides LoRa-specific tabs). +All three stacks can run at once: independent sessions, header switcher for focus (green / cyan / amber), inactive protocols stay connected, per-protocol unread badges. Meshtastic and MeshCore use `ConnectionDriver` for RF/MQTT; Reticulum uses the AGPL sidecar (`useReticulumRuntime`; no Noble/MQTT for Reticulum's own connections — the sidecar owns BLE RNode via `btleplug`). A Reticulum BLE RNode connect on macOS/Windows may still briefly suspend/yield Noble so it does not contend with the sidecar's BLE scan (see [docs/agents/ble-serial.md](docs/agents/ble-serial.md) **Multi-protocol BLE coexistence**). Capabilities differ (e.g. Meshtastic: full Security PKI/Modules/TAK; MeshCore: partial Security backup/restore, Repeaters, **Rooms** BBS; Reticulum: LXMF DMs, **Remote** rnsh/rncp (`hasReticulumRemotePanel`), **Nomad Network / My Pages** (`hasNomadNetworkPanel`), **Peers**, **RRC** hub chat (`hasRrcPanel`), propagation, RNode flasher, **Map** (RMAP v4 discovery), Topology). Sidebar tab slots are fixed in `src/renderer/lib/tabSlotIds.ts`; visibility is computed in `src/renderer/lib/appTabMappings.ts` (`computeTabMappings()` consumed from `App.tsx`); **Rooms** requires `hasRoomServersPanel`, Reticulum panels gate on `hasReticulumNetworkPanel` / `hasReticulumInterfaceConfig` / `hasReticulumDiscoveryMap` / `hasRrcPanel` / `hasReticulumRemotePanel` / `hasNomadNetworkPanel`, **Security**/`TAK` require capability flags (~16 visible tabs per LoRa protocol; MeshCore hides TAK; Reticulum hides LoRa-specific tabs). **Feature gating:** use `ProtocolCapabilities` via `useRadioProvider(protocol)` from `src/renderer/lib/radio/providerFactory.ts`; do not branch on raw `protocol === 'meshcore'` strings. @@ -74,7 +74,7 @@ Sanitize user-controlled strings before logs and IPC per [AGENTS.md](AGENTS.md). **First places to look:** `runtime/useMeshtasticRuntime.ts` / `runtime/useMeshcoreRuntime.ts` (protocol side effects); `hooks/useProtocolConnection.ts` (connect); `stores/*` (UI state); `src/main/index.ts` (IPC). -**Renderer layers:** `runtime/` (single-mount protocol runtimes), `hooks/` (facades and store selectors), `lib/` (drivers, sessions, types), `stores/` (identity-scoped UI: `identityStore`, `nodeStore`, `messageStore`, `connectionStore`; Reticulum also uses session-global `reticulumIdentityStore` for sidecar identity status). Prefer `useProtocolFacade(protocol)` in App for new wiring. Hook/runtime boundaries: [AGENTS.md](AGENTS.md#renderer-hook-architecture-multi-protocol) ([#375](https://github.com/Colorado-Mesh/mesh-client/issues/375), [#377](https://github.com/Colorado-Mesh/mesh-client/issues/377)). +**Renderer layers:** `runtime/` (single-mount protocol runtimes), `hooks/` (facades and store selectors), `lib/` (drivers, sessions, types), `stores/` (identity-scoped UI: `identityStore`, `nodeStore`, `messageStore`, `connectionStore`; Reticulum also uses session-global `reticulumIdentityStore` for sidecar identity status). Prefer `useProtocolFacade(protocol)` in App for new wiring. Hook/runtime boundaries: [docs/agents/renderer-hooks.md](docs/agents/renderer-hooks.md) ([#375](https://github.com/Colorado-Mesh/mesh-client/issues/375), [#377](https://github.com/Colorado-Mesh/mesh-client/issues/377)). **Drivers / identity bridge:** `lib/drivers/ConnectionDriver.ts` owns RF/MQTT session lifecycle and dispatches Protocol events into `lib/drivers/PacketRouter.ts` (store ingest first, then side-effect listeners). `PacketRouter` invokes generic listeners before event-type listeners, in registration order within each group; attach persistence/ingest before dependent UI side effects. `lib/meshIdentityBridge.ts` builds transport params and attaches Meshtastic Protocol ingress; `lib/identityStoreReads.ts` is the canonical read path for identity-scoped nodes/messages (`getIdentityNode` / `getIdentityChatMessages`). @@ -90,7 +90,7 @@ Sanitize user-controlled strings before logs and IPC per [AGENTS.md](AGENTS.md). - **`useRendererHeartbeat`** / **`useLongSessionMaintenance`** — renderer pings main every 30s; main `rendererHeartbeatWatchdog` warns if no heartbeat within 30s after resume **while visible**, and polls for a **90s visible-window stall**; sticky `rendererUnresponsiveSeen` + `getRendererLiveness()` feed support snapshot `mainLiveness`; long-uptime restart nudge. - **`ProtocolAutoConnectCoordinator`** / **`useProtocolRfAutoConnect`** — silent launch auto-connect for remembered serial/BLE/TCP/HTTP (cancel gate before manual Connect). - **`rfReconnectController`** — LoRa single-owner reconnect scheduling shared by Meshtastic/MeshCore runtimes. -- **Dual-radio Noble BLE startup** (Meshtastic + MeshCore different peripherals): `lib/meshcoreDualNobleBleInit.ts` initialized from `App.tsx` `useLayoutEffect`; primary order from `mesh-client:protocol` localStorage — see [AGENTS.md](AGENTS.md) **Dual-radio Noble BLE startup**. +- **Dual-radio Noble BLE startup** (Meshtastic + MeshCore different peripherals): `lib/meshcoreDualNobleBleInit.ts` initialized from `App.tsx` `useLayoutEffect`; primary order from `mesh-client:protocol` localStorage — see [docs/agents/ble-serial.md](docs/agents/ble-serial.md) **Dual-radio Noble BLE startup**. ### Database diff --git a/docs/agents/README.md b/docs/agents/README.md new file mode 100644 index 000000000..8145ea67d --- /dev/null +++ b/docs/agents/README.md @@ -0,0 +1,20 @@ +# Agent subsystem reference + +Deep, file-level subsystem detail for AI assistants, split out of [`AGENTS.md`](../../AGENTS.md) so it is loaded on demand rather than on every prompt. Hard rules and the always-on workflow/security/style policy stay in `AGENTS.md`; open the matching file here when a task touches that subsystem. + +| When working on… | Read | +| ---------------------------------------------------------------------------------------- | ---------------------------------------------- | +| Reticulum sidecar, LXMF, propagation, Remote/rnsh/rncp, Nomad, RRC, voice, games | [reticulum.md](reticulum.md) | +| LoRa BLE/serial, Noble reconnect, dual-radio startup, BLE coexistence | [ble-serial.md](ble-serial.md) | +| Renderer hooks/runtimes/stores, protocol entry points, DB, tab wiring | [renderer-hooks.md](renderer-hooks.md) | +| Meshtastic config apply, admin, channel URLs, Store & Forward, remote admin, GPS | [meshtastic.md](meshtastic.md) | +| MQTT ingest, channel key mapping, sticky BLE suppress | [mqtt.md](mqtt.md) | +| Chat panel, composer, link previews, notifications, dedup, hop badges, reactions, export | [chat.md](chat.md) | +| MeshCore Repeaters admin (ping/trace/neighbors/CLI/waiting drain) | [meshcore-repeaters.md](meshcore-repeaters.md) | +| MeshCore Rooms (BBS) login/post/sync/wire text | [meshcore-rooms.md](meshcore-rooms.md) | +| Diagnostics engines, rows, tab scoping | [diagnostics.md](diagnostics.md) | +| i18n / localization workflow, auto-translate, language selector | [i18n.md](i18n.md) | +| Connection panel helpers (error hints, rehydrate, storage migrations) | [connection-panel.md](connection-panel.md) | +| Symptom → where-to-check index | [common-issues.md](common-issues.md) | + +For human-facing deep dives, see the top-level docs (e.g. [../reticulum.md](../reticulum.md), [../diagnostics.md](../diagnostics.md), [../meshcore-meshtastic-parity.md](../meshcore-meshtastic-parity.md), [../troubleshooting.md](../troubleshooting.md)). diff --git a/docs/agents/ble-serial.md b/docs/agents/ble-serial.md new file mode 100644 index 000000000..d401fc64f --- /dev/null +++ b/docs/agents/ble-serial.md @@ -0,0 +1,43 @@ +# Agent reference: BLE and serial + +Deep subsystem reference for AI assistants. Open this when a task touches LoRa BLE/serial transports, Noble reconnect, dual-radio startup, or multi-protocol BLE coexistence. Hard rules live in [`AGENTS.md`](../../AGENTS.md). + +Meshtastic and MeshCore share LoRa BLE reconnect contracts whenever possible (platform + protocol parity). **macOS/Windows (Noble):** both use `noble-ble-manager.ts` session ids `meshtastic` / `meshcore`. **Linux:** Web Bluetooth (`webbluetooth-ble-manager.ts` / MeshCore Web BT path) — no Noble GATT manager, no `withNobleBleConnectMutex`. Serial: `connection.ts`, `serialPortSignature.ts`. Meshtastic BLE open: `connection.ts` / `TransportManager`. Reticulum BLE RNode uses sidecar `btleplug` (not Noble connect); see Multi-protocol BLE coexistence below. + +## LoRa BLE reconnect parity (Meshtastic + MeshCore) + +- **`rfReconnectController`** (`lib/rfReconnectController.ts`): single-owner link-lost / schedule / endAttempt for both runtimes (MeshCore TCP uses the same owner; conn side effects must not call `handleConnectionLost` for TCP). +- **`gattSetupInflight`** in `noble-ble-manager` (both session ids): after `connectAsync`, mid-GATT disconnect rejects in-flight `connect()` promptly (Noble only). +- **Deferred Noble disconnect** while connect/reconnect open is in flight; **flush in reconnect `finally`** in `useMeshtasticRuntime` and `useMeshcoreRuntime` so edge-of-range drops keep retrying. +- **`NOBLE_BLE_RECONNECT_ATTEMPT_BUDGET_MS`** (`timeConstants.ts`) + `raceWithDeadline` (`bleReconnectHelper.ts`): hard ceiling per BLE reconnect open+handshake attempt on **all platforms** (unsticks configure/attach hangs; does not add a Linux cross-protocol mutex). +- **Mutex:** Noble IPC connects still go through `withNobleBleConnectMutex()` — budget must not leave that mutex held (timeout clears in-flight flags; late open losers are ignored via attempt-active / generation guards). +- MeshCore must **not** start the runtime reconnect loop on disconnect before the first successful configure — ConnectionPanel `reconnectBleWithScan` owns initial retries (`meshcoreEverConfiguredRef`). **Manual disconnect** (`connectionStore.disconnectIntent`) must **not** auto-reconnect — covered by `useMeshcoreRuntime.reconnect.test.ts`, `useMeshtasticRuntime.reconnect-hardening.test.ts`, and `useReticulumRuntime.reconnect-hardening.test.ts`. + +**Meshtastic USB serial vendor patches:** `@jsr/meshtastic__core` and `@jsr/meshtastic__transport-web-serial` are patched via pnpm `patchedDependencies` (`patches/@jsr__meshtastic__core@*.patch`, `patches/@jsr__meshtastic__transport-web-serial@*.patch`) so Web Serial streams abort cleanly on disconnect (avoids “port is already open” on reconnect). Re-hash patches after JSR bumps; see `docs/troubleshooting.md`. + +**ATT MTU / writes:** Noble `toRadio` writes in `noble-ble-manager.ts` are chunked using negotiated `peripheral.mtu` (sanitized via `src/shared/bleAttWriteLimit.ts`; values below spec min 23 are coerced—NobleMac may log `MTU updated: 20` before a full exchange). Linux Web Bluetooth uses `webbluetooth-ble-manager.ts`; when Chromium exposes `maximumWriteValueLength`, writes are chunked—there is no standard Web API for negotiated MTU ([WebBluetoothCG#383](https://github.com/WebBluetoothCG/web-bluetooth/issues/383)). + +**Meshtastic transport writes:** `meshtasticTransportLossDetection.ts` wraps `transport.toDevice` with `createSerializedWritableStream` on **serial, BLE, HTTP, and TCP** so concurrent SDK `getWriter()` calls (ping, Store & Forward, queue) do not throw `WritableStream is locked`. Meshtastic **WiFi/TCP (fast)** uses `TransportTcpIpc` in the renderer with main-process `meshtastic:tcp-*` IPC (`net.Socket` on port **4403**). After configure, `getMetadata` retries once after `MESHTASTIC_GET_METADATA_AFTER_CONFIGURE_RETRY_MS` when NodeDB traffic starves BLE. **`meshtasticSdkRoutingErrorConsoleHook.ts`** intercepts SDK `console.error`/`warn` routing failures, logs matched lines at **`console.debug`**, and applies **`applyMeshtasticOutboundRoutingErrorFromLog`** / **`FromRejection`** to mark outbound chat rows failed; unmatched queue rejections log as `[meshtasticSdkRoutingErrorLog]` (timeouts may log via `warn` in queue.js). + +**Linux Web Bluetooth (Meshtastic):** `webbluetooth-ble-manager.ts` subscribes to **fromNum** GATT notify for unsolicited mesh traffic, runs a **3 s background fromRadio poll** between write cycles, and uses **multi-shot read probes** instead of a single post-write safety read (LoRa latency). MeshCore BLE echo filtering: `meshcoreCompanionTxEchoFilter.ts` (Noble + Web Bluetooth). Chooser sessions are generation-scoped; Connect/Reconnect **await** `cancelBluetoothSelection` before `requestDevice()` (see [troubleshooting](../troubleshooting.md#ble-known-issues)). Linux uses `bleCoexistenceWebBt` scan/register helpers — not Noble `gattSetupInflight` or dual-radio Noble startup deferral. + +## Dual-radio Noble BLE startup (macOS/Windows) + +When both Meshtastic and MeshCore have **different** saved BLE peripherals, the renderer must serialize auto-connect and manual Noble connects. Coordinator: `src/renderer/lib/meshcoreDualNobleBleInit.ts`; UI wiring: `ConnectionPanel.tsx` (both panels stay mounted from `App.tsx`). + +| Rule | Detail | +| -------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Init timing | Call `initNobleBleDualRadioStartup()` from **`App.tsx` `useLayoutEffect`** (not `useEffect`). Child ConnectionPanel auto-connect `useEffect` runs after layout effects — initializing in parent `useEffect` races and leaves primary unset. | +| Primary order | `mesh-client:protocol` localStorage (`meshcore` / `meshtastic`; Reticulum or missing → Meshtastic). Single-radio installs skip peer deferral. | +| Primary notify | Primary calls `notifyNobleBlePrimaryRfLinkReady()` when GATT + handshake succeed (MeshCore transport + Meshtastic `createBleConnection`), or `notifyNobleBlePrimaryAutoConnectSettled()` on first attempt failure — **not** after full configure or scan fallback. | +| Secondary wait | Secondary waits only on `awaitNobleBlePrimaryAutoConnectSettled()` — do **not** add `awaitNobleBleProtocolSettle()` here (mutex + post-config defer handles configure overlap). | +| Mutex | All Noble IPC connects go through `withNobleBleConnectMutex()` (Meshtastic + MeshCore). No-op on Linux Web Bluetooth. | +| LoRa reconnect | Shared: `gattSetupInflight`, deferred disconnect + reconnect `finally` flush, `NOBLE_BLE_RECONNECT_ATTEMPT_BUDGET_MS`. MeshCore: no runtime reconnect loop before first configure (`meshcoreEverConfiguredRef`). Manual disconnect must not auto-reconnect — `useMeshcoreRuntime.reconnect.test.ts`, `useMeshtasticRuntime.reconnect-hardening.test.ts`, `useReticulumRuntime.reconnect-hardening.test.ts`. | +| Tests | Protocol-neutral unit tests: `meshcoreDualNobleBleInit.test.ts`. Meshtastic + MeshCore defer paths: `ConnectionPanel.test.tsx` (`active-protocol-first BLE auto-connect`). | + +Do **not** reintroduce Meshtastic-only startup gates, child-before-parent init, or runtime-side secondary auto-connect subscriptions — ConnectionPanel owns auto-connect for both protocols. + +## Multi-protocol BLE coexistence (incl. Reticulum RNode Noble yield) + +- Meshtastic, MeshCore, and Reticulum (BLE Peer + `ble://` RNode) may connect to **different** BLE devices at once on all platforms. Coexistence: `ble-coexistence-coordinator.ts` (peripheral MAC registry + scan-only mutex); Linux mesh uses Web Bluetooth + sidecar `btleplug`. Same MAC rejected; scans serialized—never disconnect unrelated GATT for scans. +- **Reticulum BLE RNode** on macOS/Windows may **suspend Noble** (`suspendNobleForReticulumBleConnect`, `reticulum-ble-rnode-config.ts`, `reticulumNobleBleYield.ts`, `useReticulumNobleBleYieldWatcher`) — main kicks yield **after** sidecar HTTP health (not during cargo/spawn) so Cancel does not yank LoRa BLE; while yield holds the scan, Noble connect is rejected; post-grace yield stops re-contending (**~60s** grace aligns with OS passkey window); disconnect timeout fails closed (releases scan). Noble yield sync is **only** in `useReticulumNobleBleYieldWatcher` (always mounted from `useReticulumRuntime`, including `connecting`); **`useReticulumInterfaceSnapshot` must not release yield** (mid-pair release caused CoreBluetooth “Event receiver died”). Shared grace clock: `reticulumBleConnectGrace.ts` (watcher + snapshot). Watcher uses `AbortSignal` to avoid stale inactive release; renews grace on stack restart when main re-holds `scanOwner=reticulum` and local yield is inactive. Meshtastic/MeshCore RF autostart waits `awaitReticulumBleCoexistenceClear()` (`reticulumStartupAutostartGate.ts`, default **~65 s** = 60 s grace + 5 s buffer). Sidecar may latch **`bleBondRemoved`** (stale OS bonds) or **`blePairingTimedOut`** (passkey not entered) — Forget/re-pair; Admin Start pairing shows PIN in-panel over USB (not radio display; never Meshtastic `123456`). Release dispatches `mesh-client:nobleBleYieldReleased` for Meshtastic/MeshCore reconnect. diff --git a/docs/agents/chat.md b/docs/agents/chat.md new file mode 100644 index 000000000..e254ba938 --- /dev/null +++ b/docs/agents/chat.md @@ -0,0 +1,15 @@ +# Agent reference: Chat Panel + +Deep subsystem reference for AI assistants. Open this when a task touches the Chat panel, composer, link previews, notifications, dedup, hop badges, reactions/tapbacks, mentions, or chat/support export. Hard rules live in [`AGENTS.md`](../../AGENTS.md). + +- **Components:** `ChatPanel.tsx` (channel/DM UI) + shared `ChatComposer.tsx` (drafts, mentions, chunking, spellcheck, emoji; also used by `RoomsPanel.tsx`). Reticulum DM **Share as paper** / **Scan paper** via `ChatDmPaperControls.tsx` + `createReticulumPaperMessage.ts`. Scroll-at-bottom helper: `chatScrollUtils.ts` (`getDistFromChatBottom`). +- **Payload / links:** `ChatPayloadText.tsx` — mention highlighting, search marks, URL linkification; link previews via `chat:fetchLinkPreview` (`src/main/fetchLinkPreview.ts`): Open Graph for HTML pages; **YouTube** watch/shorts/youtu.be via oEmbed + thumbnail; **direct image URLs** (path extension via `chatDirectImageUrl.ts` or raster `Content-Type`) return `kind: 'image'` and render as inline embeds (`ChatInlineImage` / `DirectImageEmbed`); OG/YouTube use card layout. Security: DNS-pinned undici `Agent`, private/loopback blocked, magic-byte MIME sniff (`safeRasterImageMime.ts`), HTTPS-only image embeds, 10s fetch / 3s DNS, 64 KiB HTML cap, **2 MiB** image fetch cap (256 KiB cache payload cap), LRU caches, single-flight dedup (renderer map capped). Previews load even when scrolled up. LXMF attachment rasters: `chat:readReticulumAttachmentAsDataUrl` (`reticulum-attachment-image.ts`; path jail, magic-byte MIME, SVG rejected, 2 MiB, IPC rate limit) → `ReticulumAttachmentLine`. Reply quotes: `replyPreview.ts`. +- **Storage helpers:** `src/renderer/lib/chatPanelProtocolStorage.ts` — drafts (`mesh-client:drafts:`), open DM tabs, last-read, per-view mute (`mesh-client:mutedViews:`), starred (`mesh-client:starred:`, cap 200), MeshCore flood-scope overrides per chat view (`mesh-client:floodScopeOverrides:`, channel or DM `viewKey`). +- **Notifications:** `src/renderer/lib/chatNotifications.ts` — `playMessageNotification(type)` via Web Audio: `channel` = single 880 Hz pulse (150 ms); `dm` / `reply` = dual pulse (587.33 Hz then 783.99 Hz, 50 ms each, 35 ms gap). Resumes suspended `AudioContext` when the window is hidden/minimized. Type selection in `chatUnreadCounts.ts` (`resolveChatNotificationType`, `pickAudibleNotificationType`; batch priority reply > dm > channel). **ChatPanel** plays when the user is on Chat but reading another view; **App** plays for other panels / backgrounded window (avoids double beep). Meshtastic hidden-window desktop notifications are visual-only (`silent: true` in `meshtasticRouterSideEffects.ts`); typed Web Audio from App owns sound. Global mute `mesh-client:notifMuted`; per-view mute in `mutedViews`. Main-process **tray** icon shows unread when chat or MeshCore Rooms traffic arrives while backgrounded (`src/main/index.ts` `buildTrayIcon`). +- **Meshtastic dedup:** `meshtasticMessageDedup.ts` — merges delayed RF/MQTT duplicates (**10-minute** content window) in `useMeshtasticRuntime` ingest. +- **Hop badges:** `MessageRecord.rxHops` / `viaStoreForward` round-trip via `storeRecordAdapters.ts` and `meshtasticDbCacheHydration.ts` (`hopCount` on `MessageRecord` bridges to `rxHops` for Meshtastic PacketRouter rows); Chat hop pills read `ChatMessage.rxHops`. **MeshCore (primary):** companion `pathLen` on events 7/8 → `meshcoreCompanionRxPathLenToHopCount` → `DomainEvent.payload.hopCount` (`MeshCoreProtocol`, `meshcoreDirectMessageDecode`); waiting-message drain also sets `rxHops` from `pathLen`. **MeshCore (fallback):** raw-log correlation via `resolveMeshcoreIngestRxHops` / `MESHCORE_CHAT_CORRELATE_WINDOW_MS` (3000ms); `rawPacketsRef` synced inside event-136 `setRawPackets` updater for same-tick ingest (`meshcoreConnSideEffects`). **Meshtastic:** `MeshtasticProtocol` uses `meshtasticComputedRfHopsAway` — omit hops for `viaMqtt` and `hopStart === 0`; else `hopStart - hopLimit` when `hopStart > 0 && hopLimit <= hopStart`. +- **MeshCore dedup:** `meshcoreStoreDedup.ts` — RF/MQTT merge, companion TX echo, tapback self-echo, room BBS paths in `useMeshcoreRuntime` ingest (**5-minute** cross-transport window; distinct from Meshtastic’s 10-minute window). +- **Reactions / tapbacks:** `reactions.ts` (Meshtastic protobuf) + `meshcoreChannelText.ts` (MeshCore default outbound keyless `@[Name] emoji` / `@[Name] body`; opt-in **MeshCore Open compatibility** in App enables keyed replies, `r:` reactions, and `g:` GIF send via `meshcoreOpenWireCompatEnabled` in `appSettingsStorage.ts`; inbound keyed/sec↔ms parent match via `meshcoreMessageMatchesReplyKey`; inbound emoji-only replies promoted via `meshcorePromoteEmojiOnlyReplyToTapback`; inbound Open `r:HASH:INDEX` in `meshcoreOpenReaction.ts`); `ChatPanel.tsx` attaches tapbacks via `replyId` + runtime/panel `sendReaction`. +- **Mention segments:** `src/renderer/lib/chatMentionSegments.ts` — parse/build `@[Name]` tokens; `MentionAutocomplete.tsx` renders the dropdown. +- **Export IPC:** `chat:export` — renderer calls `window.electronAPI.chat.export(messages)`; main opens a Save dialog and writes a `.txt` file. +- **Support bundle IPC:** `support:exportBundle` — `exportSupportBundle.ts` → `window.electronAPI.support.exportBundle(mode, json)`; main `support-bundle.ts` writes zip (`github` = logs + debug snapshot including Reticulum diagnostic JSON and Meshtastic channel layout triage via `debugSnapshotMeshtasticContext.ts`; `developer` = SQLite plus redacted `reticulum/config` and `reticulum/mesh_client_stack.json`). Modes in `support-bundle.types.ts`. diff --git a/docs/agents/common-issues.md b/docs/agents/common-issues.md new file mode 100644 index 000000000..5e070ecf5 --- /dev/null +++ b/docs/agents/common-issues.md @@ -0,0 +1,43 @@ +# Agent reference: Common issues + +Deep subsystem reference for AI assistants. Symptom → where-to-check index. Hard rules live in [`AGENTS.md`](../../AGENTS.md). + +| Symptom | Where to check | +| ------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Connection fails | `ConnectionDriver`, `useProtocolConnection.ts`, `runtime/useMeshtasticRuntime.ts`, `runtime/useMeshcoreRuntime.ts` | +| Empty chat/nodes offline | `hydrateIdentityStoresFromDb`, connect-time cache in runtimes, `useDbRefresh`; identity split — [troubleshooting](../troubleshooting.md#chat-stuck-new-traffic-in-logsdb-but-messages-do-not-appear) | +| Chat stuck / badge moves, no new rows | `identityByProtocol`, `useActiveMeshIdentity`, `mergeOfflineIdentityStore`; **Export for GitHub** — [troubleshooting](../troubleshooting.md#reporting-bugs-export-for-github-app-tab) | +| BLE timeout | `noble-ble-manager.ts`, `bleConnectErrors` | +| Reticulum sidecar won't start | `reticulum-sidecar-manager.ts`, `ipc/reticulum-handlers.ts`, [troubleshooting](../troubleshooting.md#reticulum-sidecar-wont-start-or-health-poll-times-out) | +| Nomad hosting enabled but not serving | `nomadServingApi.ts`, `reticulum-sidecar/src/stack/nomad_server.rs` / `nomad_content_source.rs`, `[nomad-serving]` logs, `nomadPageErrorHumanize.ts` — [troubleshooting](../troubleshooting.md#nomad-my-pages-hosting-enabled-but-not-serving) | +| Reticulum interface CRUD fails | `ReticulumInterfacesPanel.tsx` / `ReticulumStackPanel.tsx`, `proxyPut`/`proxyDelete` — [troubleshooting](../troubleshooting.md#reticulum-interface-addeditdelete-fails) | +| Reticulum Remote transfer / inbound policy | `RemoteTransferSection.tsx` / `RemoteSettingsSection.tsx`, `rncpTransferStore.ts` / `reticulumInboundPolicyStore.ts`, `pushRncpListenerPolicy.ts` — [troubleshooting](../troubleshooting.md#reticulum-remote-transfer-fails-or-path_constrained) | +| Reticulum LXST voice fails / silent | `reticulumVoiceSession.ts`, `reticulumVoiceStore.ts`, sidecar `voice_session.rs`; [troubleshooting](../troubleshooting.md#reticulum-lxst-voice-call-fails-or-is-silent) | +| Reticulum LXMF hangs with Auto + LAN hub | sidecar `auto_path_policy.rs` / `lxmf_outbound.rs`; [troubleshooting](../troubleshooting.md#reticulum-local-dms-hang-with-autointerface--private-tcp-hub) | +| Serial port auto-rediscovery | `serialPortAutoRediscovery.ts` (60 s window, 5 s poll) — [troubleshooting](../troubleshooting.md#serial-port-auto-rediscovery-after-reconnect-exhaustion) | +| Meshtastic MQTT text on wrong channel tab | `mqtt-manager.ts` (`resolveMqttInboundTextChannelIndex`), debug snapshot `meshtastic.channelPills` / `channelConfigsSummary` / `mqttChannelKeyEntryCount` — [troubleshooting](../troubleshooting.md#meshtastic-inbound-messages-on-the-wrong-channel-tab) | +| Chat export fails | `chat:export` handler in `src/main/index.ts` | +| Support export fails | `support:exportBundle` in `src/main/support-bundle.ts`; App tab **Export for GitHub** / **Export for Developer** | +| Draft not restored | `chatPanelProtocolStorage.ts`, `viewKey` logic | +| Mention picker missing | `MentionAutocomplete.tsx`, `buildMentionCandidates` | +| Link preview missing | `fetchLinkPreview.ts`, `chat:fetchLinkPreview` IPC; also check direct-image extension/MIME, YouTube oEmbed, and magic-byte sniff failures; previews always fetch (including while reading history) | +| Duplicate RF+MQTT msg | `meshtasticMessageDedup.ts`, Meshtastic runtime ingest | +| MeshCore duplicate/echo | `meshcoreStoreDedup.ts`, `useMeshcoreRuntime.ts` | +| Room login/post fails | `meshcoreRoomLoginRpc.ts`, `meshcoreRoomPostRpc.ts`, [troubleshooting](../troubleshooting.md#meshcore-room-server-login-posts-and-windows-10) | +| Rooms unread vs Chat | `meshcoreRoomsUnread.ts` — Rooms tab badge only; orphan room SQL filtered by known Room contacts; contact delete cascades room messages (`deleteMeshcoreContactOn`); tombstones in `meshcoreLocallyDeletedContacts.ts` | +| MQTT decrypt / sender | `mqtt-manager.ts`, `meshtasticMqttIdentity.ts` | +| Remote admin fails | `meshtasticRemoteAdmin.ts`, key storage | +| S&F history garbled | `meshtasticBacklogUtils.ts` decode, heartbeat trigger | +| Garbled TEXT_MESSAGE | `meshtasticBacklogUtils.ts` readable-text filter | +| Channel URL apply | `meshtasticChannelApply.ts`, `meshtasticUrlEncoder.ts` | +| Header red on loss | `connectionHeaderStatus.ts`, `mqttDisconnectIntent.ts` | +| Sleep/wake reconnect | `usePowerRecovery`, `systemPowerState`, `bleReconnectHelper`, `rfReconnectHelper`, runtimes; Meshtastic ~4s + MeshCore ~8s stagger + up to 30s dual-Noble settle | +| MeshCore contact prune | `meshcoreContactAgeCutoff.ts`, `database.ts` (`last_advert` seconds); favorited exempt | +| MQTT transient after wake | `src/shared/networkTransientErrors.ts`, `mqtt:powerSuspend` / `mqtt:powerResume` IPC | +| MeshCore ping no route / priming | `meshcoreTraceRoutePrime.ts`, `meshcoreHookPreamble.ts`, `meshcore.errors.pingNoRoute`; [troubleshooting](../troubleshooting.md#meshcore-trace-route-or-ping-trace-times-out) | +| Repeater CLI danger / auto-ping | `meshcoreRepeaterCliDanger.ts`, `RepeatersPanel.tsx` (`ensureCliRoutePrimed`); `repeatersPanel.cliMultiHopHint` | +| Room vs repeater LoginFail | `meshcoreRoomLoginRpc.ts` (fail fast) vs `meshcoreRepeaterLoginRpc.ts` + `meshcoreRepeaterPrefixPushRpc.ts` (wait for LoginSuccess) | +| Renderer hung after wake | `rendererHeartbeatWatchdog.ts`, `useRendererHeartbeat`; visible stall + export `mainLiveness`; [troubleshooting](../troubleshooting.md#macos-sleep--wake-and-auto-reconnect) — quit fully if no `[usePowerRecovery]` after resume watchdog | +| MeshCore TCP mid-init peer FIN | `useMeshcoreRuntime` initConn / `meshcore:tcp-*`; [troubleshooting](../troubleshooting.md#meshcore-tcp-connect-stuck-or-reconnect-loop-on-openhop) | +| Chat hop pills missing | MeshCore: `meshcoreCompanionRxPathLenToHopCount` / `MeshCoreProtocol` / `meshcoreRawPacketCorrelate` / `meshcoreIngest`; Meshtastic: `meshtasticRfHops.ts` (`viaMqtt` / `hopStart===0` omit by design) | +| Meshtastic SDK routing console noise | `meshtasticSdkRoutingErrorConsoleHook.ts`, `meshtasticSdkRoutingErrorLog.ts` | diff --git a/docs/agents/connection-panel.md b/docs/agents/connection-panel.md new file mode 100644 index 000000000..69ad0ff97 --- /dev/null +++ b/docs/agents/connection-panel.md @@ -0,0 +1,8 @@ +# Agent reference: Connection panel helpers + +Deep subsystem reference for AI assistants. Open this when a task touches ConnectionPanel error humanization, last-connection rehydrate, storage migrations, or MeshCore chat-channel filtering. Hard rules live in [`AGENTS.md`](../../AGENTS.md). + +- **Error humanization:** `connectionPanelErrorHumanize.ts` — serial/HTTP/BLE user-facing hints (i18n); uses `electronAPI.getPlatform()`. +- **Last connection / reconnect rehydrate:** `lastConnectionStorage.ts` — `mesh-client:lastConnection:` and BLE fallback keys; rebuild RF params after wake or Noble disconnect. +- **Storage migrations:** `connectionPanelStorageMigrations.ts` — idempotent localStorage fixes on ConnectionPanel mount and from `main.tsx` before React mount (MeshCore MQTT preset reconcile, Colorado port 443 migration, Colorado region-ack auto-launch gate, and IATA topic-prefix normalization). +- **MeshCore chat channel filter:** `meshcoreConfiguredChatChannels.ts` — zero-PSK slots excluded from unread badges and chat channel pills. diff --git a/docs/agents/diagnostics.md b/docs/agents/diagnostics.md new file mode 100644 index 000000000..a5085b47e --- /dev/null +++ b/docs/agents/diagnostics.md @@ -0,0 +1,9 @@ +# Agent reference: Diagnostics + +Deep subsystem reference for AI assistants. Open this when a task touches diagnostic engines, rows, or tab scoping. Hard rules live in [`AGENTS.md`](../../AGENTS.md). + +- **Engines:** `src/renderer/lib/diagnostics/`; `RoutingDiagnosticEngine.ts`, `RFDiagnosticEngine.ts` (includes MeshCore **High Companion TX Queue** when `queueLen > 200`), `RemediationEngine.ts`, `ReticulumDiagnosticEngine.ts`. +- **Store:** `src/renderer/stores/diagnosticsStore.ts`; routing/RF rows, foreign LoRa, MQTT ignore, redundancy. +- **Tab scoping:** `filterDiagnosticRowsForProtocol()` — Meshtastic/MeshCore tabs show LoRa rows only; Reticulum tab shows `reticulum/*` only. Foreign-LoRa tables UI is on Meshtastic and MeshCore tabs (keyed by that protocol’s self node id). +- **Extend:** adjust `DiagnosticRow` in `src/renderer/lib/types.ts`, add detector, wire `replaceRoutingRowsFromMap` / `replaceRfRowsForNode`; TTL defaults in `diagnosticRows.ts` (routing 24h, RF 1h). +- **Full reference:** [../diagnostics.md](../diagnostics.md). diff --git a/docs/agents/i18n.md b/docs/agents/i18n.md new file mode 100644 index 000000000..b00e005df --- /dev/null +++ b/docs/agents/i18n.md @@ -0,0 +1,14 @@ +# Agent reference: i18n / Localization + +Deep subsystem reference for AI assistants. Open this when a task touches locale files, the localization workflow, auto-translate, or the language selector. The always-on hard rule (never add hardcoded English) lives in [`.cursor/rules/use-i18n-no-hardcoded-english.mdc`](../../.cursor/rules/use-i18n-no-hardcoded-english.mdc); broader guidelines live in [`AGENTS.md`](../../AGENTS.md). + +- **Framework:** i18next + react-i18next; static JSON bundles loaded at startup; `fallbackLng: 'en'`. +- **Locale files:** `src/renderer/locales/{en,es,uk,de,zh,pt-BR,fr,it,pl,cs,ja,ru,nl,ko,tr,id}/translation.json` — English is source of truth (`pnpm run check:i18n` reports key count). +- **Locale persistence:** `locale` key in `app_settings` SQLite table (canonical) and `mesh-client:appSettings` localStorage (fast startup read); reconciled in `App.tsx` on mount. +- **Reduce motion:** `reduceMotion` boolean in the same `app_settings` / localStorage bundle; toggled in **App → Appearance** ([`AppPanel.tsx`](../../src/renderer/components/AppPanel.tsx)). When true, non-essential UI motion (animated icons, decorative CSS pulses) is suppressed; loading spinners and connection status pulses remain. Does not auto-sync to OS `prefers-reduced-motion` after first-run init — see [`../accessibility-checklist.md`](../accessibility-checklist.md). +- **24-hour time:** `use24HourTime` beside Reduce motion in **App → Appearance** (`timeFormatStore`, `formatDisplayTime`; SQLite `app_settings` + `mesh-client:appSettings` localStorage). When on, chat/diagnostics clocks force 24-hour; when off, follow system locale. +- **Adding strings:** add to `src/renderer/locales/en/translation.json`, use `t('your.key')` in components; `check:i18n` enforces all call sites resolve to English keys and **fails on unused English keys** (no static `t()`, registered dynamic prefix, quoted literal in `src/`, or `tabs.*` from `TAB_SLOT_IDS`). +- **Removing strings:** delete the key from `en/translation.json` and run `pnpm run i18n:prune-unused -- --write` to drop it from every locale (or remove manually). `check:i18n` blocks orphaned English keys. +- **Auto-translate:** `pnpm run i18n:auto-translate` uses MyMemory (default) or LibreTranslate (`LIBRETRANSLATE_URL`). With git, the default run **only** fills keys that are **new in English vs `HEAD`** and still missing from each locale (pre-commit uses this). Use **`pnpm run i18n:auto-translate --all`** or **`I18N_TRANSLATE_ALL=1`** to backfill every key missing from a locale vs English. Use **`--audit`** (or `I18N_AUDIT=1`) to additionally retranslate any key whose locale value is still identical to English (i.e. never actually translated). Existing translated entries are never overwritten. MyMemory sends contact `info@coloradomesh.org` by default for the 50 k words/day quota; override with `MYMEMORY_EMAIL` if needed. +- **Key check:** `pnpm run check:i18n` — hard fails on missing English keys and unused English keys; warns (does not fail) on incomplete locale coverage so rate-limit gaps don't block commits. Also runs locale quality rules via `scripts/check-i18n-quality.mjs` (mojibake, `meshtastic://` spacing, false friends, **boot-sequence** transport labels, **Reticulum hub/stack** wording, **RRC** slash-command token preservation and room false friends, **Repeaters CLI danger confirm** action text, **`repeatersPanel.cliMultiHopHint`** auto-ping semantics). Unused-key detection lives in `scripts/i18n-unused-keys.mjs`; `pnpm run check:i18n:branch` skips the unused pass and only runs quality rules on keys new/changed vs `HEAD`. +- **Language selector:** `src/renderer/components/LanguageSelector.tsx` — globe-icon dropdown in the header; calls `i18n.changeLanguage()` + `mergeAppSetting('locale', ...)` + `electronAPI.appSettings.set('locale', ...)`. diff --git a/docs/agents/meshcore-repeaters.md b/docs/agents/meshcore-repeaters.md new file mode 100644 index 000000000..893acbb37 --- /dev/null +++ b/docs/agents/meshcore-repeaters.md @@ -0,0 +1,22 @@ +# Agent reference: MeshCore Repeaters admin (Ping / trace) + +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). + +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`. +- **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. +- **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 new file mode 100644 index 000000000..95e342997 --- /dev/null +++ b/docs/agents/meshcore-rooms.md @@ -0,0 +1,9 @@ +# Agent reference: MeshCore Rooms (BBS) + +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`). +- **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). +- **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/meshtastic.md b/docs/agents/meshtastic.md new file mode 100644 index 000000000..064bdc4be --- /dev/null +++ b/docs/agents/meshtastic.md @@ -0,0 +1,13 @@ +# Agent reference: Meshtastic channel URLs & Store & Forward + +Deep subsystem reference for AI assistants. Open this when a task touches Meshtastic config apply, admin, channel URLs, Store & Forward, remote admin, last-heard, or static GPS. Hard rules live in [`AGENTS.md`](../../AGENTS.md). + +- **Config apply (Radio / Modules / Security):** Firmware `setConfig` / `setModuleConfig` replace full protobuf structs. UI must merge cached device slices with form edits via `meshtasticConfigApply.ts` (`mergeMeshtasticConfigApplyValue`, `buildMeshtasticModuleApplyValue`); slices live in `deviceStore.meshtasticConfigSlices` and `moduleConfigs` (PacketRouter + runtime lifecycle wire effects). Module-specific validation: `meshtasticMqttModuleApply.ts`, `meshtasticSerialModuleApply.ts`. Apply failures surface `clientNotification` text within 8s (`meshtasticClientNotification.ts` → `formatMeshtasticModuleApplyError`); inline status via `ConfigApplyNotice.tsx`. Forms re-sync after reboot via `useSyncFormFromConfig`. +- **Administration tab:** `AdminPanel.tsx` — device commands and Danger Zone (reboot, shutdown, factory reset, NodeDB reset, OTA/DFU); shared `ConfirmModal.tsx` with Radio/Modules destructive flows. Local-only OTA/DFU disabled when **Configure node** targets a remote node. +- **Remote admin module snapshot:** `meshtasticRemoteAdminModuleFetches.ts` — canonical list/count of `ModuleConfig` reads during remote snapshot (`REMOTE_ADMIN_MODULE_CONFIG_FETCHES`). +- **Channel URLs:** `src/shared/meshtasticUrlEncoder.ts` (parse/generate), `src/shared/meshtasticChannelApply.ts` (replace vs add-only apply); Radio panel UI; Meshtastic-only. +- **S&F chat history:** `src/renderer/lib/meshtasticBacklogUtils.ts` — `CLIENT_HISTORY` on primary router heartbeat after RF configure (auto: 50-msg cap, 120 min window cap, 15 min per-server cooldown, 5 min offline gate; `storeForwardAutoFetchHistory` opt-out; `storeForwardHistoryProfile: 'conservative' | 'aggressive'` in `defaultAppSettings.ts` tunes offline gate / cooldown / cap aggressiveness; manual catch-up in Chat). Protobuf decode for replayed text, `via_store_forward` on messages; do not await SDK queue for history (async replay). +- **MQTT broker clientId:** `src/main/mqtt-broker-client-id.ts` — stable per-install IDs in `app_settings` (`meshtasticMqttClientId`, `meshcoreMqttClientId`); MeshCore LetsMesh `v1_` username unchanged as clientId. +- **PKC remote admin (firmware 2.5+):** `meshtasticRemoteAdmin.ts` — PKI-wrapped `AdminMessage` via `MeshDevice.sendRaw()` (`pkiEncrypted: true`, channel omitted on wire); session passkeys (~300s); tab-scoped snapshot routes in `meshtasticRemoteAdminSnapshot.ts` (Channels-first LoRa load). Per-node keys: `meshtasticRemoteAdminKeyStorage.ts` (`meshtasticRemoteAdminKey:` in `app_settings`; base64 / `base64:` / 64-char hex paste). Dest public key: NodeDB hex first, stored admin-key base64 fallback. `useMeshtasticRuntime`: `configureTargetNodeNum`, `remoteConfigSnapshot`, `runRemoteAdminOp` (errors → UI + toast); serialize admin reads with S&F (`remoteAdminReadsActiveCount` in `meshtasticBacklogUtils.ts`). **Requires connected local radio** (MQTT-only cannot admin). UI: `ConfigureNodeSelector.tsx`; NodeDetailModal admin key + **Configure node remotely**; SecurityPanel **Copy** public key. Persist last target in `meshtasticConfigureTargetNodeNum`. Gate with `hasRemoteAdmin`. Legacy admin channel (PSK + `"admin"`) out of scope. +- **Meshtastic last heard:** `meshtasticLastHeard.ts` — bump `last_heard` on live RF packets (not only text); `computeNodeInfoLastHeardMs` prevents configure replay from regressing fresher client timestamps. +- **Static GPS:** `src/renderer/lib/gpsSource.ts` — App tab static coordinates sync to self-node, map, and radio `setPosition`. diff --git a/docs/agents/mqtt.md b/docs/agents/mqtt.md new file mode 100644 index 000000000..8613babff --- /dev/null +++ b/docs/agents/mqtt.md @@ -0,0 +1,5 @@ +# Agent reference: MQTT + +Deep subsystem reference for AI assistants. Open this when a task touches Meshtastic/MeshCore MQTT ingest, channel key mapping, or the sticky BLE suppress. Hard rules live in [`AGENTS.md`](../../AGENTS.md). + +Meshtastic: `mqtt-manager.ts` (AES-128/256-CTR, Meshtastic nonce layout, channel keys, protobuf, dedup); inbound **TEXT_MESSAGE** ingest prefers **topic channel name** → `channelNameToIndex` (receiver-local slot); `MeshPacket.channel` is fallback when topic absent — sampled log when they disagree (`mqtt-channel-topic-mismatch:*`); Connection panel **Channel PSKs** `ChannelName@index=` for MQTT-only slot mapping; `meshtasticMqttPublish.ts`; `meshtasticChannelPskInput.ts` + `src/shared/meshtasticChannelPskLine.ts`; `meshtasticMqttSettingsStorage.ts`; `meshtasticMqttIdentity.ts` (MQTT-only `from`); `mqtt-broker-client-id.ts`. After RF configure, `useMeshtasticRuntime` must **re-push** `resolvedChannelConfigs` via `mqtt.updateChannelKeys` (not only on MQTT status change) so cold-start MQTT before deviceStore channels still gets correct topic→slot maps (`[Meshtastic MQTT] channelNameToIndex updated`). MeshCore: `meshcore-mqtt-adapter.ts` (JSON v1); LetsMesh JWT `letsMeshJwt.ts`. **Sticky MeshCore BLE “Blue” suppress:** `connectedMeshcoreBleMac.ts` persists a valid MeshCore BLE MAC and pre-arms Meshtastic NodeDB ghost suppression across cold start, failed reconnect, and user disconnect; clear only on Forget or switching MeshCore to a non-BLE transport. diff --git a/docs/agents/renderer-hooks.md b/docs/agents/renderer-hooks.md new file mode 100644 index 000000000..be81d94d6 --- /dev/null +++ b/docs/agents/renderer-hooks.md @@ -0,0 +1,34 @@ +# Agent reference: Renderer hook architecture (multi-protocol) + +Deep subsystem reference for AI assistants. Open this when a task touches renderer hooks/runtimes/stores, protocol entry points, identity hydration, the SQLite database layer, or tab/UI wiring. Hard rules live in [`AGENTS.md`](../../AGENTS.md). + +See **Renderer: hooks vs runtime vs lib** (layout map in [`AGENTS.md`](../../AGENTS.md#2-architecture--domain)). Legacy `useDevice` / `useMeshCore` are removed ([#375](https://github.com/Colorado-Mesh/mesh-client/issues/375), [#377](https://github.com/Colorado-Mesh/mesh-client/issues/377)). Default rules for new UI: + +| Concern | Use | +| ----------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Orchestration (App tab) | `useProtocolFacade(protocol)` — connection, `useConnectionView`, panel bundle, nodes, messages | +| Active protocol identity | `useActiveMeshIdentity(protocol)` — focused `identityId` per tab; prefer `capabilities` over `protocol ===` | +| LoRa dual-protocol panel bundles (App) | `useAllProtocolPanelActions` (Meshtastic + MeshCore + Reticulum); prefer this over per-protocol panel-action hooks at the App shell | +| Reads (nodes, messages, connection fields) | Zustand stores + `useNodes` / `useMessages` / `useConnectionView` / `useConnectionStatus` | +| Writes (configure, send, admin, panel callbacks) | `usePanelActions(protocol, identityId, …)` / `useProtocolFacade(protocol).panel` or `useSendMessage(identityId)` | +| Connect / disconnect / auto-connect | `useProtocolConnectionActions(protocol)` (`useProtocolConnect` + `useProtocolDisconnect` + `lib/sessions/*Session.ts`); Meshtastic/MeshCore via `ConnectionDriver`; Reticulum via sidecar start/stop. Launch RF auto-connect (serial/BLE/TCP/HTTP) via `ProtocolAutoConnectCoordinator` + `useProtocolRfAutoConnect` + `protocolRfAutoConnectGate` (`cancelProtocolRfAutoConnect` before manual Connect). LoRa reconnect single-owner: `rfReconnectController` | +| Wire subscriptions, MQTT IPC, reconnect, DB hydration | `useMeshtasticRuntime` / `useMeshcoreRuntime` / `useReticulumRuntime` in `runtime/` — mount **once** from `App.tsx` via context providers | + +Do **not** remount protocol runtimes in child components. Do **not** compare `protocol === 'meshcore'` for feature gates; use `ProtocolCapabilities` / `useRadioProvider(protocol)`. + +Protocol SDK adapters: `src/renderer/lib/protocols/`. Connection lifecycle: `ConnectionDriver` (`lib/drivers/`); inbound domain events: Protocol → `PacketRouter` → identity stores, then side-effect listeners (ingest already applied). **Meshtastic post-router side effects:** `lib/ingest/meshtasticIngest.ts`, `meshtasticRouterSideEffects.ts` (MQTT uplink / notifications / device_log), `meshtasticNodeSideEffects.ts`, `meshtasticRawPacketSideEffects.ts`, `meshtasticTraceSideEffects.ts`, `meshtasticModulePortSideEffects.ts`, `meshtasticStoreForwardSideEffects.ts`; `meshtasticTransportSideEffects.ts` handles transport-state cleanup, while lifecycle-only SDK attach remains in `meshtasticRuntimeWireEffects.ts` (DeviceStatus / MyNodeInfo / FromRadio / heartbeat / config / remote-admin — not a second packet decode path). **MeshCore post-router side effects:** `lib/ingest/meshcoreIngest.ts` (chat persist, `last_heard`, path-updated), `hooks/meshcore/meshcoreConnSideEffects.ts` + `MeshcoreConnSideEffectsCtx` (`meshcoreConnSideEffectsCtx.ts`) (DM ack 130, waiting drain 131, RF RX 136, CLI, disconnect), `lib/meshcore/meshcoreLiveContactPersist.ts` (SQLite contact rows), `lib/meshcore/meshcorePubKeyRegistry.ts` (DM/trace pubkeys). Live UI nodes/messages read `nodeStore` / `messageStore` via `identityStoreReads` (`getIdentityNode` / `getIdentityChatMessages`); runtimes do not keep hook-local node/message wire mirrors. Transport params / Protocol attach helpers: `meshIdentityBridge`. **Favorites:** `setNodeFavorited` patches `meshcoreIdentityIdRef` (fallback `getIdentityIdForProtocol('meshcore')`). **Dedup windows:** cross-transport and channel RF **5 min**; room/tapback **60 s**. Path-updated (129) for existing contacts does not bump SQLite `last_advert` until the next advert (128). + +**Identity-scoped UI stores:** `identityStore`, `nodeStore`, `messageStore`, `connectionStore` — nodes/messages keyed by `identityId`. **MQTT status bridge:** `mirrorMqttStatusToConnection` copies main-process `mqtt.onStatus` IPC into `connectionStore.mqttStatus` from runtime handlers until MQTT moves fully into `ConnectionDriver`. **SQLite → UI:** `lib/hydrateIdentityStoresFromDb.ts` (coordinator: `identityHydrationCoordinator.ts`; Meshtastic node map: `meshtasticDbCacheHydration.ts`; message cap: `meshtasticMessageLoadLimit.ts`); manual refresh via `hooks/useDbRefresh.ts`. Identity-scoped Zustand hydration is the canonical UI path ([#375]). **MeshCore contacts DB:** `meshcore_contacts.last_advert` is Unix **seconds**; age prune uses `src/shared/meshcoreContactAgeCutoff.ts` (do not compare in ms). + +## Protocol entry points + +- **Meshtastic:** `src/renderer/lib/protocols/MeshtasticProtocol.ts`, `useMeshtasticRuntime` (side effects), `src/renderer/lib/connection.ts` (`createConnection`) +- **MeshCore:** `src/renderer/lib/protocols/MeshCoreProtocol.ts`, `useMeshcoreRuntime` (side effects), `@liamcottle/meshcore.js` + +## Database + +WAL SQLite; `user_version` in `database.ts`; migrations as `migration_N()`; `db-compat.ts` over `node:sqlite`. After schema changes: `pnpm run check:db-migrations`. **Startup maintenance:** `lib/startupDbPrune.ts` — single-flight per session from `App.tsx` (node/message retention, RF stub migration); do not re-invoke from unstable effect deps. + +## 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. diff --git a/docs/agents/reticulum.md b/docs/agents/reticulum.md new file mode 100644 index 000000000..e6e63e787 --- /dev/null +++ b/docs/agents/reticulum.md @@ -0,0 +1,34 @@ +# Agent reference: Reticulum + +Deep subsystem reference for AI assistants. Open this when a task touches the Reticulum sidecar, LXMF, propagation, Remote (rnsh/rncp), Nomad, RRC, voice, or games. Hard rules live in [`AGENTS.md`](../../AGENTS.md); this file holds the file-level detail. + +- **Sidecar:** `reticulum-sidecar/` (AGPL Rust binary `mesh-client-reticulum`; path deps under repo-local `.rsstack/` via `scripts/clone-ratspeak-stack.sh` — `rsReticulum`/`rsLXMF`/`rsNomad`/`rsLXST`/`lrgp-rs`); dev: `pnpm run reticulum:sidecar:dev`. **Listen-first:** HTTP binds before `attach_live`; `/api/v1/status` `status: ok` = listening; `rns_ready`/`lxmf_ready` false until live. PN messagestore load deferred; local-prop serve waits for load. LXMF send/reaction fail closed with live-required errors until live. +- **IPC:** `reticulum:*` main handlers — `start` / `stop` / `getStatus` / `syncInterfaceIssueScope`, `proxyGet` / `proxyPost` / `proxyPut` / `proxyDelete`, **`factoryReset`** (blocked on generic proxy), config file read/import dialog, `showNomadContentSourceDialog`, `setNomadContentSource`, Remote `rncpSend` / `rncpFetch` / `setRncpListener` / `showRncpOpenFileDialog` / `showRncpSaveDirectoryDialog` / `revealInFolder`. Also `media:ensureCameraAccess`, `gps:exportGpx`, `db:setReticulumDestinationVerified`, Remote DB `db:listReticulumRemoteAddresses` / upsert / delete and `db:listReticulumInboundPolicy` / upsert / delete (`src/main/ipc/reticulum-db-handlers.ts`), `mesh-client:openUrl` / `electronAPI.deepLink.onOpenUrl`. Renderer uses `electronAPI.reticulum` proxy (no direct localhost). `ReticulumStackPanel` + `useReticulumInterfaceSnapshot` sync enabled interface names after hydrate so TCP/TX issue banners clear when hubs are disabled; `reticulumSidecarIssueTracker` keeps that enabled set sticky while reading sidecar logs. +- **Panels:** `ReticulumStackPanel` (Connection — stack lifecycle, interfaces, issue banner), `ReticulumNetworkPanel` (Network — identity **slots** + QR share/ingest, stack/announce settings, Propagation mode Off/Auto/Manual + rename/delete, config import), `ChatDmPaperControls` (Chat DM **Share as paper** + **Scan paper**), `ReticulumMapPanel` (Map — RMAP v4 discovery), `ReticulumRmapDiscoveryControls` / `ReticulumRmapConnectionStatus` (RMAP publish: Network enable-all eligible interfaces; Connection **X of Y** status), `ReticulumAdminPanel` (Admin — RNode flasher, factory reset), `ReticulumPeerListPanel` (Peers — **Peers / History / Contacts / Favorites** sub-tabs; path request + probe + verified badge; LXMFace avatars; History = messaged `last_heard`, Contacts = explicit `is_contact` / Save as contact only), `NomadNetworkPanel` (Nomad — browse + **My Pages** watched-folder static host via `NomadPageServerPanel`/rsNomad; `nomad_serving_enabled` + `nomad_serving_content_source` restore hosting after live stack start; lazy-mount keep-alive, dual-axis page scroll; fit-width default and open-width toggle), `ReticulumRemotePanel` (Remote — rnsh multi-session shell + rncp send/receive/fetch; Saved addresses + inbound policy; Chat DM send-file via `ChatDmRncpControl`), `RrcPanel` (RRC — multi-hub relay chat) +- **Deep links / QR:** OS scheme is **`lxm://`** (not `mesh-client://`); `MeshClientDeepLinkHost`, `meshClientDeepLink.ts` (`lxmPaperMessage` kind + `looksLikeLxmPaperBlob`; Games `lxm://game/` / Ratspeak `lrgp:` → `lxmGameSession`), `handleReticulumQrIngest.ts` (shared Network/Chat/OS paper + in-app contact ingest), `applyLxmPaperIngest` → `POST /api/v1/lxmf/paper/ingest`, `QrIngestControl` / `QrCodeImage`. OS contact / MeshCore imports confirm before upsert; **paper OS deep links ingest without confirm**; Games session links open Reticulum Games tab via `openReticulumGameSession`. +- **Decommissioned hubs:** `src/shared/reticulumDecommissionedHubs.ts` (Amsterdam only) — stack-start auto-disable + **Add default backbones** disables matching enabled TCP rows; UI badge + enable-block in `ReticulumInterfacesPanel.tsx` (`isDecommissionedReticulumTcpInterfaceRow`); keep TS↔Rust synced via `pnpm run check:reticulum-decommissioned-hubs`. Default backbone picker + region-grouped interface list (Primary & Global / North America / Europe / Asia & Oceania / Specialty / User Defined) in `reticulumDefaultHubPresets.ts` + `ReticulumDefaultHubsPickerModal.tsx`; muted disabled rows + checkbox bulk delete; `countEnabledDefaultHubPresets` / >3 enable warning +- **BLE RNode RSSI:** `useReticulumBleRnodeRssiMap` gates on sidecar **running** (not api-ready), burst-then-steady scans via nested `acquireReticulumBleScan`, clears sticky targets immediately when all BLE RNodes are disabled +- **Propagation mode / sync:** Network → Propagation nodes owns Off/Auto/Manual (default **Off**; persisted values including legacy App-panel `auto` are honored). Auto one-time syncs the best Discovered PN by destination hash (no Add, no Preferred write) via `startPropagationSyncCascade` + sidecar `destination_hash` sync, then configured remotes, then local-prop (skips remotes when no enabled interfaces); runtime hook `useReticulumPropagationAutoSync`. Manual uses Preferred, else picks the best configured remote **for that sync only** (no Preferred write), then the remaining remotes, then local-prop. Off = **no PN support**: `startPropagationSyncCascade` returns early (per-row Sync is disabled in UI), `hasEffectiveReticulumPropagationTarget` / `hasReticulumPnCascadeCapacity` are false, `ReticulumPropagationNotice` is hidden, and the sidecar disarms the outbound PN plus empties cascade candidates (`propagation_mode` in `mesh_client_stack.json`, `POST /api/v1/propagation/mode`, `candidates_for_propagation_mode`); renderer pushes the mode on change and on sidecar-ready. `reticulumPropagationStore` / `reticulumPropagationSync.ts` — Complete on HaveAll, Establishing stall (~45s) + hard ceiling (~180s), auto-sync interval from last success with failure cooldown, error keys for identity / non-PN / peering stamp; stamps `lastPropagationSyncAttemptAt` / `activePropagationSyncAttemptAt` for WS correlation. **Nothing-to-sync is not a failure:** when the cascade contacts no node it writes `syncNoTarget` / `syncLocalLoading` (never overwriting a real error from an attempted node), the local row reports sidecar `status: "loading"` while the messagestore reads (`local_propagation_status` + `PropagationBridge::messagestore_load_pending`, per-row Sync disabled), and the 30 s tick calls `refreshFromSidecar` while `hasPropagationCascadeCandidate` is false so a fresh stack recovers on its own — `refreshFromSidecar` must **not** clear the active attempt while `sync.active`. Debug snapshot `propagationClient` exposes mode/preferred/autoTarget/resolvedSyncTargetId. **Auto also deposits on Discovered PNs:** sidecar `auto_discovered_candidates` (`pn_cascade.rs`, Auto only, cap 3, hop-sorted, skips inactive / self / already-configured / over `max_peering_cost`) appends after configured remotes and before local-prop, rebuilt from the shared `rebuild_pn_cascade_candidates` helper in `live.rs` (called by `refresh_pn_cascade_candidates` **and** the PN announce handler); `hasEffectiveReticulumPropagationTarget` / `hasReticulumPnCascadeCapacity` therefore count discovered rows in Auto, so the Chat notice hides and the link-timeout failure bridge holds off. **Chat notice dismiss:** `chatNoticeDismissed` (`mesh-client:reticulumPropagationNoticeDismissed`) with **Don't show again** on the banner and **Show propagation reminder in Chat** in the Network section. **Named sync target:** `startSync` stamps `syncTargetId`; progress line, inline error, and Sync toasts resolve it with `resolveReticulumPropagationTargetLabel`; the cascade clears it when nothing was contacted so `syncNoTarget` / `syncLocalLoading` stay unprefixed. **Attempts settle before the cascade advances:** `startSync` returns `accepted` | `deferred` | `failed` (not a boolean) — only sidecar _acceptance_ starts `awaitPropagationSyncSettled` (terminal WS frame or stall/ceiling watchdog). `failed` advances with ~15 min session-memory omit via `reticulumPropagationSyncBackoff.ts`; `deferred` (`PROPAGATION_SYNC_OUTBOUND_BUSY` — outbound deposit owns the PN link) advances **without** backoff so the next tick may retry; `cancelled` (user Cancel) stops; `success` ends the run. Remote steps are capped by `PROPAGATION_CASCADE_BUDGET_MS` (5 min) then fall through to local-prop; each remote attempt is capped by `PROPAGATION_CASCADE_ATTEMPT_TIMEOUT_MS` (~60s); local fallback refreshes nodes when local looks disabled; the cascade is single-flight (`resetPropagationSyncCascadeState` is the test seam) so overlapping 30 s ticks join one run while an explicit per-row Sync supersedes it. Auto `/api/v1/interfaces` probe **fails open** (assumes interfaces enabled) so a broken proxy still tries remotes before local. +- **PN hosting:** Network **Advanced PN hosting** / `ReticulumPnHostingDangerZone`; shared `pnHostingPolicy.ts` + sidecar `pn_hosting_policy.rs` / `pn_hosting_apply.rs`; `POST /api/v1/propagation/hosting-policy`; rsLXMF policy-setters overlay ([ratspeak/rsLXMF#6](https://github.com/ratspeak/rsLXMF/pull/6)). Messagestore loads in background on live attach; enabled `local-prop` serve/announce waits until load completes. +- **Interface modes:** rnsd `mode` via `reticulumInterfaceMode.ts` + sidecar `normalize_interface_mode` (keep catalogs in sync — `pnpm run check:reticulum-interface-modes` in pre-commit/`release.sh`); add defaults TCP/UDP/I2P → `boundary`, RNode → `access_point`; UI in `ReticulumInterfacesPanel`; default hub presets add/repair missing mode to `boundary` (do not overwrite valid non-boundary). See [../reticulum.md#interface-modes](../reticulum.md#interface-modes). +- **Share instance defaults:** missing keys bootstrap to `share_instance = No` / `instance_name = mesh-client` (does not overwrite explicit Yes/`default`); SharedInstanceClient banner + `disable_share_instance` repair; offline lint via `reticulum:validateConfig` / Network **Check config** / `pnpm run reticulum:config:check` +- **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 +- **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 inbox); 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: local inbox, not peer-delivered (PN + amber house). 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). +- **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/` +- **LXST voice:** `hasLxstVoice` gates Call buttons (Peers + Chat DM). Session helpers in `reticulumVoiceSession.ts` (dial/answer/hangup + mic PCM); UI store `reticulumVoiceStore.ts`; overlay `ReticulumVoiceOverlay` (App mount). Dedicated IPC `reticulum:voiceSendAudio` + push channel `reticulum:voiceAudio` (`/ws/voice`; preload `onVoiceAudio`); control via `electronAPI.reticulum.voice.*`. Runtime WS: `voice.update` / `voice.incoming` / `voice.stats` / `voice.terminated` / `voice.error` (errors should carry `link_id` when known; match by link/generation/remote). **Establish-only media:** Answer warms AudioContext; mic capture/TX starts only after `established`; sidecar soft-drops pre-establish PCM (`not_established`). Outbound progress tones: dial → peer DTMF fold → UK double-ring (`reticulumVoiceCallTones.ts` / `reticulumVoiceOutcome.ts` / `reticulumVoiceFeedback.ts`); media-start coalesces by `callGeneration` to avoid Answer mic thrash. Terminal reasons: treat sidecar `established`/`terminated` as completed (not fail). +- **LRGP games:** `hasLrgpGames` gates Games tab + Challenge (Peers / Chat DM). Sidecar `games_session` + `LrgpStore`; companion `games_outbound.db` persists last envelope + `delivery_state` (LXMF outbound bridge → session chips / Resend). Dedicated IPC `electronAPI.reticulum.games.*` / `reticulum:games*` (proxy rejects `/api/v1/games/*`); WS `games.update` / `games.action_result`. Parity: [../reticulum-games-parity.md](../reticulum-games-parity.md). +- **Gating:** `hasReticulumDiscoveryMap` (Map tab); `hasReticulumRemotePanel` / `hasRncpTransfer` (Remote tab + Chat DM rncp); `hasRrcPanel` (RRC tab); `hasLxstVoice` (LXST Call); `hasLrgpGames` (Games); `hasReticulumInterfaceConfig` / `hasReticulumNetworkPanel` / `ProtocolCapabilities` +- **rnsh/rncp:** sidecar `stack/{rnsh_session,rncp_transfer,path_speed,link_task}.rs` + HTTP `/api/v1/rnsh/*`, `/api/v1/rncp/*`, `/api/v1/remote/*`; typed `electronAPI.reticulum.rnsh|rncp|remote`; picker-gated send/fetch paths in `reticulum-remote-paths.ts`; LXMF enable-request sentinel `mesh-client:request-rncp-receive:v1` (`rncpRequestEnable.ts`); peer reply `mesh-client:rncp-receive-dest:v1:` autofills via `applyRncpReceiveDestShare` (prefer pending from `markRncpReceiveDestSharePending` / `sendRncpRequestEnable`; still apply without pending for older peers); enable-request modal + dest-share side effects deduped by LXMF `message_hash` (`rncpLxmfControlSideEffectDedup`) so catch-up cannot re-fire; already-listening auto-share is once per peer per request-enable cooldown; inbound listener config persists (`rncp_listener_*` in `mesh_client_stack.json`) and restores on live stack start +- **Runtime:** `useReticulumRuntime`, `lib/sessions/reticulumSession.ts`, `lib/ingest/reticulumIngest.ts`; connect starts sidecar, not `ConnectionDriver` RF — marks **configured** when HTTP + identity ready (live attach may still run); `RETICULUM_CONFIGURED_EVENT` wakes RRC. Cancel/stop is fire-and-forget vs cargo/BLE (`START_ABORTED` checkpoints; next start does not rejoin a doomed promise). LXMF/RRC proxy sends: **15 s** `RETICULUM_IPC_SEND_TIMEOUT_MS`. RRC auto-connect (`useRrcStartupAutoConnect`): ~**500 ms** while hubs pending, ~4 s steady. Sidecar RRC: `rrc_codec` / `rrc_link` / `rrc_session` / `api/rrc.rs` +- **Diagnostics:** `ReticulumDiagnosticEngine.ts` (Reticulum-native rows; no LoRa hop-goblin semantics) — includes `reticulum/sidecar-unhealthy` (60s grace; HTTP health, not listen-first ready lag), `reticulum/rns-not-ready` / `reticulum/lxmf-not-ready`, `reticulum/propagation-sync-stuck`, `reticulum/propagation-sync-failing` (1h TTL) +- **No Noble/MQTT** for Reticulum's own connections (sidecar owns BLE RNode via `btleplug`); gate UI with `hasReticulumInterfaceConfig` / `hasReticulumNetworkPanel` / `ProtocolCapabilities`. On macOS/Windows, connecting a Reticulum BLE RNode may still **suspend/yield Noble** so it does not contend with the sidecar's BLE scan — see **Multi-protocol BLE** in [ble-serial.md](ble-serial.md). +- **Multi-protocol BLE:** see [ble-serial.md](ble-serial.md) for the full coexistence contract (peripheral MAC registry, scan-only mutex, Reticulum BLE RNode Noble yield on macOS/Windows). +- **Docs:** [../reticulum.md](../reticulum.md), [../reticulum-sidecar-ipc.md](../reticulum-sidecar-ipc.md) diff --git a/docs/index.md b/docs/index.md index 626758859..1dbb168dc 100644 --- a/docs/index.md +++ b/docs/index.md @@ -64,7 +64,8 @@ Also useful: - [Development Guide](development-environment.md) — prerequisites, all `pnpm` scripts, pre-commit hook, i18n workflow - [Accessibility Checklist](accessibility-checklist.md) - [Contributing](contributing.md) - - Renderer hook/runtime/store boundaries — [AGENTS.md](https://github.com/Colorado-Mesh/mesh-client/blob/main/AGENTS.md#renderer-hook-architecture-multi-protocol) and [ARCHITECTURE.md](https://github.com/Colorado-Mesh/mesh-client/blob/main/ARCHITECTURE.md) + - Renderer hook/runtime/store boundaries — [docs/agents/renderer-hooks.md](https://github.com/Colorado-Mesh/mesh-client/blob/main/docs/agents/renderer-hooks.md) and [ARCHITECTURE.md](https://github.com/Colorado-Mesh/mesh-client/blob/main/ARCHITECTURE.md) + - Agent subsystem reference (deep, on-demand) — [docs/agents/](https://github.com/Colorado-Mesh/mesh-client/blob/main/docs/agents/README.md) - **Meshtastic & MeshCore** - [Feature Parity](meshcore-meshtastic-parity.md) (includes **Rooms** BBS and shared **ChatComposer**) - [MQTT Auth](letsmesh-mqtt-auth.md) From 1bcd1891858bccd2663ad08583eab513ca346b5c Mon Sep 17 00:00:00 2001 From: Joey Stanford Date: Sat, 8 Aug 2026 13:29:52 -0600 Subject: [PATCH 4/5] fix(meshcore): correct locale strings, honest fast-send clock, doc links MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - i18n: fix mistranslated MeshCore strings (pl/ko drop-vs-send and recipient errors; pt-BR/es/cs cleanups) and backfill "or program error" into meshcoreQueueTooltip across all non-EN locales — meaning errors check:i18n cannot detect. - Advance the app-wide MeshCore fast-send clock (recordMeshcoreSend) from every live send: GIF and share-location in ChatComposer, and successful outbox-drain rows in useChatOutbox. Advisory UI stays on the text composer path. - Docs: document single-packet + fast-send in docs/agents/chat.md, meshcore-rooms.md, common-issues.md; retarget stale AGENTS.md section links (parity/localization/contributing) to docs/agents/{chat,i18n}.md; soften ARCHITECTURE.md and README composer wording. - Tests: add outbox drain fast-send clock cases, GIF/location->text advisory cases, and axe coverage for the amber over-limit and fast-send callouts. - Polish: extract shared ComposerAmberCallout, clear advisory on view switch, fix stale outbox pacing comment. --- ARCHITECTURE.md | 2 +- README.md | 2 +- docs/agents/chat.md | 1 + docs/agents/common-issues.md | 80 +++++++------ docs/agents/meshcore-rooms.md | 1 + docs/contributing.md | 2 +- docs/localization.md | 2 +- docs/meshcore-meshtastic-parity.md | 2 +- src/renderer/components/ChatComposer.test.tsx | 112 ++++++++++++++++++ src/renderer/components/ChatComposer.tsx | 97 +++++++++++---- src/renderer/hooks/useChatOutbox.test.ts | 40 +++++++ src/renderer/hooks/useChatOutbox.ts | 11 +- src/renderer/locales/cs/translation.json | 4 +- src/renderer/locales/de/translation.json | 2 +- src/renderer/locales/es/translation.json | 4 +- src/renderer/locales/fr/translation.json | 2 +- src/renderer/locales/id/translation.json | 2 +- src/renderer/locales/it/translation.json | 2 +- src/renderer/locales/ja/translation.json | 2 +- src/renderer/locales/ko/translation.json | 4 +- src/renderer/locales/nl/translation.json | 2 +- src/renderer/locales/pl/translation.json | 4 +- src/renderer/locales/pt-BR/translation.json | 4 +- src/renderer/locales/ru/translation.json | 2 +- src/renderer/locales/tr/translation.json | 2 +- src/renderer/locales/uk/translation.json | 2 +- src/renderer/locales/zh/translation.json | 2 +- 27 files changed, 300 insertions(+), 92 deletions(-) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 5037bd754..1e618f538 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -1,6 +1,6 @@ # Architecture -Project layout, data flow, and code placement for human reference. For AI coding guidelines, see [AGENTS.md](AGENTS.md) (self-contained). +Project layout, data flow, and code placement for human reference. For AI coding guidelines, see [AGENTS.md](AGENTS.md) (hard rules) and the subsystem references in [docs/agents/](docs/agents/README.md). ## Layout map diff --git a/README.md b/README.md index 76ef8113d..08feba8af 100644 --- a/README.md +++ b/README.md @@ -185,7 +185,7 @@ These sections apply to the two LoRa companion-radio stacks. Reticulum uses the - Send/receive messages across channels with per-transport delivery badges and delivery ACK / failure states - **Durable outbox**: outgoing messages are queued in SQLite and retried until delivered; survive app restarts and connection drops - **Long message chunking (Meshtastic / Reticulum)**: messages over the payload limit are auto-split into sequential `[N/T]`-prefixed chunks (word-boundary split, max 9 chunks). **MeshCore is single-packet**: each message is sent as one radio packet and longer text is blocked with an explanatory notice (busy repeaters drop split parts — see [Limitations](#limitations)). MeshCore MQTT-only connections are also guarded from sending when no RF path is available -- **Shared composer** (`ChatComposer`): drafts, mentions, chunking, spellcheck, and emoji picker used by **Chat** and **MeshCore Rooms**; right‑click misspelling replacements (Electron spellchecker for both protocols) +- **Shared composer** (`ChatComposer`): drafts, mentions, protocol-aware length limits (chunking for Meshtastic / Reticulum; single-packet for MeshCore, see above), spellcheck, and emoji picker used by **Chat** and **MeshCore Rooms**; right‑click misspelling replacements (Electron spellchecker for both protocols) - **Emoji reactions / tapbacks**: Meshtastic — 12 quick-pick reactions plus compose emoji (native panel on macOS/Windows; `emoji-picker-element` on Linux); wire tapbacks decode payload UTF-8 glyphs (flags, ZWJ sequences, and legacy index 1–12). MeshCore — same picker UX; **default** outbound tapbacks and text replies use keyless companion wire `@[Display Name] …` (inbound keyed `@[Name#key]`, Open `r:HASH:INDEX`, and `g:GIFID` also parsed). Optional **MeshCore Open compatibility** in App settings enables keyed replies, `r:` reactions, and Giphy GIF send — see [docs/meshcore-meshtastic-parity.md](docs/meshcore-meshtastic-parity.md#meshcore-emoji-reactions-tapbacks); reply-to-message with quoted preview in bubble (including room BBS posts) - **System tray**: docked/minimized on macOS and Windows shows an unread indicator when chat or MeshCore **Rooms** traffic arrives while the window is in the background - **`@[Display Name]` tokens** (Meshtastic / MeshCore reply, tapback, path, and inline-reference syntax) render as compact inline labels in the bubble instead of raw brackets; see [docs/meshcore-meshtastic-parity.md](docs/meshcore-meshtastic-parity.md#chat-mention-tokens) diff --git a/docs/agents/chat.md b/docs/agents/chat.md index e254ba938..97fbc273e 100644 --- a/docs/agents/chat.md +++ b/docs/agents/chat.md @@ -3,6 +3,7 @@ Deep subsystem reference for AI assistants. Open this when a task touches the Chat panel, composer, link previews, notifications, dedup, hop badges, reactions/tapbacks, mentions, or chat/support export. Hard rules live in [`AGENTS.md`](../../AGENTS.md). - **Components:** `ChatPanel.tsx` (channel/DM UI) + shared `ChatComposer.tsx` (drafts, mentions, chunking, spellcheck, emoji; also used by `RoomsPanel.tsx`). Reticulum DM **Share as paper** / **Scan paper** via `ChatDmPaperControls.tsx` + `createReticulumPaperMessage.ts`. Scroll-at-bottom helper: `chatScrollUtils.ts` (`getDistFromChatBottom`). +- **Composer limits / send cadence:** `chatComposerLimits.ts` — `getMaxChunks(protocol)` (MeshCore = 1: no outbound `[i/N]` split; `splitChatMessage` returns `null` when text needs more than one packet), room payload via `getMeshcoreRoomPayloadLimit`, `computeComposerLimitStatus` phases (`warn` surfaces a single-packet ⓘ hint; `overMaxSingle` disables send and shows a `role="note"` callout). MeshCore also gets a **non-blocking** ~5s "sending too fast" advisory (`role="status"`, dismissible) from an app-wide clock in `meshcoreSendRateNotice.ts` (`recordMeshcoreSend` / `isMeshcoreSendTooFast`, `MESHCORE_FAST_SEND_WARN_INTERVAL_MS`). Every **live** MeshCore send advances the clock — text (`handleSend`), GIF, share-location, and outbox drain (`useChatOutbox.ts`) — but the banner UI renders only on the text composer path. Inbound multi-part `[i/N]` merge is unchanged. i18n: `chatPanel.composeLimit.meshcoreSingleNotice.*`, `chatPanel.meshcoreFastSend.warning`. See [`meshcore-meshtastic-parity.md`](../meshcore-meshtastic-parity.md). - **Payload / links:** `ChatPayloadText.tsx` — mention highlighting, search marks, URL linkification; link previews via `chat:fetchLinkPreview` (`src/main/fetchLinkPreview.ts`): Open Graph for HTML pages; **YouTube** watch/shorts/youtu.be via oEmbed + thumbnail; **direct image URLs** (path extension via `chatDirectImageUrl.ts` or raster `Content-Type`) return `kind: 'image'` and render as inline embeds (`ChatInlineImage` / `DirectImageEmbed`); OG/YouTube use card layout. Security: DNS-pinned undici `Agent`, private/loopback blocked, magic-byte MIME sniff (`safeRasterImageMime.ts`), HTTPS-only image embeds, 10s fetch / 3s DNS, 64 KiB HTML cap, **2 MiB** image fetch cap (256 KiB cache payload cap), LRU caches, single-flight dedup (renderer map capped). Previews load even when scrolled up. LXMF attachment rasters: `chat:readReticulumAttachmentAsDataUrl` (`reticulum-attachment-image.ts`; path jail, magic-byte MIME, SVG rejected, 2 MiB, IPC rate limit) → `ReticulumAttachmentLine`. Reply quotes: `replyPreview.ts`. - **Storage helpers:** `src/renderer/lib/chatPanelProtocolStorage.ts` — drafts (`mesh-client:drafts:`), open DM tabs, last-read, per-view mute (`mesh-client:mutedViews:`), starred (`mesh-client:starred:`, cap 200), MeshCore flood-scope overrides per chat view (`mesh-client:floodScopeOverrides:`, channel or DM `viewKey`). - **Notifications:** `src/renderer/lib/chatNotifications.ts` — `playMessageNotification(type)` via Web Audio: `channel` = single 880 Hz pulse (150 ms); `dm` / `reply` = dual pulse (587.33 Hz then 783.99 Hz, 50 ms each, 35 ms gap). Resumes suspended `AudioContext` when the window is hidden/minimized. Type selection in `chatUnreadCounts.ts` (`resolveChatNotificationType`, `pickAudibleNotificationType`; batch priority reply > dm > channel). **ChatPanel** plays when the user is on Chat but reading another view; **App** plays for other panels / backgrounded window (avoids double beep). Meshtastic hidden-window desktop notifications are visual-only (`silent: true` in `meshtasticRouterSideEffects.ts`); typed Web Audio from App owns sound. Global mute `mesh-client:notifMuted`; per-view mute in `mutedViews`. Main-process **tray** icon shows unread when chat or MeshCore Rooms traffic arrives while backgrounded (`src/main/index.ts` `buildTrayIcon`). diff --git a/docs/agents/common-issues.md b/docs/agents/common-issues.md index 5e070ecf5..faf676be6 100644 --- a/docs/agents/common-issues.md +++ b/docs/agents/common-issues.md @@ -2,42 +2,44 @@ Deep subsystem reference for AI assistants. Symptom → where-to-check index. Hard rules live in [`AGENTS.md`](../../AGENTS.md). -| Symptom | Where to check | -| ------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Connection fails | `ConnectionDriver`, `useProtocolConnection.ts`, `runtime/useMeshtasticRuntime.ts`, `runtime/useMeshcoreRuntime.ts` | -| Empty chat/nodes offline | `hydrateIdentityStoresFromDb`, connect-time cache in runtimes, `useDbRefresh`; identity split — [troubleshooting](../troubleshooting.md#chat-stuck-new-traffic-in-logsdb-but-messages-do-not-appear) | -| Chat stuck / badge moves, no new rows | `identityByProtocol`, `useActiveMeshIdentity`, `mergeOfflineIdentityStore`; **Export for GitHub** — [troubleshooting](../troubleshooting.md#reporting-bugs-export-for-github-app-tab) | -| BLE timeout | `noble-ble-manager.ts`, `bleConnectErrors` | -| Reticulum sidecar won't start | `reticulum-sidecar-manager.ts`, `ipc/reticulum-handlers.ts`, [troubleshooting](../troubleshooting.md#reticulum-sidecar-wont-start-or-health-poll-times-out) | -| Nomad hosting enabled but not serving | `nomadServingApi.ts`, `reticulum-sidecar/src/stack/nomad_server.rs` / `nomad_content_source.rs`, `[nomad-serving]` logs, `nomadPageErrorHumanize.ts` — [troubleshooting](../troubleshooting.md#nomad-my-pages-hosting-enabled-but-not-serving) | -| Reticulum interface CRUD fails | `ReticulumInterfacesPanel.tsx` / `ReticulumStackPanel.tsx`, `proxyPut`/`proxyDelete` — [troubleshooting](../troubleshooting.md#reticulum-interface-addeditdelete-fails) | -| Reticulum Remote transfer / inbound policy | `RemoteTransferSection.tsx` / `RemoteSettingsSection.tsx`, `rncpTransferStore.ts` / `reticulumInboundPolicyStore.ts`, `pushRncpListenerPolicy.ts` — [troubleshooting](../troubleshooting.md#reticulum-remote-transfer-fails-or-path_constrained) | -| Reticulum LXST voice fails / silent | `reticulumVoiceSession.ts`, `reticulumVoiceStore.ts`, sidecar `voice_session.rs`; [troubleshooting](../troubleshooting.md#reticulum-lxst-voice-call-fails-or-is-silent) | -| Reticulum LXMF hangs with Auto + LAN hub | sidecar `auto_path_policy.rs` / `lxmf_outbound.rs`; [troubleshooting](../troubleshooting.md#reticulum-local-dms-hang-with-autointerface--private-tcp-hub) | -| Serial port auto-rediscovery | `serialPortAutoRediscovery.ts` (60 s window, 5 s poll) — [troubleshooting](../troubleshooting.md#serial-port-auto-rediscovery-after-reconnect-exhaustion) | -| Meshtastic MQTT text on wrong channel tab | `mqtt-manager.ts` (`resolveMqttInboundTextChannelIndex`), debug snapshot `meshtastic.channelPills` / `channelConfigsSummary` / `mqttChannelKeyEntryCount` — [troubleshooting](../troubleshooting.md#meshtastic-inbound-messages-on-the-wrong-channel-tab) | -| Chat export fails | `chat:export` handler in `src/main/index.ts` | -| Support export fails | `support:exportBundle` in `src/main/support-bundle.ts`; App tab **Export for GitHub** / **Export for Developer** | -| Draft not restored | `chatPanelProtocolStorage.ts`, `viewKey` logic | -| Mention picker missing | `MentionAutocomplete.tsx`, `buildMentionCandidates` | -| Link preview missing | `fetchLinkPreview.ts`, `chat:fetchLinkPreview` IPC; also check direct-image extension/MIME, YouTube oEmbed, and magic-byte sniff failures; previews always fetch (including while reading history) | -| Duplicate RF+MQTT msg | `meshtasticMessageDedup.ts`, Meshtastic runtime ingest | -| MeshCore duplicate/echo | `meshcoreStoreDedup.ts`, `useMeshcoreRuntime.ts` | -| Room login/post fails | `meshcoreRoomLoginRpc.ts`, `meshcoreRoomPostRpc.ts`, [troubleshooting](../troubleshooting.md#meshcore-room-server-login-posts-and-windows-10) | -| Rooms unread vs Chat | `meshcoreRoomsUnread.ts` — Rooms tab badge only; orphan room SQL filtered by known Room contacts; contact delete cascades room messages (`deleteMeshcoreContactOn`); tombstones in `meshcoreLocallyDeletedContacts.ts` | -| MQTT decrypt / sender | `mqtt-manager.ts`, `meshtasticMqttIdentity.ts` | -| Remote admin fails | `meshtasticRemoteAdmin.ts`, key storage | -| S&F history garbled | `meshtasticBacklogUtils.ts` decode, heartbeat trigger | -| Garbled TEXT_MESSAGE | `meshtasticBacklogUtils.ts` readable-text filter | -| Channel URL apply | `meshtasticChannelApply.ts`, `meshtasticUrlEncoder.ts` | -| Header red on loss | `connectionHeaderStatus.ts`, `mqttDisconnectIntent.ts` | -| Sleep/wake reconnect | `usePowerRecovery`, `systemPowerState`, `bleReconnectHelper`, `rfReconnectHelper`, runtimes; Meshtastic ~4s + MeshCore ~8s stagger + up to 30s dual-Noble settle | -| MeshCore contact prune | `meshcoreContactAgeCutoff.ts`, `database.ts` (`last_advert` seconds); favorited exempt | -| MQTT transient after wake | `src/shared/networkTransientErrors.ts`, `mqtt:powerSuspend` / `mqtt:powerResume` IPC | -| MeshCore ping no route / priming | `meshcoreTraceRoutePrime.ts`, `meshcoreHookPreamble.ts`, `meshcore.errors.pingNoRoute`; [troubleshooting](../troubleshooting.md#meshcore-trace-route-or-ping-trace-times-out) | -| Repeater CLI danger / auto-ping | `meshcoreRepeaterCliDanger.ts`, `RepeatersPanel.tsx` (`ensureCliRoutePrimed`); `repeatersPanel.cliMultiHopHint` | -| Room vs repeater LoginFail | `meshcoreRoomLoginRpc.ts` (fail fast) vs `meshcoreRepeaterLoginRpc.ts` + `meshcoreRepeaterPrefixPushRpc.ts` (wait for LoginSuccess) | -| Renderer hung after wake | `rendererHeartbeatWatchdog.ts`, `useRendererHeartbeat`; visible stall + export `mainLiveness`; [troubleshooting](../troubleshooting.md#macos-sleep--wake-and-auto-reconnect) — quit fully if no `[usePowerRecovery]` after resume watchdog | -| MeshCore TCP mid-init peer FIN | `useMeshcoreRuntime` initConn / `meshcore:tcp-*`; [troubleshooting](../troubleshooting.md#meshcore-tcp-connect-stuck-or-reconnect-loop-on-openhop) | -| Chat hop pills missing | MeshCore: `meshcoreCompanionRxPathLenToHopCount` / `MeshCoreProtocol` / `meshcoreRawPacketCorrelate` / `meshcoreIngest`; Meshtastic: `meshtasticRfHops.ts` (`viaMqtt` / `hopStart===0` omit by design) | -| Meshtastic SDK routing console noise | `meshtasticSdkRoutingErrorConsoleHook.ts`, `meshtasticSdkRoutingErrorLog.ts` | +| Symptom | Where to check | +| ------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| Connection fails | `ConnectionDriver`, `useProtocolConnection.ts`, `runtime/useMeshtasticRuntime.ts`, `runtime/useMeshcoreRuntime.ts` | +| Empty chat/nodes offline | `hydrateIdentityStoresFromDb`, connect-time cache in runtimes, `useDbRefresh`; identity split — [troubleshooting](../troubleshooting.md#chat-stuck-new-traffic-in-logsdb-but-messages-do-not-appear) | +| Chat stuck / badge moves, no new rows | `identityByProtocol`, `useActiveMeshIdentity`, `mergeOfflineIdentityStore`; **Export for GitHub** — [troubleshooting](../troubleshooting.md#reporting-bugs-export-for-github-app-tab) | +| BLE timeout | `noble-ble-manager.ts`, `bleConnectErrors` | +| Reticulum sidecar won't start | `reticulum-sidecar-manager.ts`, `ipc/reticulum-handlers.ts`, [troubleshooting](../troubleshooting.md#reticulum-sidecar-wont-start-or-health-poll-times-out) | +| Nomad hosting enabled but not serving | `nomadServingApi.ts`, `reticulum-sidecar/src/stack/nomad_server.rs` / `nomad_content_source.rs`, `[nomad-serving]` logs, `nomadPageErrorHumanize.ts` — [troubleshooting](../troubleshooting.md#nomad-my-pages-hosting-enabled-but-not-serving) | +| Reticulum interface CRUD fails | `ReticulumInterfacesPanel.tsx` / `ReticulumStackPanel.tsx`, `proxyPut`/`proxyDelete` — [troubleshooting](../troubleshooting.md#reticulum-interface-addeditdelete-fails) | +| Reticulum Remote transfer / inbound policy | `RemoteTransferSection.tsx` / `RemoteSettingsSection.tsx`, `rncpTransferStore.ts` / `reticulumInboundPolicyStore.ts`, `pushRncpListenerPolicy.ts` — [troubleshooting](../troubleshooting.md#reticulum-remote-transfer-fails-or-path_constrained) | +| Reticulum LXST voice fails / silent | `reticulumVoiceSession.ts`, `reticulumVoiceStore.ts`, sidecar `voice_session.rs`; [troubleshooting](../troubleshooting.md#reticulum-lxst-voice-call-fails-or-is-silent) | +| Reticulum LXMF hangs with Auto + LAN hub | sidecar `auto_path_policy.rs` / `lxmf_outbound.rs`; [troubleshooting](../troubleshooting.md#reticulum-local-dms-hang-with-autointerface--private-tcp-hub) | +| Serial port auto-rediscovery | `serialPortAutoRediscovery.ts` (60 s window, 5 s poll) — [troubleshooting](../troubleshooting.md#serial-port-auto-rediscovery-after-reconnect-exhaustion) | +| Meshtastic MQTT text on wrong channel tab | `mqtt-manager.ts` (`resolveMqttInboundTextChannelIndex`), debug snapshot `meshtastic.channelPills` / `channelConfigsSummary` / `mqttChannelKeyEntryCount` — [troubleshooting](../troubleshooting.md#meshtastic-inbound-messages-on-the-wrong-channel-tab) | +| Chat export fails | `chat:export` handler in `src/main/index.ts` | +| Support export fails | `support:exportBundle` in `src/main/support-bundle.ts`; App tab **Export for GitHub** / **Export for Developer** | +| Draft not restored | `chatPanelProtocolStorage.ts`, `viewKey` logic | +| MeshCore send blocked (message too long) | `chatComposerLimits.ts` (`getMaxChunks` = 1, `splitChatMessage` → `null`, `overMaxSingle`), `ChatComposer.tsx` — single-packet, no outbound `[i/N]` split; also gates room posts (`getMeshcoreRoomPayloadLimit`). See [chat.md](chat.md), [parity](../meshcore-meshtastic-parity.md) | +| MeshCore "sending too fast" advisory | `meshcoreSendRateNotice.ts` (`recordMeshcoreSend` / `isMeshcoreSendTooFast`, `MESHCORE_FAST_SEND_WARN_INTERVAL_MS`), `ChatComposer.tsx`; non-blocking `role="status"`, clock also fed by GIF / share-location / outbox drain (`useChatOutbox.ts`) | +| Mention picker missing | `MentionAutocomplete.tsx`, `buildMentionCandidates` | +| Link preview missing | `fetchLinkPreview.ts`, `chat:fetchLinkPreview` IPC; also check direct-image extension/MIME, YouTube oEmbed, and magic-byte sniff failures; previews always fetch (including while reading history) | +| Duplicate RF+MQTT msg | `meshtasticMessageDedup.ts`, Meshtastic runtime ingest | +| MeshCore duplicate/echo | `meshcoreStoreDedup.ts`, `useMeshcoreRuntime.ts` | +| Room login/post fails | `meshcoreRoomLoginRpc.ts`, `meshcoreRoomPostRpc.ts`, [troubleshooting](../troubleshooting.md#meshcore-room-server-login-posts-and-windows-10) | +| Rooms unread vs Chat | `meshcoreRoomsUnread.ts` — Rooms tab badge only; orphan room SQL filtered by known Room contacts; contact delete cascades room messages (`deleteMeshcoreContactOn`); tombstones in `meshcoreLocallyDeletedContacts.ts` | +| MQTT decrypt / sender | `mqtt-manager.ts`, `meshtasticMqttIdentity.ts` | +| Remote admin fails | `meshtasticRemoteAdmin.ts`, key storage | +| S&F history garbled | `meshtasticBacklogUtils.ts` decode, heartbeat trigger | +| Garbled TEXT_MESSAGE | `meshtasticBacklogUtils.ts` readable-text filter | +| Channel URL apply | `meshtasticChannelApply.ts`, `meshtasticUrlEncoder.ts` | +| Header red on loss | `connectionHeaderStatus.ts`, `mqttDisconnectIntent.ts` | +| Sleep/wake reconnect | `usePowerRecovery`, `systemPowerState`, `bleReconnectHelper`, `rfReconnectHelper`, runtimes; Meshtastic ~4s + MeshCore ~8s stagger + up to 30s dual-Noble settle | +| MeshCore contact prune | `meshcoreContactAgeCutoff.ts`, `database.ts` (`last_advert` seconds); favorited exempt | +| MQTT transient after wake | `src/shared/networkTransientErrors.ts`, `mqtt:powerSuspend` / `mqtt:powerResume` IPC | +| MeshCore ping no route / priming | `meshcoreTraceRoutePrime.ts`, `meshcoreHookPreamble.ts`, `meshcore.errors.pingNoRoute`; [troubleshooting](../troubleshooting.md#meshcore-trace-route-or-ping-trace-times-out) | +| Repeater CLI danger / auto-ping | `meshcoreRepeaterCliDanger.ts`, `RepeatersPanel.tsx` (`ensureCliRoutePrimed`); `repeatersPanel.cliMultiHopHint` | +| Room vs repeater LoginFail | `meshcoreRoomLoginRpc.ts` (fail fast) vs `meshcoreRepeaterLoginRpc.ts` + `meshcoreRepeaterPrefixPushRpc.ts` (wait for LoginSuccess) | +| Renderer hung after wake | `rendererHeartbeatWatchdog.ts`, `useRendererHeartbeat`; visible stall + export `mainLiveness`; [troubleshooting](../troubleshooting.md#macos-sleep--wake-and-auto-reconnect) — quit fully if no `[usePowerRecovery]` after resume watchdog | +| MeshCore TCP mid-init peer FIN | `useMeshcoreRuntime` initConn / `meshcore:tcp-*`; [troubleshooting](../troubleshooting.md#meshcore-tcp-connect-stuck-or-reconnect-loop-on-openhop) | +| Chat hop pills missing | MeshCore: `meshcoreCompanionRxPathLenToHopCount` / `MeshCoreProtocol` / `meshcoreRawPacketCorrelate` / `meshcoreIngest`; Meshtastic: `meshtasticRfHops.ts` (`viaMqtt` / `hopStart===0` omit by design) | +| Meshtastic SDK routing console noise | `meshtasticSdkRoutingErrorConsoleHook.ts`, `meshtasticSdkRoutingErrorLog.ts` | diff --git a/docs/agents/meshcore-rooms.md b/docs/agents/meshcore-rooms.md index 95e342997..4c1f82d5c 100644 --- a/docs/agents/meshcore-rooms.md +++ b/docs/agents/meshcore-rooms.md @@ -3,6 +3,7 @@ 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`). +- **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). diff --git a/docs/contributing.md b/docs/contributing.md index 065b4748d..f17267759 100644 --- a/docs/contributing.md +++ b/docs/contributing.md @@ -48,4 +48,4 @@ Reticulum-specific docs: [reticulum.md](reticulum.md), [reticulum-sidecar-ipc.md - Link related issues when relevant. - Follow coding and security notes in the full [CONTRIBUTING.md](https://github.com/Colorado-Mesh/mesh-client/blob/main/CONTRIBUTING.md). - For locale auto-fill (`pnpm run i18n:auto-translate`, including pre-commit), runs are incremental vs `HEAD` English unless you pass **`--all`** / **`I18N_TRANSLATE_ALL=1`**; MyMemory defaults to contact **info@coloradomesh.org** unless **`MYMEMORY_EMAIL`** is set — see [AGENTS.md](https://github.com/Colorado-Mesh/mesh-client/blob/main/AGENTS.md) (i18n / Localization). + For locale auto-fill (`pnpm run i18n:auto-translate`, including pre-commit), runs are incremental vs `HEAD` English unless you pass **`--all`** / **`I18N_TRANSLATE_ALL=1`**; MyMemory defaults to contact **info@coloradomesh.org** unless **`MYMEMORY_EMAIL`** is set — see [docs/agents/i18n.md](https://github.com/Colorado-Mesh/mesh-client/blob/main/docs/agents/i18n.md). diff --git a/docs/localization.md b/docs/localization.md index 054ef9344..ef7be1e3c 100644 --- a/docs/localization.md +++ b/docs/localization.md @@ -51,7 +51,7 @@ If you find a mistranslation or an awkward phrasing: 2. Open a new [Translation Error](https://github.com/Colorado-Mesh/mesh-client/issues/new?assignees=&labels=translation&template=translation-error.md&title=Translation+Error) issue. 3. Provide the current text and your suggested correction. -CI does **not** run `check:i18n` as a standalone workflow step. Quality rules run via **pre-commit** (`pnpm run check:i18n`) and indirectly in CI through Vitest (`locale-quality.test.ts` subprocess). See [AGENTS.md](../AGENTS.md) (i18n / Localization) for maintainer commands (`pnpm run check:i18n`, `pnpm run i18n:auto-translate`). +CI does **not** run `check:i18n` as a standalone workflow step. Quality rules run via **pre-commit** (`pnpm run check:i18n`) and indirectly in CI through Vitest (`locale-quality.test.ts` subprocess). See [docs/agents/i18n.md](agents/i18n.md) for maintainer commands (`pnpm run check:i18n`, `pnpm run i18n:auto-translate`). ### Quality checks (selected categories) diff --git a/docs/meshcore-meshtastic-parity.md b/docs/meshcore-meshtastic-parity.md index 911604b03..f43604483 100644 --- a/docs/meshcore-meshtastic-parity.md +++ b/docs/meshcore-meshtastic-parity.md @@ -20,7 +20,7 @@ Shared UI gates use `ProtocolCapabilities` in [`src/renderer/lib/radio/BaseRadio | 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 [AGENTS.md](../AGENTS.md) Chat Panel §Hop badges | **App** (implemented) | +| 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) | diff --git a/src/renderer/components/ChatComposer.test.tsx b/src/renderer/components/ChatComposer.test.tsx index 6ddefa7ce..9b3c967b2 100644 --- a/src/renderer/components/ChatComposer.test.tsx +++ b/src/renderer/components/ChatComposer.test.tsx @@ -42,6 +42,8 @@ vi.mock('react-i18next', () => ({ 'chatPanel.meshcoreGifButton': 'Insert Giphy GIF', 'chatPanel.meshcoreGifPlaceholder': 'Giphy URL or id', 'chatPanel.meshcoreGifSend': 'Send GIF', + 'chatPanel.shareLocation': 'Share location', + 'chatPanel.shareLocationLabel': 'Location', 'chatPanel.floodScopeOverrideDefault': 'Default scope', 'chatPanel.floodScopeOverrideUnscoped': 'Unscoped', 'chatPanel.floodScopeOverrideAria': 'Per-channel flood scope override', @@ -98,6 +100,46 @@ describe('ChatComposer', () => { expect(await axe(container)).toHaveNoViolations(); }); + it('has no axe violations with the meshcore over-limit callout visible', async () => { + const { container } = render( + , + ); + fireEvent.change(screen.getByRole('textbox'), { target: { value: 'a'.repeat(200) } }); + await screen.findByRole('note'); + hydrateAxeThemeColors(container); + expect(await axe(container)).toHaveNoViolations(); + }); + + it('has no axe violations with the fast-send advisory visible', async () => { + const onSendChunk = vi.fn().mockResolvedValue(undefined); + const { container } = render( + , + ); + const textarea = screen.getByRole('textbox'); + fireEvent.change(textarea, { target: { value: 'first' } }); + fireEvent.click(screen.getByRole('button', { name: 'Send' })); + await waitFor(() => { + expect(onSendChunk).toHaveBeenCalledTimes(1); + }); + fireEvent.change(textarea, { target: { value: 'second' } }); + fireEvent.click(screen.getByRole('button', { name: 'Send' })); + await screen.findByRole('status'); + hydrateAxeThemeColors(container); + expect(await axe(container)).toHaveNoViolations(); + }); + it('clears input after successful send', async () => { const onSendChunk = vi.fn().mockResolvedValue(undefined); const user = userEvent.setup(); @@ -561,6 +603,76 @@ describe('ChatComposer', () => { } }); + it('carries the fast-send advisory from a GIF send to a following text send', async () => { + // A GIF is a live MeshCore packet, so a text send within 5s of it should still warn. + localStorage.setItem( + 'mesh-client:appSettings', + JSON.stringify({ meshcoreOpenWireCompatEnabled: true }), + ); + const onSendChunk = vi.fn().mockResolvedValue(undefined); + const user = userEvent.setup(); + render( + , + ); + await user.click(screen.getByRole('button', { name: 'Insert Giphy GIF' })); + const gifField = screen.getByRole('textbox', { name: 'Giphy URL or id' }); + fireEvent.change(gifField, { target: { value: 'g:a5viI92PAF89q' } }); + await user.click(screen.getByRole('button', { name: 'Send GIF' })); + await waitFor(() => { + expect(onSendChunk).toHaveBeenCalledWith('g:a5viI92PAF89q'); + }); + // First send of the session — no advisory yet. + expect(screen.queryByRole('status')).toBeNull(); + + const textarea = screen.getByRole('textbox', { name: 'Type a message…' }); + await user.type(textarea, 'quick follow-up'); + await user.click(screen.getByRole('button', { name: 'Send' })); + await waitFor(() => { + expect(onSendChunk).toHaveBeenCalledWith( + 'quick follow-up', + expect.objectContaining({ chunkIndex: 0 }), + ); + }); + const warning = await screen.findByRole('status'); + expect(warning).toHaveTextContent('sending faster than the mesh'); + }); + + it('carries the fast-send advisory from a shared location to a following text send', async () => { + const onSendChunk = vi.fn().mockResolvedValue(undefined); + const resolveShareLocation = vi.fn().mockResolvedValue({ lat: 39.7392, lon: -104.9903 }); + const user = userEvent.setup(); + render( + , + ); + await user.click(screen.getByRole('button', { name: 'Share location' })); + await waitFor(() => { + expect(onSendChunk).toHaveBeenCalledTimes(1); + }); + expect(screen.queryByRole('status')).toBeNull(); + + const textarea = screen.getByRole('textbox', { name: 'Type a message…' }); + await user.type(textarea, 'on my way'); + await user.click(screen.getByRole('button', { name: 'Send' })); + await waitFor(() => { + expect(onSendChunk).toHaveBeenCalledTimes(2); + }); + const warning = await screen.findByRole('status'); + expect(warning).toHaveTextContent('sending faster than the mesh'); + }); + it('hides GIF button when MeshCore Open wire compat is disabled', () => { render( void; + dismissLabel?: string; +}) { + return ( +
+ + {children} + {onDismiss && ( + + )} +
+ ); +} + function emojiUnicodeFromEvent(event: Event): string | null { if ( !(event instanceof CustomEvent) || @@ -404,6 +455,12 @@ export function ChatComposer({ } setMentionQuery(null); setChatActionError(null); + // Clear any lingering fast-send advisory when switching chat views. + if (meshcoreFastSendWarnTimerRef.current) { + clearTimeout(meshcoreFastSendWarnTimerRef.current); + meshcoreFastSendWarnTimerRef.current = null; + } + setMeshcoreFastSendWarn(false); }, [viewKey, protocol, showFloodScopeOverride]); const mentionCandidates = useMemo( @@ -654,6 +711,9 @@ export function ChatComposer({ setChatActionError(null); try { await onSendChunk(wireText); + // GIF is a live MeshCore send — advance the shared fast-send clock so a text send + // right after still surfaces the advisory (UI stays on the text send path). + if (protocol === 'meshcore') recordMeshcoreSend(); setShowGifModal(false); setGifInput(''); onSendSuccess?.(); @@ -667,7 +727,7 @@ export function ChatComposer({ setSending(false); } }, - [allowOutbox, disabled, isConnected, onSendChunk, onSendSuccess, sending, t, viewKey], + [allowOutbox, disabled, isConnected, onSendChunk, onSendSuccess, protocol, sending, t, viewKey], ); const handleGifConfirm = useCallback(() => { @@ -731,6 +791,8 @@ export function ChatComposer({ return; } await onSendChunk(text); + // Live MeshCore location send counts toward the shared fast-send cadence clock. + if (protocol === 'meshcore') recordMeshcoreSend(); if ( protocol === 'meshtastic' && isShareLocationSendWaypointEnabled() && @@ -1090,26 +1152,16 @@ export function ChatComposer({ )} {meshcoreFastSendWarn && ( -
- {t('chatPanel.meshcoreFastSend.warning')} - -
+ )} @@ -1494,14 +1546,7 @@ export function ChatComposer({ )} {singlePacketProtocol && limitStatus.phase === 'overMax' && ( -
- + {t('chatPanel.composeLimit.meshcoreSingleNotice.title')} @@ -1512,7 +1557,7 @@ export function ChatComposer({ })} -
+ )} ); diff --git a/src/renderer/hooks/useChatOutbox.test.ts b/src/renderer/hooks/useChatOutbox.test.ts index c6f16ae23..0a1dc4390 100644 --- a/src/renderer/hooks/useChatOutbox.test.ts +++ b/src/renderer/hooks/useChatOutbox.test.ts @@ -1,6 +1,10 @@ import { renderHook, waitFor } from '@testing-library/react'; import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { + isMeshcoreSendTooFast, + resetMeshcoreSendRateForTests, +} from '@/renderer/lib/meshcoreSendRateNotice'; import { resetMeshtasticTextSendPacingForTests } from '@/renderer/lib/meshtasticTextSendPacing'; import { MESHTASTIC_TEXT_CHUNK_SEND_INTERVAL_MS } from '@/renderer/lib/timeConstants'; import type { OutboxEntry } from '@/shared/electron-api.types'; @@ -34,6 +38,7 @@ describe('useChatOutbox', () => { beforeEach(() => { resetMeshtasticTextSendPacingForTests(); + resetMeshcoreSendRateForTests(); vi.mocked(mockOutbox.list).mockClear(); vi.mocked(mockOutbox.add).mockClear(); vi.mocked(mockOutbox.updateStatus).mockClear(); @@ -232,6 +237,41 @@ describe('useChatOutbox', () => { expect(sendFn).toHaveBeenNthCalledWith(2, 'second', 0, undefined, undefined); }); + it('advances the shared meshcore fast-send clock when a row drains successfully', async () => { + // A drained MeshCore row is airtime too, so a composer send right after should still warn. + const entry = makeEntry({ id: 50, protocol: 'meshcore', payload: 'hi' }); + vi.mocked(mockOutbox.list).mockResolvedValue([entry]); + const sendFn = vi.fn().mockResolvedValue(undefined); + expect(isMeshcoreSendTooFast()).toBe(false); + renderHook(() => useChatOutbox({ protocol: 'meshcore', isSendAvailable: true, sendFn })); + await waitFor(() => { + expect(sendFn).toHaveBeenCalledTimes(1); + }); + expect(isMeshcoreSendTooFast()).toBe(true); + }); + + it('does not touch the meshcore fast-send clock for meshtastic drains', async () => { + const entry = makeEntry({ id: 51, protocol: 'meshtastic', payload: 'hi' }); + vi.mocked(mockOutbox.list).mockResolvedValue([entry]); + const sendFn = vi.fn().mockResolvedValue(undefined); + renderHook(() => useChatOutbox({ protocol: 'meshtastic', isSendAvailable: true, sendFn })); + await waitFor(() => { + expect(sendFn).toHaveBeenCalledTimes(1); + }); + expect(isMeshcoreSendTooFast()).toBe(false); + }); + + it('does not advance the meshcore fast-send clock when a drain send fails', async () => { + const entry = makeEntry({ id: 52, protocol: 'meshcore', payload: 'hi' }); + vi.mocked(mockOutbox.list).mockResolvedValue([entry]); + const sendFn = vi.fn().mockRejectedValue(new Error('radio busy')); + renderHook(() => useChatOutbox({ protocol: 'meshcore', isSendAvailable: true, sendFn })); + await waitFor(() => { + expect(sendFn).toHaveBeenCalledTimes(1); + }); + expect(isMeshcoreSendTooFast()).toBe(false); + }); + it('does not drain when isSendAvailable is false', async () => { const entry = makeEntry({ id: 9 }); vi.mocked(mockOutbox.list).mockResolvedValue([entry]); diff --git a/src/renderer/hooks/useChatOutbox.ts b/src/renderer/hooks/useChatOutbox.ts index f37ae1984..e630906a3 100644 --- a/src/renderer/hooks/useChatOutbox.ts +++ b/src/renderer/hooks/useChatOutbox.ts @@ -4,6 +4,7 @@ import type { MeshProtocol } from '@/renderer/lib/types'; import type { OutboxEntry, OutboxEntryInput, OutboxStatus } from '@/shared/electron-api.types'; import { registerChatOutboxDrainListener } from '../lib/chatOutboxDrain'; +import { recordMeshcoreSend } from '../lib/meshcoreSendRateNotice'; import { withMeshtasticTextSendPacing } from '../lib/meshtasticTextSendPacing'; export type { OutboxEntry }; @@ -113,6 +114,11 @@ async function sendOneOutboxRow( updateRow(row.id, { status: 'sending' }); try { await sendFn(row.payload, row.channel, row.toNode ?? undefined, row.replyId ?? undefined); + // Keep the app-wide MeshCore fast-send clock honest: a drained row is airtime too, so a + // composer send right after a backlog drain should still surface the advisory. + if (row.protocol === 'meshcore') { + recordMeshcoreSend(); + } await finalizeSuccessfulOutboxSend(row, removeRow, updateRow); } catch (err: unknown) { // catch-no-log-ok recordOutboxSendFailure logs the send failure @@ -194,8 +200,9 @@ export function useChatOutbox({ for (const row of freshRows.filter((r) => isEligibleForDrain(r, now))) { if (!isSendAvailableRef.current) break; const sendRow = () => sendOneOutboxRow(row, sendFnRef.current, updateRow, removeRow); - // Shared with ChatComposer so live multi-chunk sends and outbox drain cannot race - // firmware's TEXT_MESSAGE_APP RATE_LIMIT_EXCEEDED window. + // Meshtastic-only pacing, shared with ChatComposer so live sends and outbox drain cannot + // race firmware's TEXT_MESSAGE_APP RATE_LIMIT_EXCEEDED window. MeshCore drains without a + // client interval (see the meshcore branch below) — it only advances the fast-send clock. if (protocol === 'meshtastic') { await withMeshtasticTextSendPacing(sendRow); } else { diff --git a/src/renderer/locales/cs/translation.json b/src/renderer/locales/cs/translation.json index 61f7823ac..173880008 100644 --- a/src/renderer/locales/cs/translation.json +++ b/src/renderer/locales/cs/translation.json @@ -13,7 +13,7 @@ "loadingPanel": "Nakládací panel", "loadingDialog": "Dialog načítání", "meshtasticQueueTooltip": "Přenosová fronta: pakety čekající na odeslání. Zelená = nízká, oranžová = zaplnění, červená = ucpané.", - "meshcoreQueueTooltip": "Protože MeshCore odesílá zprávy rychle a dotazujeme každých 30 sekund, měla by být vždy 0. Pokud ne 0, dochází k přetížení.", + "meshcoreQueueTooltip": "Protože MeshCore odesílá zprávy rychle a dotazujeme každých 30 sekund, měla by být vždy 0. Pokud ne 0, dochází k přetížení nebo chybě programu.", "takRunning": "TAK běží", "takStopped": "TAK se zastavil", "takServerRunning": "Server TAK běží", @@ -687,7 +687,7 @@ "sentViaLocalPropagation": "Místní doručená pošta šíření", "reticulumSendTimeout": "Odeslání vypršelo. Zásobník Reticulum se možná spouští nebo je zaneprázdněný — zkuste to znovu.", "meshcoreFastSend": { - "warning": "Odesíláte rychleji, než může síť přenést. Na zaneprázdněné síti mohou opakovači vyslat zprávy odeslané blízko sebe — mezi zprávami nechte několik sekund." + "warning": "Odesíláte rychleji, než může síť přenést. Na zaneprázdněné síti mohou opakovače zahodit zprávy odeslané blízko sebe — mezi zprávami nechte několik sekund." } }, "chatPayload": { diff --git a/src/renderer/locales/de/translation.json b/src/renderer/locales/de/translation.json index 7e828cab4..104627514 100644 --- a/src/renderer/locales/de/translation.json +++ b/src/renderer/locales/de/translation.json @@ -13,7 +13,7 @@ "loadingPanel": "Das Panel wird geladen...", "loadingDialog": "Dialogfeld wird geladen", "meshtasticQueueTooltip": "Sendewarteschlange: Pakete, die darauf warten, gesendet zu werden. Grün = niedrig, gelb = füllt sich, rot = verstopft.", - "meshcoreQueueTooltip": "Da MeshCore Nachrichten schnell sendet und wir alle 30 Sekunden abfragen, sollte dies immer 0 sein. Wenn nicht 0, gibt es Staus.", + "meshcoreQueueTooltip": "Da MeshCore Nachrichten schnell sendet und wir alle 30 Sekunden abfragen, sollte dies immer 0 sein. Wenn nicht 0, gibt es Staus oder einen Programmfehler.", "takRunning": "TAK läuft", "takStopped": "TAK blieb stehen", "takServerRunning": "TAK-Server läuft", diff --git a/src/renderer/locales/es/translation.json b/src/renderer/locales/es/translation.json index 91a536711..f6ada4ff9 100644 --- a/src/renderer/locales/es/translation.json +++ b/src/renderer/locales/es/translation.json @@ -13,7 +13,7 @@ "loadingPanel": "Panel de carga", "loadingDialog": "Cargando diálogo", "meshtasticQueueTooltip": "Cola de transmisión: paquetes a la espera de ser enviados. Verde = bajo, ámbar = relleno, rojo = congestionado.", - "meshcoreQueueTooltip": "Debido a que MeshCore envía mensajes rápidamente, y sondeamos cada 30 segundos, esto siempre debe ser 0. Si no es 0, hay congestión.", + "meshcoreQueueTooltip": "Debido a que MeshCore envía mensajes rápidamente, y sondeamos cada 30 segundos, esto siempre debe ser 0. Si no es 0, hay congestión o un error de programa.", "takRunning": "TAK corriendo", "takStopped": "TAK detenido", "takServerRunning": "Servidor TAK en ejecución", @@ -685,7 +685,7 @@ "sentViaLocalPropagation": "Bandeja de entrada de propagación local", "reticulumSendTimeout": "El envío ha caducado. La pila Reticulum puede estar iniciándose o ocupada; inténtelo de nuevo.", "meshcoreFastSend": { - "warning": "Estás enviando más rápido de lo que la malla puede transmitir. En una malla ocupada, los repetidores pueden dejar caer mensajes enviados muy juntos; deje unos segundos entre mensajes." + "warning": "Estás enviando más rápido de lo que la malla puede transmitir. En una malla ocupada, los repetidores pueden descartar mensajes enviados muy juntos; deja unos segundos entre mensajes." } }, "chatPayload": { diff --git a/src/renderer/locales/fr/translation.json b/src/renderer/locales/fr/translation.json index 54c08f824..9029aa0c0 100644 --- a/src/renderer/locales/fr/translation.json +++ b/src/renderer/locales/fr/translation.json @@ -13,7 +13,7 @@ "loadingPanel": "Panneau de chargement", "loadingDialog": "Chargement du dialogue ...", "meshtasticQueueTooltip": "File d'attente de transmission : paquets en attente d'envoi. Vert = bas, ambre = remplissage, rouge = congestionné.", - "meshcoreQueueTooltip": "Parce que MeshCore envoie des messages rapidement, et que nous interrogeons toutes les 30 secondes, cela devrait toujours être 0. Si ce n'est pas 0, il y a congestion.", + "meshcoreQueueTooltip": "Parce que MeshCore envoie des messages rapidement, et que nous interrogeons toutes les 30 secondes, cela devrait toujours être 0. Si ce n'est pas 0, il y a congestion ou une erreur de programme.", "takRunning": "TAK en cours d'exécution", "takStopped": "TAK arrêté", "takServerRunning": "Serveur TAK en cours d'exécution", diff --git a/src/renderer/locales/id/translation.json b/src/renderer/locales/id/translation.json index c0c2db2bb..79828d29e 100644 --- a/src/renderer/locales/id/translation.json +++ b/src/renderer/locales/id/translation.json @@ -13,7 +13,7 @@ "loadingPanel": "Memuat panel", "loadingDialog": "Memuat dialog", "meshtasticQueueTooltip": "Antrian transmisi: paket menunggu untuk dikirim. Hijau = rendah, kuning = terisi, merah = padat.", - "meshcoreQueueTooltip": "Karena MeshCore mengirim pesan dengan cepat, dan kita melakukan polling setiap 30 detik, nilainya harus selalu 0. Jika bukan 0, maka terjadi kemacetan.", + "meshcoreQueueTooltip": "Karena MeshCore mengirim pesan dengan cepat, dan kita melakukan polling setiap 30 detik, nilainya harus selalu 0. Jika bukan 0, maka terjadi kemacetan atau kesalahan program.", "takRunning": "TAK berjalan", "takStopped": "TAK berhenti", "takServerRunning": "Server TAK berjalan", diff --git a/src/renderer/locales/it/translation.json b/src/renderer/locales/it/translation.json index 1f855e7e2..d317be056 100644 --- a/src/renderer/locales/it/translation.json +++ b/src/renderer/locales/it/translation.json @@ -13,7 +13,7 @@ "loadingPanel": "Pannello di caricamento", "loadingDialog": "Finestra di dialogo di caricamento", "meshtasticQueueTooltip": "Coda di trasmissione: pacchetti in attesa di essere inviati. Verde = basso, giallo = pieno, rosso = congestionato.", - "meshcoreQueueTooltip": "Poiché MeshCore invia messaggi rapidamente ed eseguiamo il polling ogni 30 secondi, dovrebbe essere sempre 0. Se non è 0, c'è congestione.", + "meshcoreQueueTooltip": "Poiché MeshCore invia messaggi rapidamente ed eseguiamo il polling ogni 30 secondi, dovrebbe essere sempre 0. Se non è 0, c'è congestione o un errore del programma.", "takRunning": "TAK correndo", "takStopped": "TAK si è fermato", "takServerRunning": "Server TAK in esecuzione", diff --git a/src/renderer/locales/ja/translation.json b/src/renderer/locales/ja/translation.json index 13c514a25..032a1481e 100644 --- a/src/renderer/locales/ja/translation.json +++ b/src/renderer/locales/ja/translation.json @@ -13,7 +13,7 @@ "loadingPanel": "ローディングパネル", "loadingDialog": "読み込みダイアログ", "meshtasticQueueTooltip": "送信キュー: 送信を待っているパケット。緑色 = 少ない、オレンジ色 = 満杯、赤色 = 混雑。", - "meshcoreQueueTooltip": "MeshCore はメッセージを迅速に送信し、30 秒ごとにポーリングするため、これは常に 0 である必要があります。0 でない場合は、輻輳が発生しています。", + "meshcoreQueueTooltip": "MeshCore はメッセージを迅速に送信し、30 秒ごとにポーリングするため、これは常に 0 である必要があります。0 でない場合は、輻輳またはプログラムエラーが発生しています。", "takRunning": "TAKランニング", "takStopped": "TAKが停止しました", "takServerRunning": "TAKサーバーが稼働中", diff --git a/src/renderer/locales/ko/translation.json b/src/renderer/locales/ko/translation.json index 64010214d..aee314dcd 100644 --- a/src/renderer/locales/ko/translation.json +++ b/src/renderer/locales/ko/translation.json @@ -13,7 +13,7 @@ "loadingPanel": "로딩 패널", "loadingDialog": "로딩 대화상자", "meshtasticQueueTooltip": "전송 큐: 전송을 기다리는 패킷입니다. 녹색 = 낮음, 황색 = 가득 찼음, 빨간색 = 혼잡함.", - "meshcoreQueueTooltip": "MeshCore는 메시지를 빠르게 보내고 30초마다 폴링하므로 이 값은 항상 0이어야 합니다. 0이 아니면 정체가 있는 것입니다.", + "meshcoreQueueTooltip": "MeshCore는 메시지를 빠르게 보내고 30초마다 폴링하므로 이 값은 항상 0이어야 합니다. 0이 아니면 정체 또는 프로그램 오류가 있는 것입니다.", "takRunning": "TAK 실행 중", "takStopped": "TAK가 중지됨", "takServerRunning": "TAK 서버 실행 중", @@ -512,7 +512,7 @@ "overMaxSingle": "너무 김 — MeshCore가 메시지당 하나의 패킷을 전송함 (최대 {{limit}} 자)", "meshcoreSingleNotice": { "title": "MeshCore에 보내기에 메시지가 너무 깁니다", - "body": "MeshCore는 각 메시지를 단일 무선 패킷으로 전송합니다 (최대 {{limit}} 자). 더 긴 메시는 번호가 매겨진 부분으로 나눠야 하지만, 바쁜 메시 리피터에서는 이러한 부분 중 일부를 일상적으로 드롭하므로 메시지를 보내는 사람에게 알릴 방법이 없는 불완전한 메시지가 전송됩니다. 메시지를 안정적으로 유지하기 위해 메시지는 자동으로 분할되지 않습니다. 이 메시지를 짧게 줄이거나 짧은 메시지 몇 개로 따로 보내세요.", + "body": "MeshCore는 각 메시지를 단일 무선 패킷으로 전송합니다(최대 {{limit}}자). 더 긴 메시지는 번호가 매겨진 부분으로 나눠야 하지만, 바쁜 메시에서는 리피터가 이러한 부분 중 일부를 일상적으로 버리기 때문에 메시지를 받는 상대방은 불완전한 메시지를 받고도 이를 알 방법이 없습니다. 메시지를 안정적으로 유지하기 위해 자동으로 분할되지 않습니다. 이 메시지를 짧게 줄이거나 짧은 메시지 몇 개로 나누어 보내세요.", "hint": "MeshCore는 메시지당 하나의 패킷을 전송합니다. 바쁜 리피터가 분할 부분을 드롭하는 경우가 많으므로 더 긴 메시지를 보낼 수 없습니다." } }, diff --git a/src/renderer/locales/nl/translation.json b/src/renderer/locales/nl/translation.json index 23489107e..d0729be57 100644 --- a/src/renderer/locales/nl/translation.json +++ b/src/renderer/locales/nl/translation.json @@ -13,7 +13,7 @@ "loadingPanel": "Laadpaneel", "loadingDialog": "Dialoogvenster laden", "meshtasticQueueTooltip": "Verzendwachtrij: pakketten die wachten om te worden verzonden. Groen = laag, oranje = vol, rood = verstopt.", - "meshcoreQueueTooltip": "Omdat MeshCore snel berichten verzendt en we elke 30 seconden een poll uitvoeren, moet dit altijd 0 zijn. Als dit niet 0 is, is er sprake van congestie.", + "meshcoreQueueTooltip": "Omdat MeshCore snel berichten verzendt en we elke 30 seconden een poll uitvoeren, moet dit altijd 0 zijn. Als dit niet 0 is, is er sprake van congestie of een programmafout.", "takRunning": "TAK loopt", "takStopped": "TAK stopte", "takServerRunning": "TAK-server actief", diff --git a/src/renderer/locales/pl/translation.json b/src/renderer/locales/pl/translation.json index 03c4462d8..9d4923a88 100644 --- a/src/renderer/locales/pl/translation.json +++ b/src/renderer/locales/pl/translation.json @@ -13,7 +13,7 @@ "loadingPanel": "Panel ładowania", "loadingDialog": "Ładowanie okna dialogowego", "meshtasticQueueTooltip": "Kolejka transmisji: pakiety oczekujące na wysłanie. Zielony = niski poziom, bursztynowy = napełnianie, czerwony = zatłoczony.", - "meshcoreQueueTooltip": "Ponieważ MeshCore szybko wysyła wiadomości, a my odpytujemy co 30 sekund, powinno to zawsze wynosić 0. Jeśli nie jest to 0, oznacza to przeciążenie.", + "meshcoreQueueTooltip": "Ponieważ MeshCore szybko wysyła wiadomości, a my odpytujemy co 30 sekund, powinno to zawsze wynosić 0. Jeśli nie jest to 0, oznacza to przeciążenie lub błąd programu.", "takRunning": "TAK działa", "takStopped": "TAK zatrzymał się", "takServerRunning": "Serwer TAK działa", @@ -689,7 +689,7 @@ "sentViaLocalPropagation": "Skrzynka odbiorcza propagacji lokalnej", "reticulumSendTimeout": "Przekroczono limit czasu wysyłania. Stos Reticulum może się uruchamiać lub być zajęty — spróbuj ponownie.", "meshcoreFastSend": { - "warning": "Wysyłasz szybciej niż siatka może przekazywać. Na zatłoczonej siatce wtórniki mogą wysyłać wiadomości wysyłane blisko siebie — pozostaw kilka sekund między wiadomościami." + "warning": "Wysyłasz szybciej, niż siatka może przekazywać. Na zatłoczonej siatce wzmacniaki mogą odrzucać wiadomości wysyłane blisko siebie — pozostaw kilka sekund między wiadomościami." } }, "chatPayload": { diff --git a/src/renderer/locales/pt-BR/translation.json b/src/renderer/locales/pt-BR/translation.json index a268e8265..45561737d 100644 --- a/src/renderer/locales/pt-BR/translation.json +++ b/src/renderer/locales/pt-BR/translation.json @@ -13,7 +13,7 @@ "loadingPanel": "Painel de carregamento", "loadingDialog": "Carregando diálogo...", "meshtasticQueueTooltip": "Fila de transmissão: pacotes aguardando para serem enviados. Verde = baixo, âmbar = cheio, vermelho = congestionado.", - "meshcoreQueueTooltip": "Como o MeshCore envia mensagens rapidamente e pesquisamos a cada 30 segundos, isso deve ser sempre 0. Se não for 0, há congestionamento.", + "meshcoreQueueTooltip": "Como o MeshCore envia mensagens rapidamente e pesquisamos a cada 30 segundos, isso deve ser sempre 0. Se não for 0, há congestionamento ou erro de programa.", "takRunning": "TAK em execução", "takStopped": "TAK parou", "takServerRunning": "Servidor TAK em execução", @@ -512,7 +512,7 @@ "overMaxSingle": "Muito longo — MeshCore envia um pacote por mensagem (máximo de {{limit}} caracteres)", "meshcoreSingleNotice": { "title": "Mensagem muito longa para MeshCore", - "body": "O MeshCore envia cada mensagem como um único pacote de rádio (até {{limit}} caracteres). Mensagens mais longas precisam ser divididas em partes numeradas, mas em um repetidor de malha ocupado, solte rotineiramente algumas dessas partes — para que a pessoa que você está enviando a mensagem receba uma mensagem incompleta sem nenhuma maneira de dizer. Para manter as mensagens confiáveis, elas não são divididas automaticamente. Encurte esta mensagem ou envie-a como algumas mensagens curtas separadas.", + "body": "O MeshCore envia cada mensagem como um único pacote de rádio (até {{limit}} caracteres). Mensagens mais longas precisam ser divididas em partes numeradas, mas em uma malha ocupada os repetidores costumam descartar algumas dessas partes — então a pessoa para quem você está enviando a mensagem receberia uma mensagem incompleta sem ter como saber. Para manter as mensagens confiáveis, elas não são divididas automaticamente. Encurte esta mensagem ou envie-a como algumas mensagens curtas separadas.", "hint": "O MeshCore envia um pacote por mensagem; as partes divididas geralmente são descartadas por repetidores ocupados, portanto, mensagens mais longas não podem ser enviadas." } }, diff --git a/src/renderer/locales/ru/translation.json b/src/renderer/locales/ru/translation.json index 37200adc6..4b2e9684e 100644 --- a/src/renderer/locales/ru/translation.json +++ b/src/renderer/locales/ru/translation.json @@ -13,7 +13,7 @@ "loadingPanel": "Загрузочная панель", "loadingDialog": "Диалог загрузки", "meshtasticQueueTooltip": "Очередь передачи: пакеты, ожидающие отправки. Зеленый = низкий уровень, желтый = заполнение, красный = перегружен.", - "meshcoreQueueTooltip": "Поскольку MeshCore отправляет сообщения быстро, а мы опрашиваем каждые 30 секунд, это значение всегда должно быть 0. Если не 0, существует перегрузка.", + "meshcoreQueueTooltip": "Поскольку MeshCore отправляет сообщения быстро, а мы опрашиваем каждые 30 секунд, это значение всегда должно быть 0. Если не 0, существует перегрузка или программная ошибка.", "takRunning": "TAK работает", "takStopped": "TAK остановлен", "takServerRunning": "TAK-сервер работает", diff --git a/src/renderer/locales/tr/translation.json b/src/renderer/locales/tr/translation.json index 5fe23cefd..bbed94531 100644 --- a/src/renderer/locales/tr/translation.json +++ b/src/renderer/locales/tr/translation.json @@ -13,7 +13,7 @@ "loadingPanel": "Yükleme paneli", "loadingDialog": "Yükleme iletişim kutusu", "meshtasticQueueTooltip": "İletim kuyruğu: gönderilmeyi bekleyen paketler. Yeşil = az, sarı = doluyor, kırmızı = tıkalı.", - "meshcoreQueueTooltip": "MeshCore hızlı mesaj gönderdiği ve her 30 saniyede bir yoklama yaptığımız için bu her zaman 0 olmalıdır. 0 değilse tıkanıklık vardır.", + "meshcoreQueueTooltip": "MeshCore hızlı mesaj gönderdiği ve her 30 saniyede bir yoklama yaptığımız için bu her zaman 0 olmalıdır. 0 değilse tıkanıklık veya program hatası vardır.", "takRunning": "TAK çalışıyor", "takStopped": "TAK durduruldu", "takServerRunning": "TAK sunucusu çalışıyor", diff --git a/src/renderer/locales/uk/translation.json b/src/renderer/locales/uk/translation.json index 0628df7f3..c737961a0 100644 --- a/src/renderer/locales/uk/translation.json +++ b/src/renderer/locales/uk/translation.json @@ -13,7 +13,7 @@ "loadingPanel": "Панель завантаження", "loadingDialog": "Діалогове вікно завантаження", "meshtasticQueueTooltip": "Черга передачі: пакети, які очікують відправлення. Зелений = низький, бурштиновий = заповнення, червоний = перевантажений.", - "meshcoreQueueTooltip": "Оскільки MeshCore надсилає повідомлення швидко, а ми опитуємо кожні 30 секунд, це завжди має бути 0. Якщо не 0, є затори.", + "meshcoreQueueTooltip": "Оскільки MeshCore надсилає повідомлення швидко, а ми опитуємо кожні 30 секунд, це завжди має бути 0. Якщо не 0, є затори або програмна помилка.", "takRunning": "TAK працює", "takStopped": "TAK зупинився", "takServerRunning": "Сервер TAK працює", diff --git a/src/renderer/locales/zh/translation.json b/src/renderer/locales/zh/translation.json index 57d217830..37aa20a09 100644 --- a/src/renderer/locales/zh/translation.json +++ b/src/renderer/locales/zh/translation.json @@ -13,7 +13,7 @@ "loadingPanel": "加载面板", "loadingDialog": "正在加载对话框", "meshtasticQueueTooltip": "传输队列:等待发送的数据包。绿色=低,琥珀色=填满,红色=拥挤。", - "meshcoreQueueTooltip": "由于MeshCore快速发送消息,并且我们每30秒轮询一次,因此此值应始终为0。如果不是0 ,则会出现拥堵。", + "meshcoreQueueTooltip": "由于MeshCore快速发送消息,并且我们每30秒轮询一次,因此此值应始终为0。如果不是0 ,则会出现拥堵或程序错误。", "takRunning": "TAK 运行中", "takStopped": "TAK 已停止", "takServerRunning": "TAK服务器运行", From f5d519932c228526c50944fbcfcd88022947e219 Mon Sep 17 00:00:00 2001 From: Joey Stanford Date: Sat, 8 Aug 2026 13:45:25 -0600 Subject: [PATCH 5/5] fix(meshcore): gate single-packet via capabilities; quarantine legacy outbox Address PR review: document the MeshCore multi-split removal as a breaking change in README Limitations; drive chunk limits and fast-send cadence from ProtocolCapabilities.composerMaxChunks; show the advisory on GIF/location sends; and block upgrade-path drain of legacy [i/N] / groupTotal>1 MeshCore outbox rows without transmitting them. --- README.md | 2 +- docs/agents/chat.md | 2 +- src/renderer/components/ChatComposer.test.tsx | 66 +++++++++++++++++ src/renderer/components/ChatComposer.tsx | 71 +++++++++++++------ src/renderer/hooks/useChatOutbox.test.ts | 54 ++++++++++++++ src/renderer/hooks/useChatOutbox.ts | 51 +++++++++++-- src/renderer/lib/chatComposerLimits.ts | 12 ++-- src/renderer/lib/radio/BaseRadioProvider.ts | 14 +++- .../lib/radio/protocol-capabilities.test.ts | 4 ++ src/renderer/lib/radio/providerFactory.ts | 7 +- src/renderer/locales/cs/translation.json | 3 +- src/renderer/locales/de/translation.json | 3 +- src/renderer/locales/en/translation.json | 1 + src/renderer/locales/es/translation.json | 3 +- src/renderer/locales/fr/translation.json | 3 +- src/renderer/locales/id/translation.json | 3 +- src/renderer/locales/it/translation.json | 3 +- src/renderer/locales/ja/translation.json | 3 +- src/renderer/locales/ko/translation.json | 3 +- src/renderer/locales/nl/translation.json | 3 +- src/renderer/locales/pl/translation.json | 3 +- src/renderer/locales/pt-BR/translation.json | 3 +- src/renderer/locales/ru/translation.json | 3 +- src/renderer/locales/tr/translation.json | 3 +- src/renderer/locales/uk/translation.json | 3 +- src/renderer/locales/zh/translation.json | 3 +- 26 files changed, 273 insertions(+), 56 deletions(-) diff --git a/README.md b/README.md index 08feba8af..ff3089811 100644 --- a/README.md +++ b/README.md @@ -383,7 +383,7 @@ Architecture and API: [docs/reticulum.md](docs/reticulum.md). Games wire parity: - **MQTT → RF (MeshCore JSON)**: Not supported; MeshCore MQTT is chat ingest only. - **Meshtastic - PKC remote admin**: Configure-node-over-MQTT is not supported; a connected local RF radio is required to reach remote nodes (firmware 2.5+). - **MeshCore - MQTT (JSON v1)**: The Connection tab can connect to an MQTT broker in MeshCore mode using a small JSON chat envelope (see [docs/meshcore-meshtastic-parity.md](docs/meshcore-meshtastic-parity.md)). This is separate from Meshtastic's protobuf MQTT pipeline. -- **MeshCore - single-packet messages (no multi-part split)**: mesh-client sends each MeshCore chat/DM/room message as a single radio packet (max ~130-160 characters depending on context and sender name) and **blocks** longer text with an explanatory notice in the composer. Splitting a long message into numbered `[i/N]` parts is unreliable on a busy mesh: repeaters routinely drop some parts, so the recipient silently gets an incomplete message. To keep messages reliable, mesh-client does not split them — shorten the text or send it as a few separate shorter messages. A non-blocking advisory also appears if you send messages faster than the mesh can relay them (within ~5s). Upstream context: repeater token-bucket / rate-limit drops [meshcore-dev/MeshCore#1502](https://github.com/meshcore-dev/MeshCore/issues/1502), busy-mesh collisions / packet loss [meshcore-dev/MeshCore#2820](https://github.com/meshcore-dev/MeshCore/issues/2820), and client anti-spam context [meshcore-dev/MeshCore#3053](https://github.com/meshcore-dev/MeshCore/issues/3053). Inbound multi-part messages from other clients are still merged for display. +- **Breaking change — MeshCore single-packet messages (no multi-part / multi-split)**: mesh-client **no longer** auto-splits outbound MeshCore chat, DM, or room messages into numbered `[i/N]` packets. Each send is one radio packet (max ~130-160 characters depending on context and sender name); longer text is **blocked** with an explanatory notice in the composer. On a busy mesh, repeaters routinely drop some split parts, so recipients previously got silently incomplete messages. **Migration:** shorten long messages, or send them as a few separate shorter messages. A non-blocking advisory also appears if you send faster than the mesh can relay (~5s). Call this out in release notes. Upstream: [meshcore-dev/MeshCore#1502](https://github.com/meshcore-dev/MeshCore/issues/1502), [#2820](https://github.com/meshcore-dev/MeshCore/issues/2820), [#3053](https://github.com/meshcore-dev/MeshCore/issues/3053). Inbound multi-part from other clients is still merged for display. Meshtastic multi-split (up to 9 parts) is unchanged. - **MeshCore - partial routing diagnostics**: MeshCore supports `route_flapping` / `path_instability` (PathUpdated events) and `weak_link` (when `hasPerHopSnr` and a trace is completed). Distance-based `hop_goblin` / close-in `bad_route` are Meshtastic-only (`hasDistanceBasedHopAnomalies`). Full hop-anomaly detection and Meshtastic-style LocalStats RF findings require Meshtastic packets; MeshCore provides its own RF findings (Elevated Noise Floor, Excessive Flooding) from Repeater Status packet stats. **Foreign LoRa** tables render on the Meshtastic tab only (MeshCore may record overhear internally). - **MeshCore - channel editing**: Can add/edit/delete channels (name + PSK) via the Radio tab, but does not expose Meshtastic-style full protobuf config. Radio parameters (frequency, bandwidth, spreading factor, coding rate, TX power) can be set via the Radio tab. - **MeshCore - remote telemetry availability**: `getTelemetry` requires the remote node to have environment sensors. A timeout is returned if the node has no sensor data. diff --git a/docs/agents/chat.md b/docs/agents/chat.md index 97fbc273e..78dc4e802 100644 --- a/docs/agents/chat.md +++ b/docs/agents/chat.md @@ -3,7 +3,7 @@ Deep subsystem reference for AI assistants. Open this when a task touches the Chat panel, composer, link previews, notifications, dedup, hop badges, reactions/tapbacks, mentions, or chat/support export. Hard rules live in [`AGENTS.md`](../../AGENTS.md). - **Components:** `ChatPanel.tsx` (channel/DM UI) + shared `ChatComposer.tsx` (drafts, mentions, chunking, spellcheck, emoji; also used by `RoomsPanel.tsx`). Reticulum DM **Share as paper** / **Scan paper** via `ChatDmPaperControls.tsx` + `createReticulumPaperMessage.ts`. Scroll-at-bottom helper: `chatScrollUtils.ts` (`getDistFromChatBottom`). -- **Composer limits / send cadence:** `chatComposerLimits.ts` — `getMaxChunks(protocol)` (MeshCore = 1: no outbound `[i/N]` split; `splitChatMessage` returns `null` when text needs more than one packet), room payload via `getMeshcoreRoomPayloadLimit`, `computeComposerLimitStatus` phases (`warn` surfaces a single-packet ⓘ hint; `overMaxSingle` disables send and shows a `role="note"` callout). MeshCore also gets a **non-blocking** ~5s "sending too fast" advisory (`role="status"`, dismissible) from an app-wide clock in `meshcoreSendRateNotice.ts` (`recordMeshcoreSend` / `isMeshcoreSendTooFast`, `MESHCORE_FAST_SEND_WARN_INTERVAL_MS`). Every **live** MeshCore send advances the clock — text (`handleSend`), GIF, share-location, and outbox drain (`useChatOutbox.ts`) — but the banner UI renders only on the text composer path. Inbound multi-part `[i/N]` merge is unchanged. i18n: `chatPanel.composeLimit.meshcoreSingleNotice.*`, `chatPanel.meshcoreFastSend.warning`. See [`meshcore-meshtastic-parity.md`](../meshcore-meshtastic-parity.md). +- **Composer limits / send cadence:** `chatComposerLimits.ts` — `getMaxChunks(protocol)` via `ProtocolCapabilities.composerMaxChunks` (MeshCore = 1: no outbound `[i/N]` split; `splitChatMessage` returns `null` when text needs more than one packet), room payload via `getMeshcoreRoomPayloadLimit`, `computeComposerLimitStatus` phases (`warn` surfaces a single-packet ⓘ hint; `overMaxSingle` disables send and shows a `role="note"` callout). MeshCore also gets a **non-blocking** ~5s "sending too fast" advisory (`role="status"`, dismissible) from an app-wide clock in `meshcoreSendRateNotice.ts` (`recordMeshcoreSend` / `isMeshcoreSendTooFast`, `MESHCORE_FAST_SEND_WARN_INTERVAL_MS`). Every **live** MeshCore send advances the clock and can show the advisory — text (`handleSend`), GIF, and share-location in `ChatComposer`, plus outbox drain (`useChatOutbox.ts`). Legacy MeshCore outbox rows with `groupTotal > 1` or `[i/N]` payloads are quarantined (`blocked`) on drain instead of being transmitted. Inbound multi-part `[i/N]` merge is unchanged. i18n: `chatPanel.composeLimit.meshcoreSingleNotice.*`, `chatPanel.meshcoreFastSend.warning`, `chatPanel.outboxLegacyMultipartBlocked`. See [`meshcore-meshtastic-parity.md`](../meshcore-meshtastic-parity.md). - **Payload / links:** `ChatPayloadText.tsx` — mention highlighting, search marks, URL linkification; link previews via `chat:fetchLinkPreview` (`src/main/fetchLinkPreview.ts`): Open Graph for HTML pages; **YouTube** watch/shorts/youtu.be via oEmbed + thumbnail; **direct image URLs** (path extension via `chatDirectImageUrl.ts` or raster `Content-Type`) return `kind: 'image'` and render as inline embeds (`ChatInlineImage` / `DirectImageEmbed`); OG/YouTube use card layout. Security: DNS-pinned undici `Agent`, private/loopback blocked, magic-byte MIME sniff (`safeRasterImageMime.ts`), HTTPS-only image embeds, 10s fetch / 3s DNS, 64 KiB HTML cap, **2 MiB** image fetch cap (256 KiB cache payload cap), LRU caches, single-flight dedup (renderer map capped). Previews load even when scrolled up. LXMF attachment rasters: `chat:readReticulumAttachmentAsDataUrl` (`reticulum-attachment-image.ts`; path jail, magic-byte MIME, SVG rejected, 2 MiB, IPC rate limit) → `ReticulumAttachmentLine`. Reply quotes: `replyPreview.ts`. - **Storage helpers:** `src/renderer/lib/chatPanelProtocolStorage.ts` — drafts (`mesh-client:drafts:`), open DM tabs, last-read, per-view mute (`mesh-client:mutedViews:`), starred (`mesh-client:starred:`, cap 200), MeshCore flood-scope overrides per chat view (`mesh-client:floodScopeOverrides:`, channel or DM `viewKey`). - **Notifications:** `src/renderer/lib/chatNotifications.ts` — `playMessageNotification(type)` via Web Audio: `channel` = single 880 Hz pulse (150 ms); `dm` / `reply` = dual pulse (587.33 Hz then 783.99 Hz, 50 ms each, 35 ms gap). Resumes suspended `AudioContext` when the window is hidden/minimized. Type selection in `chatUnreadCounts.ts` (`resolveChatNotificationType`, `pickAudibleNotificationType`; batch priority reply > dm > channel). **ChatPanel** plays when the user is on Chat but reading another view; **App** plays for other panels / backgrounded window (avoids double beep). Meshtastic hidden-window desktop notifications are visual-only (`silent: true` in `meshtasticRouterSideEffects.ts`); typed Web Audio from App owns sound. Global mute `mesh-client:notifMuted`; per-view mute in `mutedViews`. Main-process **tray** icon shows unread when chat or MeshCore Rooms traffic arrives while backgrounded (`src/main/index.ts` `buildTrayIcon`). diff --git a/src/renderer/components/ChatComposer.test.tsx b/src/renderer/components/ChatComposer.test.tsx index 9b3c967b2..b4779be01 100644 --- a/src/renderer/components/ChatComposer.test.tsx +++ b/src/renderer/components/ChatComposer.test.tsx @@ -643,6 +643,44 @@ describe('ChatComposer', () => { expect(warning).toHaveTextContent('sending faster than the mesh'); }); + it('shows the fast-send advisory on rapid GIF-to-GIF sends', async () => { + localStorage.setItem( + 'mesh-client:appSettings', + JSON.stringify({ meshcoreOpenWireCompatEnabled: true }), + ); + const onSendChunk = vi.fn().mockResolvedValue(undefined); + const user = userEvent.setup(); + render( + , + ); + await user.click(screen.getByRole('button', { name: 'Insert Giphy GIF' })); + fireEvent.change(screen.getByRole('textbox', { name: 'Giphy URL or id' }), { + target: { value: 'g:a5viI92PAF89q' }, + }); + await user.click(screen.getByRole('button', { name: 'Send GIF' })); + await waitFor(() => { + expect(onSendChunk).toHaveBeenCalledTimes(1); + }); + expect(screen.queryByRole('status')).toBeNull(); + + await user.click(screen.getByRole('button', { name: 'Insert Giphy GIF' })); + fireEvent.change(screen.getByRole('textbox', { name: 'Giphy URL or id' }), { + target: { value: 'g:b6wjJ03QBG90r' }, + }); + await user.click(screen.getByRole('button', { name: 'Send GIF' })); + await waitFor(() => { + expect(onSendChunk).toHaveBeenCalledTimes(2); + }); + const warning = await screen.findByRole('status'); + expect(warning).toHaveTextContent('sending faster than the mesh'); + }); + it('carries the fast-send advisory from a shared location to a following text send', async () => { const onSendChunk = vi.fn().mockResolvedValue(undefined); const resolveShareLocation = vi.fn().mockResolvedValue({ lat: 39.7392, lon: -104.9903 }); @@ -673,6 +711,34 @@ describe('ChatComposer', () => { expect(warning).toHaveTextContent('sending faster than the mesh'); }); + it('shows the fast-send advisory on rapid location-to-location sends', async () => { + const onSendChunk = vi.fn().mockResolvedValue(undefined); + const resolveShareLocation = vi.fn().mockResolvedValue({ lat: 39.7392, lon: -104.9903 }); + const user = userEvent.setup(); + render( + , + ); + await user.click(screen.getByRole('button', { name: 'Share location' })); + await waitFor(() => { + expect(onSendChunk).toHaveBeenCalledTimes(1); + }); + expect(screen.queryByRole('status')).toBeNull(); + + await user.click(screen.getByRole('button', { name: 'Share location' })); + await waitFor(() => { + expect(onSendChunk).toHaveBeenCalledTimes(2); + }); + const warning = await screen.findByRole('status'); + expect(warning).toHaveTextContent('sending faster than the mesh'); + }); + it('hides GIF button when MeshCore Open wire compat is disabled', () => { render( window.electronAPI.getPlatform() === 'linux', []); const limitHintId = useId(); const counterLiveId = useId(); @@ -521,6 +523,24 @@ export function ChatComposer({ }, MESHCORE_FAST_SEND_WARN_INTERVAL_MS); }, []); + /** + * Shared single-packet cadence bookkeeping for live text / GIF / location sends. + * Capture `tooFast` *before* the send; call after a successful send with that flag. + * Never blocks or delays the send. + */ + const finishSendCadence = useCallback( + (tooFast: boolean) => { + if (!tracksSendCadence) return; + recordMeshcoreSend(); + if (tooFast) { + triggerMeshcoreFastSendWarn(); + } else { + dismissMeshcoreFastSendWarn(); + } + }, + [dismissMeshcoreFastSendWarn, tracksSendCadence, triggerMeshcoreFastSendWarn], + ); + useEffect(() => { return () => { if (meshcoreFastSendWarnTimerRef.current) { @@ -594,8 +614,8 @@ export function ChatComposer({ setSending(true); setChatActionError(null); // Advisory fast-send cadence: capture before recording this send so the warning reflects - // proximity to the *previous* MeshCore send. Never blocks or delays the send. - const meshcoreTooFast = protocol === 'meshcore' && isMeshcoreSendTooFast(); + // proximity to the *previous* single-packet-protocol send. Never blocks or delays the send. + const sendTooFast = tracksSendCadence && isMeshcoreSendTooFast(); try { for (let i = 0; i < textsToSend.length; i++) { const sendChunk = () => @@ -618,14 +638,7 @@ export function ChatComposer({ await sendChunk(); } } - if (protocol === 'meshcore') { - recordMeshcoreSend(); - if (meshcoreTooFast) { - triggerMeshcoreFastSendWarn(); - } else { - dismissMeshcoreFastSendWarn(); - } - } + finishSendCadence(sendTooFast); rememberFloodScopeIfNeeded(floodScopeOverride); clearSentDraft(draftSnapshot); setMentionQuery(null); @@ -693,8 +706,8 @@ export function ChatComposer({ viewKey, wireOverheadFirstChunk, meshcoreOpenWireCompat, - triggerMeshcoreFastSendWarn, - dismissMeshcoreFastSendWarn, + tracksSendCadence, + finishSendCadence, ]); const sendGifWire = useCallback( @@ -709,11 +722,11 @@ export function ChatComposer({ } setSending(true); setChatActionError(null); + const sendTooFast = tracksSendCadence && isMeshcoreSendTooFast(); try { await onSendChunk(wireText); - // GIF is a live MeshCore send — advance the shared fast-send clock so a text send - // right after still surfaces the advisory (UI stays on the text send path). - if (protocol === 'meshcore') recordMeshcoreSend(); + // GIF is a live send on single-packet protocols — same cadence check/record/warn as text. + finishSendCadence(sendTooFast); setShowGifModal(false); setGifInput(''); onSendSuccess?.(); @@ -727,7 +740,18 @@ export function ChatComposer({ setSending(false); } }, - [allowOutbox, disabled, isConnected, onSendChunk, onSendSuccess, protocol, sending, t, viewKey], + [ + allowOutbox, + disabled, + finishSendCadence, + isConnected, + onSendChunk, + onSendSuccess, + sending, + t, + tracksSendCadence, + viewKey, + ], ); const handleGifConfirm = useCallback(() => { @@ -790,9 +814,10 @@ export function ChatComposer({ if (await enqueueLocationText(text)) onSendSuccess?.(); return; } + const sendTooFast = tracksSendCadence && isMeshcoreSendTooFast(); await onSendChunk(text); - // Live MeshCore location send counts toward the shared fast-send cadence clock. - if (protocol === 'meshcore') recordMeshcoreSend(); + // Live location send on single-packet protocols uses the same cadence sequence as text. + finishSendCadence(sendTooFast); if ( protocol === 'meshtastic' && isShareLocationSendWaypointEnabled() && @@ -833,6 +858,7 @@ export function ChatComposer({ allowOutbox, disabled, enqueueLocationText, + finishSendCadence, isConnected, isMqttOnly, onSendChunk, @@ -842,6 +868,7 @@ export function ChatComposer({ resolveShareLocation, sending, t, + tracksSendCadence, viewKey, ]); @@ -923,9 +950,9 @@ export function ChatComposer({ ? t('chatPanel.composePlaceholderMqttOnly') : t('chatPanel.composePlaceholderDefault')); - // MeshCore sends a single radio packet (no multi-part `[i/N]` split): over-limit text is - // blocked with an explanatory callout rather than auto-split into parts that busy repeaters drop. - const singlePacketProtocol = getMaxChunks(protocol) <= 1; + // Single-packet protocols (composerMaxChunks <= 1): over-limit text is blocked with an + // explanatory callout rather than auto-split into parts that busy repeaters drop. + const singlePacketProtocol = capabilities.composerMaxChunks <= 1; const limitHintText = singlePacketProtocol ? t('chatPanel.composeLimit.limitHintSingle', { limit: limitStatus.singleMessageLimit }) diff --git a/src/renderer/hooks/useChatOutbox.test.ts b/src/renderer/hooks/useChatOutbox.test.ts index 0a1dc4390..76ca0a010 100644 --- a/src/renderer/hooks/useChatOutbox.test.ts +++ b/src/renderer/hooks/useChatOutbox.test.ts @@ -250,6 +250,60 @@ describe('useChatOutbox', () => { expect(isMeshcoreSendTooFast()).toBe(true); }); + it('quarantines legacy meshcore multipart outbox rows without calling sendFn', async () => { + // Upgrade path: rows queued before single-packet (groupTotal > 1 / [i/N] payload) must not TX. + const legacy = makeEntry({ + id: 60, + protocol: 'meshcore', + payload: '[1/3] first chunk of a long message', + groupId: 'legacy-group', + groupIndex: 0, + groupTotal: 3, + }); + vi.mocked(mockOutbox.list).mockResolvedValue([legacy]); + const sendFn = vi.fn().mockResolvedValue(undefined); + const { result } = renderHook(() => + useChatOutbox({ protocol: 'meshcore', isSendAvailable: true, sendFn }), + ); + await waitFor(() => { + expect(mockOutbox.updateStatus).toHaveBeenCalledWith( + 60, + 'blocked', + expect.stringMatching(/multi-part|shorter/i), + undefined, + ); + }); + expect(sendFn).not.toHaveBeenCalled(); + await waitFor(() => { + const row = result.current.rows.find((r) => r.id === 60); + expect(row?.status).toBe('blocked'); + expect(row?.error).toMatch(/multi-part|shorter/i); + }); + }); + + it('still drains non-legacy meshcore rows when a legacy multipart row is also present', async () => { + const legacy = makeEntry({ + id: 61, + protocol: 'meshcore', + payload: '[2/2] leftover', + groupTotal: 2, + }); + const ok = makeEntry({ id: 62, protocol: 'meshcore', payload: 'short ok' }); + vi.mocked(mockOutbox.list).mockResolvedValue([legacy, ok]); + const sendFn = vi.fn().mockResolvedValue(undefined); + renderHook(() => useChatOutbox({ protocol: 'meshcore', isSendAvailable: true, sendFn })); + await waitFor(() => { + expect(sendFn).toHaveBeenCalledTimes(1); + }); + expect(sendFn).toHaveBeenCalledWith('short ok', 0, undefined, undefined); + expect(mockOutbox.updateStatus).toHaveBeenCalledWith( + 61, + 'blocked', + expect.stringMatching(/multi-part|shorter/i), + undefined, + ); + }); + it('does not touch the meshcore fast-send clock for meshtastic drains', async () => { const entry = makeEntry({ id: 51, protocol: 'meshtastic', payload: 'hi' }); vi.mocked(mockOutbox.list).mockResolvedValue([entry]); diff --git a/src/renderer/hooks/useChatOutbox.ts b/src/renderer/hooks/useChatOutbox.ts index e630906a3..fd2619560 100644 --- a/src/renderer/hooks/useChatOutbox.ts +++ b/src/renderer/hooks/useChatOutbox.ts @@ -2,10 +2,13 @@ import { useCallback, useEffect, useRef, useState } from 'react'; import type { MeshProtocol } from '@/renderer/lib/types'; import type { OutboxEntry, OutboxEntryInput, OutboxStatus } from '@/shared/electron-api.types'; +import { isMeshProtocol } from '@/shared/meshProtocol'; import { registerChatOutboxDrainListener } from '../lib/chatOutboxDrain'; +import i18n from '../lib/i18n'; import { recordMeshcoreSend } from '../lib/meshcoreSendRateNotice'; import { withMeshtasticTextSendPacing } from '../lib/meshtasticTextSendPacing'; +import { getRadioCapabilities } from '../lib/radio/providerFactory'; export type { OutboxEntry }; @@ -15,6 +18,9 @@ const MAX_ATTEMPTS = 5; /** Drop outbox rows older than this from automatic drain (manual retry still allowed). */ export const OUTBOX_MAX_AGE_MS = 24 * 60 * 60 * 1000; +/** Legacy mesh-client `[i/N] ` chunk prefix on outbox payloads queued before single-packet. */ +const LEGACY_MULTIPART_PREFIX_RE = /^\[\d+\/\d+\]\s/; + function isEncryptionBlockedError(errMsg: string): boolean { return /no.?encr|no.?key|encryption/i.test(errMsg); } @@ -23,6 +29,18 @@ function errMessage(err: unknown): string { return err instanceof Error ? err.message : String(err); } +/** + * True for durable outbox rows from before MeshCore single-packet: grouped multi-chunk sends + * (`groupTotal > 1`) and/or payloads already prefixed with `[i/N] `. Single-packet protocols + * must not TX these on upgrade — quarantine for user cancel/edit instead. + */ +export function isLegacySinglePacketMultipartOutboxRow(row: OutboxEntry): boolean { + if (!isMeshProtocol(row.protocol)) return false; + if (getRadioCapabilities(row.protocol).composerMaxChunks > 1) return false; + if (row.groupTotal != null && row.groupTotal > 1) return true; + return LEGACY_MULTIPART_PREFIX_RE.test(row.payload); +} + /** Reset interrupted `sending` rows to `queued` (crash / failed status persist). */ async function recoverStuckSendingRows(listed: OutboxEntry[]): Promise { const stuckSending = listed.filter((r) => r.status === 'sending'); @@ -104,6 +122,25 @@ async function recordOutboxSendFailure( console.warn('[useChatOutbox] send failed for outbox row', row.id, errMsg); } +/** + * Preserve a legacy multi-part MeshCore outbox row without calling sendFn. + * Failure point: upgrade-path drain would otherwise TX incomplete `[i/N]` parts; + * fallback: block with an explanatory error so the user can cancel or rewrite. + */ +async function quarantineLegacyMultipartOutboxRow( + row: OutboxEntry, + updateRow: (id: number, patch: Partial) => void, +): Promise { + const error = i18n.t('chatPanel.outboxLegacyMultipartBlocked'); + try { + await window.electronAPI.chat.outbox.updateStatus(row.id, 'blocked', error, undefined); + } catch (persistErr: unknown) { + console.warn('[useChatOutbox] quarantine legacy multipart failed', row.id, persistErr); + } + updateRow(row.id, { status: 'blocked', error, nextRetryAt: null }); + console.warn('[useChatOutbox] quarantined legacy multipart outbox row', row.id); +} + async function sendOneOutboxRow( row: OutboxEntry, sendFn: UseChatOutboxOptions['sendFn'], @@ -114,9 +151,8 @@ async function sendOneOutboxRow( updateRow(row.id, { status: 'sending' }); try { await sendFn(row.payload, row.channel, row.toNode ?? undefined, row.replyId ?? undefined); - // Keep the app-wide MeshCore fast-send clock honest: a drained row is airtime too, so a - // composer send right after a backlog drain should still surface the advisory. - if (row.protocol === 'meshcore') { + // Keep the app-wide single-packet fast-send clock honest: a drained row is airtime too. + if (isMeshProtocol(row.protocol) && getRadioCapabilities(row.protocol).composerMaxChunks <= 1) { recordMeshcoreSend(); } await finalizeSuccessfulOutboxSend(row, removeRow, updateRow); @@ -199,10 +235,15 @@ export function useChatOutbox({ const now = Date.now(); for (const row of freshRows.filter((r) => isEligibleForDrain(r, now))) { if (!isSendAvailableRef.current) break; + // Upgrade path: do not TX legacy MeshCore multi-split rows queued before single-packet. + if (isLegacySinglePacketMultipartOutboxRow(row)) { + await quarantineLegacyMultipartOutboxRow(row, updateRow); + continue; + } const sendRow = () => sendOneOutboxRow(row, sendFnRef.current, updateRow, removeRow); // Meshtastic-only pacing, shared with ChatComposer so live sends and outbox drain cannot - // race firmware's TEXT_MESSAGE_APP RATE_LIMIT_EXCEEDED window. MeshCore drains without a - // client interval (see the meshcore branch below) — it only advances the fast-send clock. + // race firmware's TEXT_MESSAGE_APP RATE_LIMIT_EXCEEDED window. Single-packet protocols + // drain without a client interval — they only advance the fast-send clock after success. if (protocol === 'meshtastic') { await withMeshtasticTextSendPacing(sendRow); } else { diff --git a/src/renderer/lib/chatComposerLimits.ts b/src/renderer/lib/chatComposerLimits.ts index 9a12a5462..462356672 100644 --- a/src/renderer/lib/chatComposerLimits.ts +++ b/src/renderer/lib/chatComposerLimits.ts @@ -2,6 +2,7 @@ import { formatMeshcoreWireReplyPrefix, formatMeshcoreWireTapbackPrefix, } from './meshcoreChannelText'; +import { getRadioCapabilities } from './radio/providerFactory'; import type { MeshProtocol } from './types'; export const MESHTASTIC_PAYLOAD_LIMIT = 228; @@ -9,17 +10,16 @@ export const MESHTASTIC_PAYLOAD_LIMIT = 228; export const MESHCORE_PAYLOAD_LIMIT = 133; /** LXMF DM text limit for composer (sidecar handles wire encoding; no Meshtastic-style chunking). */ export const RETICULUM_LXMF_PAYLOAD_LIMIT = 4096; +/** Keep in sync with `ProtocolCapabilities.composerMaxChunks` for Meshtastic/Reticulum. */ export const MAX_CHUNKS = 9; /** - * Max chunks a composer will split a message into, per protocol. MeshCore is capped at a - * single packet (no multi-part `[i/N]` split): on a busy mesh, repeaters routinely drop some - * split parts, so the recipient silently gets an incomplete message. Meshtastic/Reticulum keep - * the `MAX_CHUNKS` (9) auto-split. See meshcore-dev/MeshCore #1502 / #2820. Inbound multi-part - * from other clients is unaffected (we still merge `[i/N]` on receive). + * Max chunks a composer will split a message into, per protocol. Sourced from + * `ProtocolCapabilities.composerMaxChunks` (MeshCore = 1: no multi-part `[i/N]` split). + * See meshcore-dev/MeshCore #1502 / #2820. Inbound multi-part from other clients is unaffected. */ export function getMaxChunks(protocol: MeshProtocol): number { - return protocol === 'meshcore' ? 1 : MAX_CHUNKS; + return getRadioCapabilities(protocol).composerMaxChunks; } export const MESHCORE_WIRE_MAX = 160; diff --git a/src/renderer/lib/radio/BaseRadioProvider.ts b/src/renderer/lib/radio/BaseRadioProvider.ts index 7e76b2e54..49923f1d8 100644 --- a/src/renderer/lib/radio/BaseRadioProvider.ts +++ b/src/renderer/lib/radio/BaseRadioProvider.ts @@ -1,8 +1,6 @@ import type { MeshProtocol } from '@/shared/meshProtocol'; import { MS_PER_DAY, MS_PER_HOUR } from '@/shared/timeConstants'; -import { RETICULUM_LXMF_PAYLOAD_LIMIT } from '../chatComposerLimits'; - /** * Protocol-agnostic capability descriptor. Each radio protocol adapter exposes * one of these so UI and diagnostic engines can branch on features rather than @@ -10,6 +8,12 @@ import { RETICULUM_LXMF_PAYLOAD_LIMIT } from '../chatComposerLimits'; */ export interface ProtocolCapabilities { protocol: MeshProtocol; + /** + * Max `[i/N]` chunks the composer may emit per outbound text send. + * MeshCore is 1 (single-packet; no multi-split). Meshtastic/Reticulum use 9 + * (keep in sync with `MAX_CHUNKS` in `chatComposerLimits.ts`). + */ + composerMaxChunks: number; /** Whether hops_away is populated for peers (Meshtastic / MeshCore: true; Reticulum: false) */ hasHopCount: boolean; /** [min, max] valid hop limit for this protocol */ @@ -167,6 +171,7 @@ export interface ProtocolCapabilities { export const MESHTASTIC_CAPABILITIES: ProtocolCapabilities = { protocol: 'meshtastic', + composerMaxChunks: 9, hasHopCount: true, hopLimitRange: [1, 7], hasMqttHybrid: true, @@ -245,6 +250,7 @@ export const MESHTASTIC_CAPABILITIES: ProtocolCapabilities = { export const MESHCORE_CAPABILITIES: ProtocolCapabilities = { protocol: 'meshcore', + composerMaxChunks: 1, hasHopCount: true, hopLimitRange: [1, 64], /** MeshCore session is RF-first; MQTT bridge is optional and not shown as a node column. */ @@ -324,6 +330,7 @@ export const MESHCORE_CAPABILITIES: ProtocolCapabilities = { export const RETICULUM_CAPABILITIES: ProtocolCapabilities = { protocol: 'reticulum', + composerMaxChunks: 9, hasHopCount: false, hopLimitRange: [1, 128], hasMqttHybrid: false, @@ -398,5 +405,6 @@ export const RETICULUM_CAPABILITIES: ProtocolCapabilities = { hasLrgpGames: true, hasNobleBleScanning: false, hasLxmfPaper: true, - lxmfPayloadLimit: RETICULUM_LXMF_PAYLOAD_LIMIT, + // Keep in sync with RETICULUM_LXMF_PAYLOAD_LIMIT in chatComposerLimits.ts (no import — avoids cycle). + lxmfPayloadLimit: 4096, }; diff --git a/src/renderer/lib/radio/protocol-capabilities.test.ts b/src/renderer/lib/radio/protocol-capabilities.test.ts index abeee785d..f0f3c483d 100644 --- a/src/renderer/lib/radio/protocol-capabilities.test.ts +++ b/src/renderer/lib/radio/protocol-capabilities.test.ts @@ -20,6 +20,7 @@ import { const REQUIRED_CAPABILITY_KEYS: (keyof ProtocolCapabilities)[] = [ 'protocol', + 'composerMaxChunks', 'hasHopCount', 'hopLimitRange', 'hasMqttHybrid', @@ -121,6 +122,7 @@ describe('ProtocolCapabilities contract', () => { it('MESHTASTIC_CAPABILITIES exact values are stable', () => { expect(MESHTASTIC_CAPABILITIES).toMatchInlineSnapshot(` { + "composerMaxChunks": 9, "dedupeQueueBadgeForLocalSending": true, "hasAtakPlugin": true, "hasAudio": true, @@ -206,6 +208,7 @@ describe('ProtocolCapabilities contract', () => { it('MESHCORE_CAPABILITIES exact values are stable', () => { expect(MESHCORE_CAPABILITIES).toMatchInlineSnapshot(` { + "composerMaxChunks": 1, "dedupeQueueBadgeForLocalSending": false, "hasAtakPlugin": false, "hasAudio": false, @@ -297,6 +300,7 @@ describe('ProtocolCapabilities contract', () => { it('RETICULUM_CAPABILITIES exact values are stable', () => { expect(RETICULUM_CAPABILITIES).toMatchInlineSnapshot(` { + "composerMaxChunks": 9, "dedupeQueueBadgeForLocalSending": false, "hasAtakPlugin": false, "hasAudio": false, diff --git a/src/renderer/lib/radio/providerFactory.ts b/src/renderer/lib/radio/providerFactory.ts index 7525d6da1..693299c03 100644 --- a/src/renderer/lib/radio/providerFactory.ts +++ b/src/renderer/lib/radio/providerFactory.ts @@ -7,12 +7,13 @@ import { MESHTASTIC_CAPABILITIES } from './BaseRadioProvider'; export type { ProtocolCapabilities }; -function resolveCapabilities(protocol: MeshProtocol): ProtocolCapabilities { +/** Non-hook capability lookup for lib/hooks that cannot call `useRadioProvider`. */ +export function getRadioCapabilities(protocol: MeshProtocol): ProtocolCapabilities { const registration = getProtocolRegistration(protocol); if (registration) return registration.capabilities; if (process.env.NODE_ENV === 'development') { console.warn( - `[useRadioProvider] Unknown protocol "${protocol}", falling back to Meshtastic capabilities`, + `[getRadioCapabilities] Unknown protocol "${protocol}", falling back to Meshtastic capabilities`, ); } return MESHTASTIC_CAPABILITIES; @@ -23,5 +24,5 @@ function resolveCapabilities(protocol: MeshProtocol): ProtocolCapabilities { * Memoized on protocol identity — stable across renders unless protocol changes. */ export function useRadioProvider(protocol: MeshProtocol): ProtocolCapabilities { - return useMemo(() => resolveCapabilities(protocol), [protocol]); + return useMemo(() => getRadioCapabilities(protocol), [protocol]); } diff --git a/src/renderer/locales/cs/translation.json b/src/renderer/locales/cs/translation.json index 173880008..4a57e0df4 100644 --- a/src/renderer/locales/cs/translation.json +++ b/src/renderer/locales/cs/translation.json @@ -688,7 +688,8 @@ "reticulumSendTimeout": "Odeslání vypršelo. Zásobník Reticulum se možná spouští nebo je zaneprázdněný — zkuste to znovu.", "meshcoreFastSend": { "warning": "Odesíláte rychleji, než může síť přenést. Na zaneprázdněné síti mohou opakovače zahodit zprávy odeslané blízko sebe — mezi zprávami nechte několik sekund." - } + }, + "outboxLegacyMultipartBlocked": "Tato zpráva ve frontě používala vícedílné rozdělení MeshCore, které se již neodesílá. Zrušte jej a odešlete kratší zprávu (nebo několik samostatných kratších zpráv)." }, "chatPayload": { "mention": "Zmínit {{label}}", diff --git a/src/renderer/locales/de/translation.json b/src/renderer/locales/de/translation.json index 104627514..23843fba3 100644 --- a/src/renderer/locales/de/translation.json +++ b/src/renderer/locales/de/translation.json @@ -686,7 +686,8 @@ "reticulumSendTimeout": "Senden abgelaufen. Der Reticulum-Stack startet möglicherweise oder ist beschäftigt — bitte erneut versuchen.", "meshcoreFastSend": { "warning": "Sie senden schneller, als das Netz weiterleiten kann. In einem ausgelasteten Netz können Repeater Nachrichten, die nahe beieinander gesendet werden, ablegen — lassen Sie ein paar Sekunden zwischen den Nachrichten." - } + }, + "outboxLegacyMultipartBlocked": "Diese in der Warteschlange befindliche Nachricht verwendete eine mehrteilige MeshCore-Aufteilung, die nicht mehr gesendet wird. Brechen Sie es ab und senden Sie eine kürzere Nachricht (oder mehrere separate kürzere Nachrichten)." }, "chatPayload": { "mention": "Erwähne {{label}}", diff --git a/src/renderer/locales/en/translation.json b/src/renderer/locales/en/translation.json index ed119c051..4ab2a2bd9 100644 --- a/src/renderer/locales/en/translation.json +++ b/src/renderer/locales/en/translation.json @@ -659,6 +659,7 @@ "outboxStatusSending": "Sending…", "outboxStatusBlocked": "Blocked", "outboxStatusFailed": "Failed", + "outboxLegacyMultipartBlocked": "This queued message used multi-part MeshCore splitting, which is no longer sent. Cancel it and send a shorter message (or several separate shorter messages).", "retryOutboxMessage": "Retry outbox message", "retryOutbox": "Retry", "cancelOutboxMessage": "Cancel outbox message", diff --git a/src/renderer/locales/es/translation.json b/src/renderer/locales/es/translation.json index f6ada4ff9..3790adbf5 100644 --- a/src/renderer/locales/es/translation.json +++ b/src/renderer/locales/es/translation.json @@ -686,7 +686,8 @@ "reticulumSendTimeout": "El envío ha caducado. La pila Reticulum puede estar iniciándose o ocupada; inténtelo de nuevo.", "meshcoreFastSend": { "warning": "Estás enviando más rápido de lo que la malla puede transmitir. En una malla ocupada, los repetidores pueden descartar mensajes enviados muy juntos; deja unos segundos entre mensajes." - } + }, + "outboxLegacyMultipartBlocked": "Este mensaje en cola utilizó la división MeshCore de varias partes, que ya no se envía. Cancélelo y envíe un mensaje más corto (o varios mensajes más cortos por separado)." }, "chatPayload": { "mention": "Mencionar {{label}}", diff --git a/src/renderer/locales/fr/translation.json b/src/renderer/locales/fr/translation.json index 9029aa0c0..724d4606c 100644 --- a/src/renderer/locales/fr/translation.json +++ b/src/renderer/locales/fr/translation.json @@ -686,7 +686,8 @@ "reticulumSendTimeout": "Envoi expiré. La pile Reticulum est peut-être en cours de démarrage ou occupée — réessayez.", "meshcoreFastSend": { "warning": "Vous envoyez plus vite que le maillage ne peut relayer. Sur un maillage occupé, les répéteurs peuvent déposer des messages envoyés à proximité les uns des autres — laissez quelques secondes entre les messages." - } + }, + "outboxLegacyMultipartBlocked": "Ce message en file d'attente utilisait le fractionnement MeshCore en plusieurs parties, qui n'est plus envoyé. Annulez-le et envoyez un message plus court (ou plusieurs messages plus courts distincts)." }, "chatPayload": { "mention": "Mention {{label}}", diff --git a/src/renderer/locales/id/translation.json b/src/renderer/locales/id/translation.json index 79828d29e..67a9fca85 100644 --- a/src/renderer/locales/id/translation.json +++ b/src/renderer/locales/id/translation.json @@ -686,7 +686,8 @@ "reticulumSendTimeout": "Waktu pengiriman habis. Stack Reticulum mungkin sedang mulai atau sibuk — coba lagi.", "meshcoreFastSend": { "warning": "Anda mengirim lebih cepat daripada yang dapat disampaikan oleh mesh. Pada mesh yang sibuk, repeater dapat menjatuhkan pesan yang dikirim secara berdekatan — menyisakan beberapa detik di antara pesan." - } + }, + "outboxLegacyMultipartBlocked": "Pesan antrean ini menggunakan pemisahan MeshCore multi-bagian, yang tidak lagi terkirim. Batalkan dan kirim pesan singkat (atau beberapa pesan singkat terpisah)." }, "chatPayload": { "mention": "Sebutkan {{label}}", diff --git a/src/renderer/locales/it/translation.json b/src/renderer/locales/it/translation.json index d317be056..f4efb10df 100644 --- a/src/renderer/locales/it/translation.json +++ b/src/renderer/locales/it/translation.json @@ -686,7 +686,8 @@ "reticulumSendTimeout": "Timeout dell'invio. Lo stack Reticulum potrebbe essere in avvio o occupato. Riprova.", "meshcoreFastSend": { "warning": "Stai inviando più velocemente di quanto la rete possa trasmettere. Su una mesh occupata, i ripetitori possono rilasciare i messaggi inviati vicini — lasciare alcuni secondi tra i messaggi." - } + }, + "outboxLegacyMultipartBlocked": "Questo messaggio in coda utilizzava la suddivisione MeshCore in più parti, che non viene più inviata. Annullalo e invia un messaggio più breve (o più messaggi brevi separati)." }, "chatPayload": { "mention": "Menziona {{label}}", diff --git a/src/renderer/locales/ja/translation.json b/src/renderer/locales/ja/translation.json index 032a1481e..6576e7e94 100644 --- a/src/renderer/locales/ja/translation.json +++ b/src/renderer/locales/ja/translation.json @@ -686,7 +686,8 @@ "reticulumSendTimeout": "送信がタイムアウトしました。Reticulum スタックが起動中またはビジーの可能性があります — 再試行してください。", "meshcoreFastSend": { "warning": "メッシュが中継できるよりも速く送信しています。ビジーメッシュでは、リピーターが近くに送信されたメッセージをドロップすることがあります。メッセージの間に数秒残します。" - } + }, + "outboxLegacyMultipartBlocked": "このキューに入れられたメッセージでは、マルチパート MeshCore 分割が使用されていましたが、現在は送信されません。それをキャンセルして、より短いメッセージ (または複数の別個の短いメッセージ) を送信します。" }, "chatPayload": { "mention": "{{label}} について言及してください", diff --git a/src/renderer/locales/ko/translation.json b/src/renderer/locales/ko/translation.json index aee314dcd..aecb71630 100644 --- a/src/renderer/locales/ko/translation.json +++ b/src/renderer/locales/ko/translation.json @@ -686,7 +686,8 @@ "reticulumSendTimeout": "전송 시간이 초과되었습니다. Reticulum 스택이 시작 중이거나 사용 중일 수 있습니다 — 다시 시도하세요.", "meshcoreFastSend": { "warning": "메시가 릴레이할 수 있는 속도보다 빠르게 전송하고 있습니다. 바쁜 메시에서는 반복자가 서로 가깝게 보낸 메시지를 드롭할 수 있습니다. 메시지 사이에 몇 초를 남겨두세요." - } + }, + "outboxLegacyMultipartBlocked": "대기 중인 이 메시지는 더 이상 전송되지 않는 다중 부분 MeshCore 분할을 사용했습니다. 취소하고 더 ​​짧은 메시지(또는 여러 개의 개별 짧은 메시지)를 보내십시오." }, "chatPayload": { "mention": "{{label}}을(를) 언급하세요", diff --git a/src/renderer/locales/nl/translation.json b/src/renderer/locales/nl/translation.json index d0729be57..e64b174ab 100644 --- a/src/renderer/locales/nl/translation.json +++ b/src/renderer/locales/nl/translation.json @@ -686,7 +686,8 @@ "reticulumSendTimeout": "Time-out voor verzenden. De Reticulum-stack is mogelijk aan het starten of bezet — probeer het opnieuw.", "meshcoreFastSend": { "warning": "Je verzendt sneller dan de mesh kan doorgeven. Op een drukke mesh kunnen herhalers berichten die dicht bij elkaar zijn verzonden laten vallen — laat een paar seconden tussen berichten." - } + }, + "outboxLegacyMultipartBlocked": "Dit bericht in de wachtrij maakte gebruik van meerdelige MeshCore-splitsing, die niet langer wordt verzonden. Annuleer het en stuur een korter bericht (of meerdere afzonderlijke kortere berichten)." }, "chatPayload": { "mention": "Vermeld {{label}}", diff --git a/src/renderer/locales/pl/translation.json b/src/renderer/locales/pl/translation.json index 9d4923a88..2c7fe147c 100644 --- a/src/renderer/locales/pl/translation.json +++ b/src/renderer/locales/pl/translation.json @@ -690,7 +690,8 @@ "reticulumSendTimeout": "Przekroczono limit czasu wysyłania. Stos Reticulum może się uruchamiać lub być zajęty — spróbuj ponownie.", "meshcoreFastSend": { "warning": "Wysyłasz szybciej, niż siatka może przekazywać. Na zatłoczonej siatce wzmacniaki mogą odrzucać wiadomości wysyłane blisko siebie — pozostaw kilka sekund między wiadomościami." - } + }, + "outboxLegacyMultipartBlocked": "Ta wiadomość umieszczona w kolejce korzystała z wieloczęściowego podziału MeshCore, który nie jest już wysyłany. Anuluj i wyślij krótszą wiadomość (lub kilka oddzielnych krótszych wiadomości)." }, "chatPayload": { "mention": "Wspomnij o {{label}}", diff --git a/src/renderer/locales/pt-BR/translation.json b/src/renderer/locales/pt-BR/translation.json index 45561737d..5b77f8814 100644 --- a/src/renderer/locales/pt-BR/translation.json +++ b/src/renderer/locales/pt-BR/translation.json @@ -686,7 +686,8 @@ "reticulumSendTimeout": "O envio expirou. A pilha Reticulum pode estar iniciando ou ocupada — tente novamente.", "meshcoreFastSend": { "warning": "Você está enviando mais rápido do que a malha pode retransmitir. Em uma malha ocupada, os repetidores podem soltar mensagens enviadas juntas — deixe alguns segundos entre as mensagens." - } + }, + "outboxLegacyMultipartBlocked": "Esta mensagem na fila usava divisão MeshCore em várias partes, que não é mais enviada. Cancele e envie uma mensagem mais curta (ou várias mensagens mais curtas separadas)." }, "chatPayload": { "mention": "Mencionar {{label}}", diff --git a/src/renderer/locales/ru/translation.json b/src/renderer/locales/ru/translation.json index 4b2e9684e..da4472ccc 100644 --- a/src/renderer/locales/ru/translation.json +++ b/src/renderer/locales/ru/translation.json @@ -688,7 +688,8 @@ "reticulumSendTimeout": "Время ожидания отправки истекло. Стек Reticulum, возможно, запускается или занят — повторите попытку.", "meshcoreFastSend": { "warning": "Вы отправляете быстрее, чем сеть может ретранслировать. На загруженной сети повторители могут отбрасывать сообщения, отправленные близко друг к другу, — оставьте несколько секунд между сообщениями." - } + }, + "outboxLegacyMultipartBlocked": "В этом сообщении в очереди использовалось разделение MeshCore на несколько частей, которое больше не отправляется. Отмените его и отправьте более короткое сообщение (или несколько отдельных более коротких сообщений)." }, "chatPayload": { "mention": "Упоминание {{label}}", diff --git a/src/renderer/locales/tr/translation.json b/src/renderer/locales/tr/translation.json index bbed94531..10d990d9d 100644 --- a/src/renderer/locales/tr/translation.json +++ b/src/renderer/locales/tr/translation.json @@ -686,7 +686,8 @@ "reticulumSendTimeout": "Gönderim zaman aşımına uğradı. Reticulum yığını başlıyor veya meşgul olabilir — tekrar deneyin.", "meshcoreFastSend": { "warning": "Ağın aktarabileceğinden daha hızlı gönderiyorsun. Yoğun bir ağda tekrarlayıcılar birbirine yakın gönderilen mesajları bırakabilir; mesajlar arasında birkaç saniye bırakın." - } + }, + "outboxLegacyMultipartBlocked": "Bu sıraya alınmış mesaj, artık gönderilmeyen çok parçalı MeshCore bölmeyi kullandı. İptal edin ve daha kısa bir mesaj (veya birkaç ayrı kısa mesaj) gönderin." }, "chatPayload": { "mention": "{{label}}'dan bahsedin", diff --git a/src/renderer/locales/uk/translation.json b/src/renderer/locales/uk/translation.json index c737961a0..d3e8a7afd 100644 --- a/src/renderer/locales/uk/translation.json +++ b/src/renderer/locales/uk/translation.json @@ -688,7 +688,8 @@ "reticulumSendTimeout": "Тайм-аут надсилання. Стек Reticulum може запускатися або бути зайнятий — спробуйте ще раз.", "meshcoreFastSend": { "warning": "Ви надсилаєте швидше, ніж мережа може передавати. На завантаженій сіті повторювачі можуть відкидати повідомлення, надіслані близько один до одного, — залишайте кілька секунд між повідомленнями." - } + }, + "outboxLegacyMultipartBlocked": "Це повідомлення в черзі використовувало розділення MeshCore на кілька частин, яке більше не надсилається. Скасуйте його та надішліть коротше повідомлення (або кілька окремих коротших повідомлень)." }, "chatPayload": { "mention": "Згадайте {{label}}", diff --git a/src/renderer/locales/zh/translation.json b/src/renderer/locales/zh/translation.json index 37aa20a09..4700ec000 100644 --- a/src/renderer/locales/zh/translation.json +++ b/src/renderer/locales/zh/translation.json @@ -686,7 +686,8 @@ "reticulumSendTimeout": "发送超时。Reticulum 堆栈可能正在启动或忙碌—请重试。", "meshcoreFastSend": { "warning": "您发送的速度超过了网格可以中继的速度。在繁忙的网格上,中继器可能会丢弃一起发送的消息—在消息之间留几秒钟。" - } + }, + "outboxLegacyMultipartBlocked": "该排队消息使用了多部分MeshCore分裂,不再发送。取消它并发送一条较短的消息(或多个单独的较短消息)。" }, "chatPayload": { "mention": "提及{{label}}",