From 3548cb417679c64aa8477fd6cf5808b2ab255518 Mon Sep 17 00:00:00 2001 From: Joey Stanford Date: Fri, 14 Aug 2026 12:53:28 -0600 Subject: [PATCH 1/6] fix(meshcore): make repeater CLI send reliable and move radio settings Repeater CLI timed out or failed silently when pubkey lookup missed the contact map and waiting-message drain occupied the companion. Wait for drain before send, route CLI_DATA as CLI replies, explain firmware clock-sync refusal, and keep Open-wire/path-hash on Radio so App saves cannot overwrite them. --- docs/agents/meshcore-repeaters.md | 3 +- src/renderer/App.tsx | 25 ++-- src/renderer/components/AppPanel.test.tsx | 97 +------------ src/renderer/components/AppPanel.tsx | 110 +-------------- src/renderer/components/RadioPanel.test.tsx | 108 ++++++++++++++ src/renderer/components/RadioPanel.tsx | 133 ++++++++++++++++++ .../components/RepeatersPanel.test.tsx | 32 +++++ src/renderer/components/RepeatersPanel.tsx | 30 +++- .../hooks/meshcore/meshcoreConnSideEffects.ts | 3 +- .../lib/meshcore/meshcoreMessageI18n.test.ts | 20 +++ .../lib/meshcore/meshcoreMessageI18n.ts | 12 ++ src/renderer/lib/meshcorePathHashMode.test.ts | 11 +- src/renderer/lib/meshcorePathHashMode.ts | 8 +- .../meshcoreProcessWaitingMessageItem.test.ts | 51 ++++++- .../lib/meshcoreProcessWaitingMessageItem.ts | 43 +++++- .../meshcoreRepeaterCliWorkingState.test.ts | 34 ++++- .../lib/meshcoreWaitingMessagesDrain.test.ts | 30 ++++ .../lib/meshcoreWaitingMessagesDrain.ts | 18 +++ .../lib/repeaterCommandService.test.ts | 26 ++++ src/renderer/lib/repeaterCommandService.ts | 25 ++++ 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 +- src/renderer/runtime/useMeshcoreRuntime.ts | 102 +++++++++----- 37 files changed, 686 insertions(+), 281 deletions(-) diff --git a/docs/agents/meshcore-repeaters.md b/docs/agents/meshcore-repeaters.md index 096aa6332..20d160476 100644 --- a/docs/agents/meshcore-repeaters.md +++ b/docs/agents/meshcore-repeaters.md @@ -16,7 +16,8 @@ MeshCore firmware **serializes traceroutes** — one active trace cycle per RF l - **Login**: Optional for CLI/telemetry when password saved; Status/Neighbors do not require login RPC. **Room login** rejects immediately on prefix-matched LoginFail. **Repeater admin login** matches meshcore.js — LoginFail alone does not reject (congested links may emit LoginFail before LoginSuccess); timeout after LoginFail is reported as timeout, not wrong password. - **Repeater CLI danger**: destructive commands (`meshcoreRepeaterCliDanger.ts`) require confirm modal in Repeaters panel; runtime rejects unconfirmed sends (`meshcore.errors.cliDangerNotConfirmed`). Commands longer than **512** characters (`REPEATER_CLI_MAX_COMMAND_LENGTH`) are rejected before send. Multi-hop CLI auto-pings once per session when no trace exists (`RepeatersPanel` → `onPing`); CLI aborts when ping does not produce a trace result. Safe quick pills include `clock`, `clock sync`, `clear stats`, `advert`, `board` (firmware CLI tokens as labels). - **Per-repeater passwords:** shared factory `meshcorePerNodeCredentialStorage.ts` with `meshcoreRepeaterCredentialStorage.ts` / `meshcoreRoomCredentialStorage.ts` (`meshcoreRepeaterCredential:` and room keys in `app_settings` via IPC), `useMeshcoreRepeaterRemoteAuth.tsx`, `MeshcoreRepeaterPasswordControls.tsx`; Repeaters sidebar **Saved repeater passwords** + Forget (parallel to Rooms). -- **Waiting-message drain:** event 131 → `meshcoreWaitingMessagesDrain.ts` / `meshcoreProcessWaitingMessageItem.ts`; silent auto-drain vs manual **Sync now** (`MeshcoreWaitingMessagesHeaderIndicator.tsx` in the App header via `meshcoreWaitingMessagesStatusText.ts`; **queued backlog visible on any protocol tab**; **active sync spinner and paused/deferred** state only on the MeshCore tab); defers during TraceData/admin RPC. Silent bulk `getWaitingMessages` circuit-opens after **2** consecutive timeouts (`MESHCORE_WAITING_MESSAGES_SILENT_BULK_TIMEOUT_TRIP`) and skips bulk until reconnect/success; disconnect bumps the attempt id so a late timeout cannot trip the next connection. +- **Waiting-message drain:** event 131 → `meshcoreWaitingMessagesDrain.ts` / `meshcoreProcessWaitingMessageItem.ts`; silent auto-drain vs manual **Sync now** (`MeshcoreWaitingMessagesHeaderIndicator.tsx` in the App header via `meshcoreWaitingMessagesStatusText.ts`; **queued backlog visible on any protocol tab**; **active sync spinner and paused/deferred** state only on the MeshCore tab); defers during TraceData/admin RPC. Silent bulk `getWaitingMessages` circuit-opens after **2** consecutive timeouts (`MESHCORE_WAITING_MESSAGES_SILENT_BULK_TIMEOUT_TRIP`) and skips bulk until reconnect/success; disconnect bumps the attempt id so a late timeout cannot trip the next connection. Repeater CLI waits for drain idle **before** `runMeshcoreRepeaterRpcOnce` (waiting inside the once slot deadlocks because in-flight defers drain). CLI_DATA waiting messages dispatch as CLI responses, not chat. If drain is busy at start or SENT, CLI timeout is padded by `MESHCORE_WAITING_MESSAGES_SILENT_TIMEOUT_MS` (45s), capped at `REPEATER_CLI_MAX_TIMEOUT_MS`. +- **Repeater CLI clock:** `clock` / `clock sync` are firmware tokens. `clock sync` uses the companion/computer UTC unix timestamp; MeshCore firmware **refuses to set time backwards** (`ERR: clock cannot go backwards`). Do not auto-`clkreboot`; toast `repeatersPanel.cliClockCannotGoBackwards` and leave `clkreboot` behind the danger confirm. Optional companion `syncDeviceTime()` runs before repeater `clock sync`. - **Cross-traffic**: Room sync/auto-login defer while `meshcoreCompanionRepeaterRfBusy()`; waiting-messages drain defers during TraceData. Do not change behavior guarded by `meshcoreZeroHopRepeaterWorkingState.test.ts` without explicit user request. See [../meshcore-meshtastic-parity.md](../meshcore-meshtastic-parity.md#serialized-traceroutes-protocol-requirement). diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index f556d9802..cc5bf4ce3 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -3763,6 +3763,16 @@ function AppContent() { ? meshcorePanelActions.syncClock : undefined } + deviceReportedPathHashMode={ + capabilities.hasCompanionContactManagementConfig + ? (meshcoreRuntime.state.pathHashMode ?? null) + : null + } + onApplyMeshcorePathHashMode={ + capabilities.hasCompanionContactManagementConfig + ? meshcorePanelActions.applyMeshcorePathHashMode + : undefined + } onRefreshContacts={ capabilities.hasContactImportExport ? meshcorePanelActions.refreshContacts @@ -4145,21 +4155,6 @@ function AppContent() { onAlwaysShowMessageActionsChange={ handleAlwaysShowMessageActionsChange } - deviceReportedPathHashMode={ - capabilities.modulesTabUsesRepeatersLabel - ? (meshcoreRuntime.state.pathHashMode ?? null) - : null - } - isMeshcoreRadioConnected={ - capabilities.modulesTabUsesRepeatersLabel && - (meshcoreRuntime.state.status === 'connected' || - meshcoreRuntime.state.status === 'configured') - } - onApplyMeshcorePathHashMode={ - capabilities.modulesTabUsesRepeatersLabel - ? meshcorePanelActions.applyMeshcorePathHashMode - : undefined - } reticulumIdentityId={reticulumIdentityId} reticulumSidecarReady={ reticulumRuntime.state.status !== 'disconnected' diff --git a/src/renderer/components/AppPanel.test.tsx b/src/renderer/components/AppPanel.test.tsx index a89f6709e..f082ecb31 100644 --- a/src/renderer/components/AppPanel.test.tsx +++ b/src/renderer/components/AppPanel.test.tsx @@ -274,7 +274,7 @@ describe('AppPanel: RRC unread all room messages toggle', () => { }); }); -describe('AppPanel: MeshCore path hash mode persist', () => { +describe('AppPanel: MeshCore Radio-owned settings are not on App', () => { const defaultProps = { nodeCount: 0, messageCount: 0, @@ -287,13 +287,13 @@ describe('AppPanel: MeshCore path hash mode persist', () => { localStorage.removeItem('mesh-client:appSettings'); }); - it('does not stamp default meshcorePathHashMode into app settings on mount', async () => { + it('does not stamp meshcorePathHashMode or Open-wire into app settings on mount', async () => { render( , ); - await screen.findByLabelText(/Default path hash size/i); + await screen.findByText('App Settings'); await waitFor( () => { const raw = localStorage.getItem('mesh-client:appSettings'); @@ -303,103 +303,20 @@ describe('AppPanel: MeshCore path hash mode persist', () => { ); const raw = localStorage.getItem('mesh-client:appSettings'); expect(raw).not.toContain('meshcorePathHashMode'); + expect(raw).not.toContain('meshcoreOpenWireCompatEnabled'); }); - it('persists meshcorePathHashMode when the user changes the dropdown', async () => { + it('does not show Open-wire or path-hash controls on App', async () => { render( , ); - const select = await screen.findByLabelText(/Default path hash size/i); - fireEvent.change(select, { target: { value: '1' } }); - await waitFor(() => { - const raw = localStorage.getItem('mesh-client:appSettings'); - expect(raw).toContain('"meshcorePathHashMode":1'); - }); - }); - - it('syncs dropdown from device-reported mode when user has not changed it', async () => { - const { rerender } = render( - - - , - ); - const select = await screen.findByLabelText(/Default path hash size/i); - expect(select).toHaveValue('0'); - - rerender( - - - , - ); - await waitFor(() => { - expect(screen.getByLabelText(/Default path hash size/i)).toHaveValue('1'); - }); - }); -}); - -describe('AppPanel: MeshCore Open wire toggle', () => { - const defaultProps = { - nodeCount: 0, - messageCount: 0, - channels: [] as { index: number; name: string }[], - myNodeNum: null as number | null, - onLocationFilterChange: vi.fn(), - }; - - beforeEach(() => { - localStorage.removeItem('mesh-client:appSettings'); - }); - - it('shows Open wire toggle only on MeshCore protocol tab', async () => { - const { unmount } = render( - - - , - ); + await screen.findByText('App Settings'); expect( screen.queryByRole('checkbox', { name: /Enable MeshCore Open compatibility/i }), ).toBeNull(); - unmount(); - - render( - - - , - ); - const checkbox = await screen.findByRole('checkbox', { - name: /Enable MeshCore Open compatibility/i, - }); - expect(checkbox).not.toBeChecked(); - }); - - it('persists meshcoreOpenWireCompatEnabled to app settings', async () => { - render( - - - , - ); - const checkbox = await screen.findByRole('checkbox', { - name: /Enable MeshCore Open compatibility/i, - }); - act(() => { - fireEvent.click(checkbox); - }); - await waitFor(() => { - const raw = localStorage.getItem('mesh-client:appSettings'); - expect(raw).toContain('"meshcoreOpenWireCompatEnabled":true'); - }); + expect(screen.queryByLabelText(/Default path hash size/i)).toBeNull(); }); }); diff --git a/src/renderer/components/AppPanel.tsx b/src/renderer/components/AppPanel.tsx index c0b125abf..d0ce22c3e 100644 --- a/src/renderer/components/AppPanel.tsx +++ b/src/renderer/components/AppPanel.tsx @@ -8,7 +8,6 @@ import { exportSupportBundleToDisk } from '@/renderer/lib/exportSupportBundle'; import type { MessageClearRefreshOptions } from '@/renderer/lib/hydrateIdentityStoresFromDb'; import { DetailsChevron } from '@/renderer/lib/icons/detailsChevron'; import { parseDatabaseSchemaTooNewFromMessage } from '@/shared/databaseSchemaTooNew'; -import { isMeshcorePathHashMode } from '@/shared/meshcorePathHash'; import type { SupportBundleMode } from '@/shared/support-bundle.types'; import type { LocationFilter } from '../App'; @@ -216,9 +215,6 @@ interface Props { onAutoFloodAdvertTypeChange?: (type: 'flood' | 'zeroHop') => void; onChatCompactModeChange?: (compact: boolean) => void; onAlwaysShowMessageActionsChange?: (alwaysShow: boolean) => void; - deviceReportedPathHashMode?: 0 | 1 | 2 | null; - isMeshcoreRadioConnected?: boolean; - onApplyMeshcorePathHashMode?: (mode: 0 | 1 | 2) => Promise; /** Reticulum LXMF identity for DM-only message clear in Danger Zone. */ reticulumIdentityId?: string | null; reticulumSidecarReady?: boolean; @@ -255,9 +251,6 @@ export default function AppPanel({ onAutoFloodAdvertTypeChange, onChatCompactModeChange, onAlwaysShowMessageActionsChange, - deviceReportedPathHashMode, - isMeshcoreRadioConnected = false, - onApplyMeshcorePathHashMode, reticulumIdentityId = null, reticulumSidecarReady = false, }: Props) { @@ -309,7 +302,6 @@ export default function AppPanel({ // ─── Node retention settings ──────────────────────────────── const [settings, setSettings] = useState(loadSettings); - const pathHashModeUserChangedRef = useRef(false); const [themeColors, setThemeColors] = useState>(loadThemeColors); const [hasSavedThemeSnapshot, setHasSavedThemeSnapshot] = useState(hasThemeSnapshot); const [messageActionsBarBgVisible, setMessageActionsBarBgVisibleState] = useState( @@ -397,10 +389,7 @@ export default function AppPanel({ if (saveTimerRef.current) clearTimeout(saveTimerRef.current); saveTimerRef.current = setTimeout(() => { mergeAppSettingsPartial( - appPanelSettingsPersistPayload( - settings as unknown as Record, - pathHashModeUserChangedRef.current, - ), + appPanelSettingsPersistPayload(settings as unknown as Record), 'AppPanel saveSettings', ); }, 300); @@ -409,17 +398,6 @@ export default function AppPanel({ }; }, [settings]); - // Keep dropdown aligned with companion when connect adopts radio mode into settings. - useEffect(() => { - if (!isMeshcorePathHashMode(deviceReportedPathHashMode)) return; - if (pathHashModeUserChangedRef.current) return; - setSettings((prev) => - prev.meshcorePathHashMode === deviceReportedPathHashMode - ? prev - : { ...prev, meshcorePathHashMode: deviceReportedPathHashMode }, - ); - }, [deviceReportedPathHashMode]); - useEffect(() => { onLocationFilterChange({ enabled: settings.distanceFilterEnabled, @@ -1428,92 +1406,6 @@ export default function AppPanel({ )} - {/* MeshCore Open wire compatibility (experimental) */} - {protocol === 'meshcore' && ( -
-

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

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

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

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

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

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

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

- ) : null} -

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

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

diff --git a/src/renderer/components/RadioPanel.test.tsx b/src/renderer/components/RadioPanel.test.tsx index 1f6dc4099..5c4537dae 100644 --- a/src/renderer/components/RadioPanel.test.tsx +++ b/src/renderer/components/RadioPanel.test.tsx @@ -529,3 +529,111 @@ describe('RadioPanel collapsible section consistency', () => { }); }); }); + +describe('RadioPanel MeshCore Open wire and path hash', () => { + beforeEach(() => { + localStorage.removeItem('mesh-client:appSettings'); + }); + + it('shows Open-wire and path-hash controls for MeshCore capabilities', () => { + render( + + + , + ); + expect( + screen.getByRole('checkbox', { name: /Enable MeshCore Open compatibility/i }), + ).not.toBeChecked(); + expect(screen.getByLabelText(/Default path hash size/i)).toHaveValue('0'); + }); + + it('does not show Open-wire or path-hash without MeshCore capabilities', () => { + render( + + + , + ); + expect( + screen.queryByRole('checkbox', { name: /Enable MeshCore Open compatibility/i }), + ).toBeNull(); + expect(screen.queryByLabelText(/Default path hash size/i)).toBeNull(); + }); + + it('persists meshcoreOpenWireCompatEnabled to app settings', async () => { + render( + + + , + ); + const checkbox = screen.getByRole('checkbox', { + name: /Enable MeshCore Open compatibility/i, + }); + fireEvent.click(checkbox); + await waitFor(() => { + const raw = localStorage.getItem('mesh-client:appSettings'); + expect(raw).toContain('"meshcoreOpenWireCompatEnabled":true'); + }); + }); + + it('persists meshcorePathHashMode when the user changes the dropdown', async () => { + render( + + + , + ); + const select = screen.getByLabelText(/Default path hash size/i); + fireEvent.change(select, { target: { value: '1' } }); + await waitFor(() => { + const raw = localStorage.getItem('mesh-client:appSettings'); + expect(raw).toContain('"meshcorePathHashMode":1'); + }); + }); + + it('syncs dropdown from device-reported mode when user has not changed it', async () => { + const { rerender } = render( + + + , + ); + expect(screen.getByLabelText(/Default path hash size/i)).toHaveValue('0'); + + rerender( + + + , + ); + await waitFor(() => { + expect(screen.getByLabelText(/Default path hash size/i)).toHaveValue('1'); + }); + }); + + it('applies path hash mode to the radio when connected', async () => { + const onApplyMeshcorePathHashMode = vi.fn().mockResolvedValue(undefined); + render( + + + , + ); + fireEvent.change(screen.getByLabelText(/Default path hash size/i), { + target: { value: '2' }, + }); + await waitFor(() => { + expect(onApplyMeshcorePathHashMode).toHaveBeenCalledWith(2); + }); + }); +}); diff --git a/src/renderer/components/RadioPanel.tsx b/src/renderer/components/RadioPanel.tsx index 3d38b17af..84757e6e6 100644 --- a/src/renderer/components/RadioPanel.tsx +++ b/src/renderer/components/RadioPanel.tsx @@ -21,6 +21,7 @@ import { buildMeshcoreChannelAddUri, classifyMeshClientDeepLink, } from '@/shared/meshClientDeepLink'; +import { isMeshcorePathHashMode, type MeshcorePathHashMode } from '@/shared/meshcorePathHash'; import { formatMeshtasticBluetoothPin, parseMeshtasticBluetoothPin, @@ -42,6 +43,8 @@ import { useMeshcoreContactCapacity, } from '../hooks/useMeshcoreContactCapacity'; import { useSyncFormFromConfig } from '../hooks/useSyncFormFromConfig'; +import { getAppSettingsRaw, mergeAppSetting } from '../lib/appSettingsStorage'; +import { DEFAULT_APP_SETTINGS_SHARED } from '../lib/defaultAppSettings'; import type { OurPosition } from '../lib/gpsSource'; import type { MeshCoreContactRaw, MeshCoreSelfInfo } from '../lib/meshcore/meshcoreHookTypes'; import type { MeshcoreAutoaddWireState } from '../lib/meshcoreContactAutoAdd'; @@ -61,6 +64,7 @@ import { meshcoreSelfInfoBwToDisplayKhz, meshcoreSelfInfoFreqToDisplayHz, } from '../lib/meshcoreUtils'; +import { parseStoredJson } from '../lib/parseStoredJson'; import type { ProtocolCapabilities } from '../lib/radio/BaseRadioProvider'; import type { ConfigTargetContext, RemoteConfigChannelsTailStatus } from '../lib/types'; import { ConfigApplyNotice } from './ConfigApplyNotice'; @@ -96,6 +100,25 @@ function numericArray(value: unknown): number[] | null { return value; } +function loadMeshcoreRadioExperimentalSettings(): { + meshcoreOpenWireCompatEnabled: boolean; + meshcorePathHashMode: MeshcorePathHashMode; +} { + const parsed = parseStoredJson<{ + meshcoreOpenWireCompatEnabled?: boolean; + meshcorePathHashMode?: unknown; + }>(getAppSettingsRaw(), 'RadioPanel meshcore experimental'); + const mode = parsed?.meshcorePathHashMode; + return { + meshcoreOpenWireCompatEnabled: + parsed?.meshcoreOpenWireCompatEnabled ?? + DEFAULT_APP_SETTINGS_SHARED.meshcoreOpenWireCompatEnabled, + meshcorePathHashMode: isMeshcorePathHashMode(mode) + ? mode + : DEFAULT_APP_SETTINGS_SHARED.meshcorePathHashMode, + }; +} + interface Props { configTarget?: ConfigTargetContext; onSetConfig: (config: unknown) => Promise; @@ -175,6 +198,8 @@ interface Props { onXmodemUpload?: () => Promise; onXmodemDownload?: (filename: string) => Promise; onSyncClock?: () => Promise; + deviceReportedPathHashMode?: MeshcorePathHashMode | null; + onApplyMeshcorePathHashMode?: (mode: MeshcorePathHashMode) => Promise; onRefreshContacts?: () => Promise; onOffloadContactsFromRadio?: () => Promise; /** Remote admin: channel indices that failed to load from the target node. */ @@ -689,6 +714,8 @@ export default function RadioPanel({ onXmodemUpload, onXmodemDownload, onSyncClock, + deviceReportedPathHashMode = null, + onApplyMeshcorePathHashMode, onRefreshContacts, onOffloadContactsFromRadio, remoteChannelFailedIndices, @@ -1036,6 +1063,21 @@ export default function RadioPanel({ const [advertLoading, setAdvertLoading] = useState(false); const [zeroHopAdvertLoading, setZeroHopAdvertLoading] = useState(false); const [syncClockLoading, setSyncClockLoading] = useState(false); + const pathHashModeUserChangedRef = useRef(false); + const [meshcoreOpenWireCompatEnabled, setMeshcoreOpenWireCompatEnabled] = useState( + () => loadMeshcoreRadioExperimentalSettings().meshcoreOpenWireCompatEnabled, + ); + const [meshcorePathHashMode, setMeshcorePathHashMode] = useState( + () => loadMeshcoreRadioExperimentalSettings().meshcorePathHashMode, + ); + + useEffect(() => { + if (!isMeshcorePathHashMode(deviceReportedPathHashMode)) return; + if (pathHashModeUserChangedRef.current) return; + setMeshcorePathHashMode((prev) => + prev === deviceReportedPathHashMode ? prev : deviceReportedPathHashMode, + ); + }, [deviceReportedPathHashMode]); const disabled = !isConnected || (configTarget?.mode === 'remote' && !configTarget.isReady); const loraDisabled = @@ -2687,6 +2729,97 @@ export default function RadioPanel({

)} + + {capabilities?.hasCompanionContactManagementConfig && ( + <> +
+

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

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

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

+
+
+
+

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

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

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

+ ) : null} +

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

+
+
+ + )} ); } diff --git a/src/renderer/components/RepeatersPanel.test.tsx b/src/renderer/components/RepeatersPanel.test.tsx index 02c6a46cb..a4a41b800 100644 --- a/src/renderer/components/RepeatersPanel.test.tsx +++ b/src/renderer/components/RepeatersPanel.test.tsx @@ -455,6 +455,23 @@ describe('RepeatersPanel', () => { expect(onSendCliCommand).toHaveBeenCalledWith(repeater.node_id, 'name', undefined); }); + it('shows translated CLI error in the expanded CLI panel', async () => { + render( + , + ); + + await userEvent.click( + screen.getByRole('button', { name: /CLI: Node not found \(no encryption key\)/i }), + ); + expect(screen.getByText('Node not found (no encryption key)')).toBeInTheDocument(); + }); + it('calls onSendCliCommand when a quick command button is clicked', async () => { const onSendCliCommand = vi.fn().mockResolvedValue('ok'); render(); @@ -495,6 +512,21 @@ describe('RepeatersPanel', () => { expect(onSendCliCommand).toHaveBeenCalledWith(repeater.node_id, 'clock sync', undefined); }); + it('toasts when clock sync is refused because the repeater clock cannot go backwards', async () => { + const onSendCliCommand = vi.fn().mockResolvedValue('02|ERR: clock cannot go backwards'); + render(); + + await userEvent.click(screen.getByRole('button', { name: 'CLI interface' })); + await userEvent.click(screen.getByRole('button', { name: 'clock sync' })); + + await waitFor(() => { + expect(mockAddToast).toHaveBeenCalledWith( + expect.stringMatching(/clock is ahead of this computer/i), + 'info', + ); + }); + }); + it('requires confirmation before sending destructive CLI commands', async () => { const onSendCliCommand = vi.fn().mockResolvedValue('ok'); render(); diff --git a/src/renderer/components/RepeatersPanel.tsx b/src/renderer/components/RepeatersPanel.tsx index 336091382..f45e2e890 100644 --- a/src/renderer/components/RepeatersPanel.tsx +++ b/src/renderer/components/RepeatersPanel.tsx @@ -20,6 +20,7 @@ import type { import { meshcoreRepeaterAdminErrorMessage, translateMeshcoreUserMessage, + translateRepeaterCliHistoryText, } from '../lib/meshcore/meshcoreMessageI18n'; import { buildMeshcorePathChainSegments, @@ -98,6 +99,14 @@ function isMeshcoreNeighborsHopBlocked(node: MeshNode): boolean { return hops != null && hops >= MESHCORE_NEIGHBORS_MAX_RECOMMENDED_HOPS; } +function formatComputerUtcStamp(d = new Date()): string { + return `${d.toISOString().slice(0, 16).replace('T', ' ')} UTC`; +} + +function isRepeaterCliClockCannotGoBackwards(command: string, response: string): boolean { + return command.trim().toLowerCase() === 'clock sync' && /cannot go backwards/i.test(response); +} + function effectiveRepeaterLastAdvert( dbAdvert: number | null | undefined, nodeLastHeard: number | undefined, @@ -616,7 +625,9 @@ export default function RepeatersPanel({ command: string, opts?: { confirmedDanger?: boolean }, ) => { - if (!onSendCliCommand || !command.trim()) return; + if (!onSendCliCommand || !command.trim()) { + return; + } const node = nodes.get(nodeId); const auth = await ensureRepeaterAuth( nodeId, @@ -625,9 +636,16 @@ export default function RepeatersPanel({ ); if (!auth.ok) return; if (auth.saved) refreshStoredRepeaters(); - if (!(await ensureCliRoutePrimed(nodeId))) return; + const primed = await ensureCliRoutePrimed(nodeId); + if (!primed) return; try { - await onSendCliCommand(nodeId, command.trim(), opts); + const response = await onSendCliCommand(nodeId, command.trim(), opts); + if (isRepeaterCliClockCannotGoBackwards(command, response)) { + addToast( + t('repeatersPanel.cliClockCannotGoBackwards', { utc: formatComputerUtcStamp() }), + 'info', + ); + } } catch (e) { console.warn('[RepeatersPanel] CLI command error ' + errLikeToLogString(e)); } @@ -1617,6 +1635,9 @@ export default function RepeatersPanel({ {t('repeatersPanel.cliMultiHopHint')}

) : null} + {cliErrorText ? ( +

{cliErrorText}

+ ) : null}
)) )} diff --git a/src/renderer/hooks/meshcore/meshcoreConnSideEffects.ts b/src/renderer/hooks/meshcore/meshcoreConnSideEffects.ts index 4f27c03ff..900f1e3d2 100644 --- a/src/renderer/hooks/meshcore/meshcoreConnSideEffects.ts +++ b/src/renderer/hooks/meshcore/meshcoreConnSideEffects.ts @@ -695,8 +695,9 @@ export function attachMeshcoreConnSideEffects( ) => { const senderId = payload.senderNodeId; const service = repeaterCommandServiceRef.current; + const handled = service ? service.handleResponse(payload.text, senderId) : false; if (service) { - if (service.handleResponse(payload.text, senderId)) return; + if (handled) return; } else { console.warn( '[meshcoreConnSideEffects] CLI response received but no command service active (sender:', diff --git a/src/renderer/lib/meshcore/meshcoreMessageI18n.test.ts b/src/renderer/lib/meshcore/meshcoreMessageI18n.test.ts index 3f8a8852d..c5a0c8d66 100644 --- a/src/renderer/lib/meshcore/meshcoreMessageI18n.test.ts +++ b/src/renderer/lib/meshcore/meshcoreMessageI18n.test.ts @@ -12,6 +12,7 @@ import { meshcoreRepeaterRpcErrorMessage, meshcoreStoredUserMessage, meshcoreUserMessageKey, + translateRepeaterCliHistoryText, } from './meshcoreMessageI18n'; describe('meshcoreMessageI18n', () => { @@ -75,4 +76,23 @@ describe('meshcoreMessageI18n', () => { hintKey: MESHCORE_REPEATER_AUTH_HINT_KEY, }); }); + + it('translateRepeaterCliHistoryText translates serialized error lines', () => { + const t = ((key: string, params?: { seconds?: number }) => { + if (key === 'meshcore.errors.requestTimedOutApprox') { + return `Request timed out (~${params?.seconds ?? '?'}s)`; + } + return key; + }) as TFunction; + const stored = meshcoreStoredUserMessage( + meshcoreRepeaterRpcErrorMessage('CLI command timed out after 30000ms', 30_000), + ); + expect(translateRepeaterCliHistoryText(t, 'received', `[Error: ${stored}]`)).toBe( + '[Error: Request timed out (~30s)]', + ); + expect(translateRepeaterCliHistoryText(t, 'sent', 'clock')).toBe('clock'); + expect(translateRepeaterCliHistoryText(t, 'received', '01:46 - 17/8/2026 UTC')).toBe( + '01:46 - 17/8/2026 UTC', + ); + }); }); diff --git a/src/renderer/lib/meshcore/meshcoreMessageI18n.ts b/src/renderer/lib/meshcore/meshcoreMessageI18n.ts index c1be1bc25..8f9609bde 100644 --- a/src/renderer/lib/meshcore/meshcoreMessageI18n.ts +++ b/src/renderer/lib/meshcore/meshcoreMessageI18n.ts @@ -173,3 +173,15 @@ export function meshcoreRepeaterRpcErrorMessage( } return { key: MESHCORE_ERR_REQUEST_FAILED, params: { detail: errMsg } }; } + +/** Translate `[Error: ]` CLI history lines; pass firmware text through. */ +export function translateRepeaterCliHistoryText( + t: TFunction, + type: 'sent' | 'received', + text: string, +): string { + if (type !== 'received') return text; + const match = /^\[Error: (.*)\]$/s.exec(text); + if (!match) return text; + return `[Error: ${translateMeshcoreUserMessage(t, match[1])}]`; +} diff --git a/src/renderer/lib/meshcorePathHashMode.test.ts b/src/renderer/lib/meshcorePathHashMode.test.ts index 5d4f6b490..89fef1c09 100644 --- a/src/renderer/lib/meshcorePathHashMode.test.ts +++ b/src/renderer/lib/meshcorePathHashMode.test.ts @@ -12,10 +12,13 @@ describe('meshcorePathHashMode', () => { expect(Array.from(buildSetPathHashModeFrame(1))).toEqual([61, 0, 1]); }); - it('omits meshcorePathHashMode from AppPanel persist unless user changed it', () => { - const settings = { chatCompactMode: true, meshcorePathHashMode: 0 }; - expect(appPanelSettingsPersistPayload(settings, false)).toEqual({ chatCompactMode: true }); - expect(appPanelSettingsPersistPayload(settings, true)).toEqual(settings); + it('omits meshcorePathHashMode and Open-wire from AppPanel persist', () => { + const settings = { + chatCompactMode: true, + meshcorePathHashMode: 0, + meshcoreOpenWireCompatEnabled: true, + }; + expect(appPanelSettingsPersistPayload(settings)).toEqual({ chatCompactMode: true }); }); it('parses pathHashMode from deviceQuery payload', () => { diff --git a/src/renderer/lib/meshcorePathHashMode.ts b/src/renderer/lib/meshcorePathHashMode.ts index fdfb9caa7..d25eec05f 100644 --- a/src/renderer/lib/meshcorePathHashMode.ts +++ b/src/renderer/lib/meshcorePathHashMode.ts @@ -63,17 +63,15 @@ export interface MeshcoreDeviceQueryPathHashFields { } /** - * AppPanel auto-saves the full settings object (defaults merged in). Omit path hash mode - * unless the user explicitly changed the dropdown this session — otherwise a visit to App - * stamps mode 0 into localStorage and later connect logic used to fight the radio. + * AppPanel auto-saves the full settings object (defaults merged in). Path hash mode and + * Open-wire live on Radio — omit them so an App visit cannot overwrite RadioPanel writes. */ export function appPanelSettingsPersistPayload( settings: Record, - userChangedPathHashMode: boolean, ): Record { - if (userChangedPathHashMode) return { ...settings }; const rest = { ...settings }; delete rest.meshcorePathHashMode; + delete rest.meshcoreOpenWireCompatEnabled; return rest; } diff --git a/src/renderer/lib/meshcoreProcessWaitingMessageItem.test.ts b/src/renderer/lib/meshcoreProcessWaitingMessageItem.test.ts index fb13a8469..917aedc79 100644 --- a/src/renderer/lib/meshcoreProcessWaitingMessageItem.test.ts +++ b/src/renderer/lib/meshcoreProcessWaitingMessageItem.test.ts @@ -1,7 +1,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { packetRouter } from './drivers/PacketRouter'; -import { MESHCORE_TXT_TYPE_SIGNED_PLAIN } from './meshcoreChannelText'; +import { MESHCORE_TXT_TYPE_CLI_DATA, MESHCORE_TXT_TYPE_SIGNED_PLAIN } from './meshcoreChannelText'; import { processMeshcoreWaitingMessageItem } from './meshcoreProcessWaitingMessageItem'; import * as meshcoreRoomSyncStorage from './meshcoreRoomSyncStorage'; import { pubkeyToNodeId } from './meshcoreUtils'; @@ -114,6 +114,55 @@ describe('processMeshcoreWaitingMessageItem', () => { expect(deps.workingNodes.get(senderId)?.last_heard).toBe(1_700_000_100); }); + it('dispatches repeater CLI_DATA through PacketRouter instead of chat history', () => { + const dispatchSpy = vi.spyOn(packetRouter, 'dispatch').mockImplementation(() => {}); + const pubKey = makePubKey(12); + const prefixBytes = pubKey.slice(0, 6); + const senderId = pubkeyToNodeId(pubKey); + const deps = baseDeps({ + pubKeyPrefixMap: new Map([[prefixHexFromBytes(prefixBytes), senderId]]), + }); + deps.workingNodes.set(senderId, { + node_id: senderId, + long_name: 'RPT', + short_name: '', + hw_model: 'Repeater', + snr: 0, + rssi: 0, + last_heard: 0, + battery: 0, + latitude: null, + longitude: null, + }); + + const result = processMeshcoreWaitingMessageItem( + { + contactMessage: { + pubKeyPrefix: prefixBytes, + text: 'A1|uptime 42', + senderTimestamp: 1_700_000_200, + txtType: MESHCORE_TXT_TYPE_CLI_DATA, + }, + }, + deps, + ); + + expect(result.pendingMessages).toHaveLength(0); + expect(result.roomDispatched).toBe(false); + expect(dispatchSpy).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'meshcore_cli_response', + payload: expect.objectContaining({ + text: 'A1|uptime 42', + senderNodeId: senderId, + }), + }), + 'meshcore-test-id', + ); + expect(deps.workingNodes.get(senderId)?.last_heard).toBe(1_700_000_200); + dispatchSpy.mockRestore(); + }); + it('warns and skips ingest for unknown pubKeyPrefix (senderId 0)', () => { const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); const pubKey = makePubKey(99); diff --git a/src/renderer/lib/meshcoreProcessWaitingMessageItem.ts b/src/renderer/lib/meshcoreProcessWaitingMessageItem.ts index c482f38d7..63c030b6d 100644 --- a/src/renderer/lib/meshcoreProcessWaitingMessageItem.ts +++ b/src/renderer/lib/meshcoreProcessWaitingMessageItem.ts @@ -1,6 +1,7 @@ import { packetRouter } from '@/renderer/lib/drivers/PacketRouter'; import { errLikeToLogString } from '@/renderer/lib/errLikeToLogString'; import { + MESHCORE_TXT_TYPE_CLI_DATA, parseMeshcoreChannelIncomingFromThread, parseMeshcoreDmIncomingFromThread, resolveMeshcoreChannelMessageSender, @@ -49,7 +50,47 @@ export function processMeshcoreWaitingMessageItem( .map((b) => b.toString(16).padStart(2, '0')) .join(''); const senderId = deps.pubKeyPrefixMap.get(prefix) ?? 0; - if (senderId === 0) { + if (d.txtType === MESHCORE_TXT_TYPE_CLI_DATA) { + if (senderId !== 0) { + const sender = deps.workingNodes.get(senderId); + if (sender) { + deps.workingNodes.set(senderId, { + ...sender, + last_heard: Math.max(sender.last_heard, d.senderTimestamp), + }); + nodesDirty = true; + updatedNodeIds.push(senderId); + } + } + const identityId = deps.meshcoreIdentityId; + if (identityId) { + const roomNodeIds = new Set(); + for (const [nodeId, node] of deps.workingNodes) { + if (node.hw_model === 'Room') roomNodeIds.add(nodeId); + } + dispatchMeshcoreWaitingContactMessage( + identityId, + { + pubKeyPrefix: d.pubKeyPrefix, + text: d.text, + senderTimestamp: d.senderTimestamp, + txtType: MESHCORE_TXT_TYPE_CLI_DATA, + ...(d.pathLen != null ? { pathLen: d.pathLen } : {}), + }, + deps.pubKeyPrefixMap, + roomNodeIds, + (event, id) => { + packetRouter.dispatch(event, id); + }, + deps.logTransportLineAsDevice, + ); + } else { + console.warn( + '[meshcoreProcessWaitingMessageItem] CLI waiting message skipped (no identityId)', + senderId, + ); + } + } else if (senderId === 0) { console.warn( '[meshcoreProcessWaitingMessageItem] unknown pubKeyPrefix in queued DM, skipping ingest', prefix, diff --git a/src/renderer/lib/meshcoreRepeaterCliWorkingState.test.ts b/src/renderer/lib/meshcoreRepeaterCliWorkingState.test.ts index abae7eb0d..284634d11 100644 --- a/src/renderer/lib/meshcoreRepeaterCliWorkingState.test.ts +++ b/src/renderer/lib/meshcoreRepeaterCliWorkingState.test.ts @@ -19,6 +19,12 @@ describe('meshcore repeater CLI working state', () => { expect(cliBody).toContain("runMeshcoreRepeaterRpcOnce('cli'"); }); + it('resolves repeater pubkey via ensureNodePubKey like other admin RPCs', () => { + const cliBody = extractUseCallbackBody(RUNTIME_SOURCE, 'sendRepeaterCliCommand'); + expect(cliBody).toContain('ensureNodePubKey'); + expect(cliBody).not.toMatch(/pubKeyMapRef\.current\.get\(nodeId\)/); + }); + it('awaits ping settle and login with companion queue before CLI send', () => { const cliBody = extractUseCallbackBody(RUNTIME_SOURCE, 'sendRepeaterCliCommand'); expect(cliBody).toContain('awaitMeshcoreRepeaterPingSettleForNode'); @@ -32,11 +38,30 @@ describe('meshcore repeater CLI working state', () => { expect(cliBody).toContain('awaitMeshcoreRepeaterAdminRfIdle'); const sendSlotStart = cliBody.indexOf('await repeaterRemoteRpcRef.current(async () => {'); const sendSlotEnd = cliBody.indexOf('});', sendSlotStart); - const responseWaitIdx = cliBody.indexOf('const response = await promise'); + const responseWaitIdx = cliBody.indexOf('const response = await cliResponsePromise'); expect(sendSlotStart).toBeGreaterThan(-1); expect(responseWaitIdx).toBeGreaterThan(sendSlotEnd); }); + it('waits for waiting-message drain idle before runMeshcoreRepeaterRpcOnce', () => { + const cliBody = extractUseCallbackBody(RUNTIME_SOURCE, 'sendRepeaterCliCommand'); + const drainWaitIdx = cliBody.indexOf('await awaitMeshcoreWaitingMessagesDrainIdle'); + const onceIdx = cliBody.indexOf("runMeshcoreRepeaterRpcOnce('cli'"); + expect(drainWaitIdx).toBeGreaterThan(-1); + expect(onceIdx).toBeGreaterThan(drainWaitIdx); + expect(cliBody).toContain('padRepeaterCliTimeoutForWaitingDrain'); + expect(cliBody).toContain('extendPendingTimeout'); + }); + + it('waits for CLI DM response after runMeshcoreRepeaterRpcOnce so waiting-message drain can run', () => { + const cliBody = extractUseCallbackBody(RUNTIME_SOURCE, 'sendRepeaterCliCommand'); + expect(cliBody).toContain('cliResponsePromise = promise'); + const onceIdx = cliBody.indexOf("runMeshcoreRepeaterRpcOnce('cli'"); + const responseWaitIdx = cliBody.indexOf('const response = await cliResponsePromise'); + expect(onceIdx).toBeGreaterThan(-1); + expect(responseWaitIdx).toBeGreaterThan(onceIdx); + }); + it('rejects CLI commands longer than REPEATER_CLI_MAX_COMMAND_LENGTH before send', () => { expect(REPEATER_CMD_SOURCE).toContain('export const REPEATER_CLI_MAX_COMMAND_LENGTH = 512'); const cliBody = extractUseCallbackBody(RUNTIME_SOURCE, 'sendRepeaterCliCommand'); @@ -55,4 +80,11 @@ describe('meshcore repeater CLI working state', () => { const cliBody = extractUseCallbackBody(RUNTIME_SOURCE, 'sendRepeaterCliCommand'); expect(cliBody).toContain('senderNodeId: nodeId'); }); + + it('syncs companion time before repeater clock sync and reports actual CLI timeout on error', () => { + const cliBody = extractUseCallbackBody(RUNTIME_SOURCE, 'sendRepeaterCliCommand'); + expect(cliBody).toContain("trimmed.toLowerCase() === 'clock sync'"); + expect(cliBody).toContain('syncDeviceTime'); + expect(cliBody).toContain('meshcoreRepeaterRpcErrorMessage(errMsg, cliTimeoutMs)'); + }); }); diff --git a/src/renderer/lib/meshcoreWaitingMessagesDrain.test.ts b/src/renderer/lib/meshcoreWaitingMessagesDrain.test.ts index 5e61074e3..39eb338f5 100644 --- a/src/renderer/lib/meshcoreWaitingMessagesDrain.test.ts +++ b/src/renderer/lib/meshcoreWaitingMessagesDrain.test.ts @@ -4,6 +4,7 @@ import * as meshcoreRepeaterRpcInFlight from './meshcoreRepeaterRpcInFlight'; import * as meshcoreTracePathMultiplex from './meshcoreTracePathMultiplex'; import { abandonMeshcoreSilentBulkAttempt, + awaitMeshcoreWaitingMessagesDrainIdle, beginMeshcoreSilentBulkAttempt, isMeshcoreCompanionDrainDeferred, isMeshcoreSilentBulkAttemptCurrent, @@ -379,3 +380,32 @@ describe('silent bulk timeout circuit breaker', () => { expect(shouldSkipMeshcoreSilentBulkGetWaitingMessages()).toBe(false); }); }); + +describe('awaitMeshcoreWaitingMessagesDrainIdle', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('returns true immediately when drain is idle', async () => { + await expect(awaitMeshcoreWaitingMessagesDrainIdle(() => false, 5_000)).resolves.toBe(true); + }); + + it('returns true after drain becomes idle', async () => { + let busy = true; + const pending = awaitMeshcoreWaitingMessagesDrainIdle(() => busy, 5_000); + await vi.advanceTimersByTimeAsync(250); + busy = false; + await vi.advanceTimersByTimeAsync(250); + await expect(pending).resolves.toBe(true); + }); + + it('returns false when drain stays busy until timeout', async () => { + const pending = awaitMeshcoreWaitingMessagesDrainIdle(() => true, 1_000); + await vi.advanceTimersByTimeAsync(1_250); + await expect(pending).resolves.toBe(false); + }); +}); diff --git a/src/renderer/lib/meshcoreWaitingMessagesDrain.ts b/src/renderer/lib/meshcoreWaitingMessagesDrain.ts index f749ddd14..befc57267 100644 --- a/src/renderer/lib/meshcoreWaitingMessagesDrain.ts +++ b/src/renderer/lib/meshcoreWaitingMessagesDrain.ts @@ -186,6 +186,24 @@ export function isMeshcoreCompanionDrainDeferred(): boolean { ); } +const DRAIN_IDLE_POLL_MS = 250; + +/** Wait until silent/manual waiting-message drain is idle, or until timeout. */ +export async function awaitMeshcoreWaitingMessagesDrainIdle( + isBusy: () => boolean, + timeoutMs: number = MESHCORE_WAITING_MESSAGES_SILENT_TIMEOUT_MS, +): Promise { + if (!isBusy()) return true; + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + await new Promise((resolve) => { + setTimeout(resolve, DRAIN_IDLE_POLL_MS); + }); + if (!isBusy()) return true; + } + return !isBusy(); +} + /** Silent auto-drain timeouts during BLE congestion are expected — log at debug, not warn. */ export function logMeshcoreWaitingMessagesDrainError( context: string, diff --git a/src/renderer/lib/repeaterCommandService.test.ts b/src/renderer/lib/repeaterCommandService.test.ts index 6da7f6c95..6398e6ec8 100644 --- a/src/renderer/lib/repeaterCommandService.test.ts +++ b/src/renderer/lib/repeaterCommandService.test.ts @@ -100,6 +100,22 @@ describe('RepeaterCommandService', () => { }); }); + describe('padRepeaterCliTimeoutForWaitingDrain', () => { + it('leaves timeout unchanged when drain is idle', async () => { + const { padRepeaterCliTimeoutForWaitingDrain } = await import('./repeaterCommandService'); + expect(padRepeaterCliTimeoutForWaitingDrain(30_000, false, 45_000)).toBe(30_000); + }); + + it('pads timeout and caps at max when drain is busy', async () => { + const { padRepeaterCliTimeoutForWaitingDrain, REPEATER_CLI_MAX_TIMEOUT_MS } = + await import('./repeaterCommandService'); + expect(padRepeaterCliTimeoutForWaitingDrain(30_000, true, 45_000)).toBe(75_000); + expect(padRepeaterCliTimeoutForWaitingDrain(100_000, true, 45_000)).toBe( + REPEATER_CLI_MAX_TIMEOUT_MS, + ); + }); + }); + describe('computeRepeaterCliHopCount', () => { it('prefers trace hop count over hopsAway', async () => { const { computeRepeaterCliHopCount } = await import('./repeaterCommandService'); @@ -271,6 +287,16 @@ describe('RepeaterCommandService', () => { await expect(promise).rejects.toThrow('CLI command timed out after 1000ms'); }); + it('extendPendingTimeout lengthens an in-flight wait', async () => { + const { token, promise } = service.registerPendingCommand('cmd', [], { timeoutMs: 1000 }); + promise.catch(() => {}); + service.extendPendingTimeout(token, 3000); + vi.advanceTimersByTime(1500); + expect(service.hasPendingCommand(token)).toBe(true); + vi.advanceTimersByTime(1600); + await expect(promise).rejects.toThrow('CLI command timed out after 3000ms'); + }); + it('should not fire timeout after handleResponse resolves the command', async () => { const { token, promise } = service.registerPendingCommand('cmd', [], { timeoutMs: 1000 }); service.handleResponse(`${token}|ok`); diff --git a/src/renderer/lib/repeaterCommandService.ts b/src/renderer/lib/repeaterCommandService.ts index 2f9e529a6..e5fe5c25c 100644 --- a/src/renderer/lib/repeaterCommandService.ts +++ b/src/renderer/lib/repeaterCommandService.ts @@ -62,6 +62,17 @@ export function calculateRepeaterCliTimeout( return Math.min(Math.max(dynamicTimeout, baseTimeoutMs), maxTimeoutMs); } +/** Extra CLI wait when companion waiting-message drain is (or was) busy. */ +export function padRepeaterCliTimeoutForWaitingDrain( + timeoutMs: number, + drainBusy: boolean, + drainPadMs: number, + maxTimeoutMs = REPEATER_CLI_MAX_TIMEOUT_MS, +): number { + if (!drainBusy) return timeoutMs; + return Math.min(maxTimeoutMs, timeoutMs + drainPadMs); +} + export class RepeaterCommandService { private nextToken = 0; private pendingCommands = new Map(); @@ -152,6 +163,20 @@ export class RepeaterCommandService { return { token, promise, timeoutMs }; } + /** Lengthen an in-flight CLI wait when drain is busy at SENT. */ + extendPendingTimeout(token: string, newTimeoutMs: number): void { + const pending = this.pendingCommands.get(token); + if (!pending || newTimeoutMs <= pending.timeoutMs) return; + clearTimeout(pending.timerId); + pending.timeoutMs = newTimeoutMs; + const remaining = Math.max(0, newTimeoutMs - (Date.now() - pending.sentAt)); + pending.timerId = setTimeout(() => { + if (this.pendingCommands.delete(token)) { + pending.reject(new Error(`CLI command timed out after ${newTimeoutMs}ms`)); + } + }, remaining); + } + handleResponse(rawResponse: string, senderId?: number): boolean { const { token, body } = this.parseResponseToken(rawResponse); if (!token) return false; diff --git a/src/renderer/locales/cs/translation.json b/src/renderer/locales/cs/translation.json index e97beb55a..d9fcebecd 100644 --- a/src/renderer/locales/cs/translation.json +++ b/src/renderer/locales/cs/translation.json @@ -4166,7 +4166,8 @@ "currentRouteLabel": "Aktuální trasa:", "neighborsLoadMore": "Načíst další ({{loaded}} z {{total}} načteno)", "neighborsLoadMoreAria": "Načíst další sousedy ({{loaded}} z {{total}} načteno)", - "neighborsLoadingMore": "Načítání…" + "neighborsLoadingMore": "Načítání…", + "cliClockCannotGoBackwards": "Hodiny tohoto opakovače jsou před tímto počítačem ({{utc}}). Firmware MeshCore nenastaví hodiny dozadu. Po potvrzení spusťte clkreboot a poté synchronizaci hodin." }, "reticulumIdentity": { "announceIntervalSec": "Interval oznamování (sekundy)", diff --git a/src/renderer/locales/de/translation.json b/src/renderer/locales/de/translation.json index bf6e961f1..40eb02a63 100644 --- a/src/renderer/locales/de/translation.json +++ b/src/renderer/locales/de/translation.json @@ -4164,7 +4164,8 @@ "currentRouteLabel": "Aktuelle Route:", "neighborsLoadMore": "Mehr laden ({{loaded}} von {{total}} geladen)", "neighborsLoadMoreAria": "Weitere Nachbarn laden ({{loaded}} von {{total}} geladen)", - "neighborsLoadingMore": "Wird geladen…" + "neighborsLoadingMore": "Wird geladen…", + "cliClockCannotGoBackwards": "Die Uhr dieses Repeaters liegt vor diesem Computer ({{utc}}). Die MeshCore-Firmware stellt die Uhr nicht rückwärts. Führen Sie nach der Bestätigung clkreboot und dann clock sync aus." }, "reticulumIdentity": { "announceIntervalSec": "Ankündigungsintervall (Sekunden)", diff --git a/src/renderer/locales/en/translation.json b/src/renderer/locales/en/translation.json index 557d6ad5b..289e541ee 100644 --- a/src/renderer/locales/en/translation.json +++ b/src/renderer/locales/en/translation.json @@ -4386,6 +4386,7 @@ "cliDangerConfirmMessage": "This command may reboot, erase, or factory-reset the repeater: \"{{command}}\". Continue only if you intend this change.", "cliDangerConfirmAction": "Run command", "cliCommandTooLong": "CLI command is too long (max {{max}} characters).", + "cliClockCannotGoBackwards": "This repeater's clock is ahead of this computer ({{utc}}). MeshCore firmware will not set the clock backwards. After you confirm, run clkreboot, then clock sync.", "searchRepeatersPlaceholder": "Search repeaters…", "openCliInterface": "Open CLI interface", "pathHashCliGet": "Get path hash mode", diff --git a/src/renderer/locales/es/translation.json b/src/renderer/locales/es/translation.json index 5c4c46e8b..1869d7fee 100644 --- a/src/renderer/locales/es/translation.json +++ b/src/renderer/locales/es/translation.json @@ -4164,7 +4164,8 @@ "currentRouteLabel": "Ruta actual:", "neighborsLoadMore": "Cargar más ({{loaded}} de {{total}})", "neighborsLoadMoreAria": "Cargar más vecinos ({{loaded}} de {{total}} cargados)", - "neighborsLoadingMore": "Cargando…" + "neighborsLoadingMore": "Cargando…", + "cliClockCannotGoBackwards": "El reloj de este repetidor está por delante de este equipo ({{utc}}). El firmware de MeshCore no pondrá el reloj al revés. Después de confirmar, ejecuta clkreboot y, a continuación, sincroniza el reloj." }, "reticulumIdentity": { "announceIntervalSec": "Intervalo de anuncio (segundos)", diff --git a/src/renderer/locales/fr/translation.json b/src/renderer/locales/fr/translation.json index 8505153b3..bf8e9c575 100644 --- a/src/renderer/locales/fr/translation.json +++ b/src/renderer/locales/fr/translation.json @@ -4164,7 +4164,8 @@ "currentRouteLabel": "Itinéraire actuel :", "neighborsLoadMore": "Charger plus ({{loaded}} sur {{total}})", "neighborsLoadMoreAria": "Charger plus de voisins ({{loaded}} sur {{total}} chargés)", - "neighborsLoadingMore": "Chargement…" + "neighborsLoadingMore": "Chargement…", + "cliClockCannotGoBackwards": "L'horloge de ce répéteur est en avance sur cet ordinateur ({{utc}}). Le micrologiciel MeshCore ne remettra pas l'horloge en marche arrière. Une fois que vous avez confirmé, exécutez clkreboot, puis synchronisez l'horloge." }, "reticulumIdentity": { "announceIntervalSec": "Intervalle d'annonce (secondes)", diff --git a/src/renderer/locales/id/translation.json b/src/renderer/locales/id/translation.json index 2ce6d294b..6bde38edd 100644 --- a/src/renderer/locales/id/translation.json +++ b/src/renderer/locales/id/translation.json @@ -4164,7 +4164,8 @@ "currentRouteLabel": "Rute saat ini:", "neighborsLoadMore": "Muat lebih banyak ({{loaded}} dari {{total}})", "neighborsLoadMoreAria": "Muat lebih banyak tetangga ({{loaded}} dari {{total}} dimuat)", - "neighborsLoadingMore": "Memuat…" + "neighborsLoadingMore": "Memuat…", + "cliClockCannotGoBackwards": "Jam repeater ini berada di depan komputer ini ({{utc}}). Firmware MeshCore tidak akan mengatur jam mundur. Setelah Anda mengonfirmasi, jalankan clkreboot, lalu sinkronisasi jam." }, "reticulumIdentity": { "announceIntervalSec": "Interval pengumuman (detik)", diff --git a/src/renderer/locales/it/translation.json b/src/renderer/locales/it/translation.json index 8e7355009..90b393a5c 100644 --- a/src/renderer/locales/it/translation.json +++ b/src/renderer/locales/it/translation.json @@ -4164,7 +4164,8 @@ "currentRouteLabel": "Itinerario attuale:", "neighborsLoadMore": "Carica altro ({{loaded}} di {{total}} caricati)", "neighborsLoadMoreAria": "Carica altri vicini ({{loaded}} di {{total}} caricati)", - "neighborsLoadingMore": "Caricamento in corso…" + "neighborsLoadingMore": "Caricamento in corso…", + "cliClockCannotGoBackwards": "L'orologio di questo ripetitore è davanti a questo computer ({{utc}}). Il firmware MeshCore non imposterà l'orologio all'indietro. Dopo aver confermato, eseguire clkreboot, quindi sincronizzare l'orologio." }, "reticulumIdentity": { "announceIntervalSec": "Intervallo annunci (secondi)", diff --git a/src/renderer/locales/ja/translation.json b/src/renderer/locales/ja/translation.json index dd773a095..35f442f06 100644 --- a/src/renderer/locales/ja/translation.json +++ b/src/renderer/locales/ja/translation.json @@ -4164,7 +4164,8 @@ "currentRouteLabel": "現在のルート:", "neighborsLoadMore": "さらに読み込む({{loaded}}/{{total}} 読み込み済み)", "neighborsLoadMoreAria": "隣接ノードをさらに読み込む({{loaded}}/{{total}} 読み込み済み)", - "neighborsLoadingMore": "読み込み中…" + "neighborsLoadingMore": "読み込み中…", + "cliClockCannotGoBackwards": "このリピーターのクロックはこのコンピューターの前にあります( {{utc}} )。MeshCoreファームウェアは時計を逆に設定しません。確認したら、clkrebootを実行してから、クロック同期を実行します。" }, "reticulumIdentity": { "announceIntervalSec": "アナウンス間隔(秒)", diff --git a/src/renderer/locales/ko/translation.json b/src/renderer/locales/ko/translation.json index 97541af12..c8b017d18 100644 --- a/src/renderer/locales/ko/translation.json +++ b/src/renderer/locales/ko/translation.json @@ -4164,7 +4164,8 @@ "currentRouteLabel": "현재 경로:", "neighborsLoadMore": "더 보기 ({{loaded}}/{{total}} 로드됨)", "neighborsLoadMoreAria": "더 많은 이웃 로드 ({{loaded}}/{{total}} 로드됨)", - "neighborsLoadingMore": "로드 중…" + "neighborsLoadingMore": "로드 중…", + "cliClockCannotGoBackwards": "이 리피터의 시계가 이 컴퓨터보다 앞서 있습니다 ({{utc}}). MeshCore 펌웨어는 시계를 거꾸로 설정하지 않습니다. 확인 후 clkreboot를 실행한 다음 시계 동기화를 실행합니다." }, "reticulumIdentity": { "announceIntervalSec": "발표 간격 (초)", diff --git a/src/renderer/locales/nl/translation.json b/src/renderer/locales/nl/translation.json index 0b2fd21b9..9dac885e0 100644 --- a/src/renderer/locales/nl/translation.json +++ b/src/renderer/locales/nl/translation.json @@ -4164,7 +4164,8 @@ "currentRouteLabel": "Huidig ​​traject:", "neighborsLoadMore": "Laad meer ({{loaded}} van {{total}})", "neighborsLoadMoreAria": "Laad meer buren ({{loaded}} van {{total}} geladen)", - "neighborsLoadingMore": "Aan het laden…" + "neighborsLoadingMore": "Aan het laden…", + "cliClockCannotGoBackwards": "De klok van deze repeater loopt voor op deze computer ({{utc}}). MeshCore-firmware zet de klok niet achteruit. Nadat u hebt bevestigd, voert u clkreboot uit en vervolgens kloksynchronisatie." }, "reticulumIdentity": { "announceIntervalSec": "Aankondigingsinterval (seconden)", diff --git a/src/renderer/locales/pl/translation.json b/src/renderer/locales/pl/translation.json index 678eea9ee..f94de46ac 100644 --- a/src/renderer/locales/pl/translation.json +++ b/src/renderer/locales/pl/translation.json @@ -4168,7 +4168,8 @@ "currentRouteLabel": "Aktualna trasa:", "neighborsLoadMore": "Załaduj więcej ({{loaded}} z {{total}} załadowanych)", "neighborsLoadMoreAria": "Załaduj więcej sąsiadów ({{loaded}} z {{total}} załadowanych)", - "neighborsLoadingMore": "Wczytywanie…" + "neighborsLoadingMore": "Wczytywanie…", + "cliClockCannotGoBackwards": "Zegar tego przemiennika wyprzedza ten komputer ({{utc}}). Oprogramowanie sprzętowe MeshCore nie ustawi zegara wstecz. Po potwierdzeniu uruchom clkreboot, a następnie synchronizację zegara." }, "reticulumIdentity": { "announceIntervalSec": "Interwał ogłaszania (sekundy)", diff --git a/src/renderer/locales/pt-BR/translation.json b/src/renderer/locales/pt-BR/translation.json index 1cb874eee..55c9e0e85 100644 --- a/src/renderer/locales/pt-BR/translation.json +++ b/src/renderer/locales/pt-BR/translation.json @@ -4164,7 +4164,8 @@ "currentRouteLabel": "Rota atual:", "neighborsLoadMore": "Carregar mais ({{loaded}} de {{total}} carregados)", "neighborsLoadMoreAria": "Carregar mais vizinhos ({{loaded}} de {{total}} carregados)", - "neighborsLoadingMore": "Carregando…" + "neighborsLoadingMore": "Carregando…", + "cliClockCannotGoBackwards": "O relógio deste repetidor está à frente deste computador ({{utc}}). O firmware MeshCore não ajustará o relógio para trás. Depois de confirmar, execute o clkreboot e, em seguida, sincronize o relógio." }, "reticulumIdentity": { "announceIntervalSec": "Intervalo de anúncio (segundos)", diff --git a/src/renderer/locales/ru/translation.json b/src/renderer/locales/ru/translation.json index 74731b2fe..34593e735 100644 --- a/src/renderer/locales/ru/translation.json +++ b/src/renderer/locales/ru/translation.json @@ -4166,7 +4166,8 @@ "currentRouteLabel": "Текущий маршрут:", "neighborsLoadMore": "Загрузить больше ({{loaded}} из {{total}})", "neighborsLoadMoreAria": "Загрузить больше соседей ({{loaded}} из {{total}} загружено)", - "neighborsLoadingMore": "Загрузка…" + "neighborsLoadingMore": "Загрузка…", + "cliClockCannotGoBackwards": "Часы этого ретранслятора опережают этот компьютер ({{utc}}). Прошивка MeshCore не переводит часы назад. После подтверждения запустите clkreboot, а затем синхронизируйте часы." }, "reticulumIdentity": { "announceIntervalSec": "Интервал объявления (секунды)", diff --git a/src/renderer/locales/tr/translation.json b/src/renderer/locales/tr/translation.json index 8bcc3f1ad..b7218362b 100644 --- a/src/renderer/locales/tr/translation.json +++ b/src/renderer/locales/tr/translation.json @@ -4164,7 +4164,8 @@ "currentRouteLabel": "Mevcut rota:", "neighborsLoadMore": "Daha fazla yükle ({{loaded}}/{{total}})", "neighborsLoadMoreAria": "Daha fazla komşu yükleyin ({{loaded}}/{{total}} yüklü)", - "neighborsLoadingMore": "Yükleniyor…" + "neighborsLoadingMore": "Yükleniyor…", + "cliClockCannotGoBackwards": "Bu tekrarlayıcının saati bu bilgisayarın önünde ({{utc}}). MeshCore ürün yazılımı saati geriye doğru ayarlamaz. Onayladıktan sonra clkreboot'u çalıştırın, ardından saat senkronizasyonunu yapın." }, "reticulumIdentity": { "announceIntervalSec": "Anons aralığı (saniye)", diff --git a/src/renderer/locales/uk/translation.json b/src/renderer/locales/uk/translation.json index 855818af9..a2d416136 100644 --- a/src/renderer/locales/uk/translation.json +++ b/src/renderer/locales/uk/translation.json @@ -4166,7 +4166,8 @@ "currentRouteLabel": "Поточний маршрут:", "neighborsLoadMore": "Завантажити більше ({{loaded}} з {{total}})", "neighborsLoadMoreAria": "Завантажити більше сусідів (завантажено {{loaded}} з {{total}})", - "neighborsLoadingMore": "Завантаження…" + "neighborsLoadingMore": "Завантаження…", + "cliClockCannotGoBackwards": "Годинник цього ретранслятора випереджає цей комп'ютер ({{utc}}). Прошивка MeshCore не встановлює годинник назад. Після підтвердження запустіть clkreboot, а потім синхронізуйте годинник." }, "reticulumIdentity": { "announceIntervalSec": "Інтервал оголошення (секунди)", diff --git a/src/renderer/locales/zh/translation.json b/src/renderer/locales/zh/translation.json index 3b00fa255..c58c696e0 100644 --- a/src/renderer/locales/zh/translation.json +++ b/src/renderer/locales/zh/translation.json @@ -4164,7 +4164,8 @@ "currentRouteLabel": "当前路线:", "neighborsLoadMore": "加载更多({{loaded}}/{{total}})", "neighborsLoadMoreAria": "加载更多邻居(已加载{{loaded}}/{{total}})", - "neighborsLoadingMore": "加载中…" + "neighborsLoadingMore": "加载中…", + "cliClockCannotGoBackwards": "此中继器的时钟在此计算机前面( {{utc}} )。MeshCore固件不会向后设置时钟。确认后,运行clkreboot ,然后进行时钟同步。" }, "reticulumIdentity": { "announceIntervalSec": "公告间隔(秒)", diff --git a/src/renderer/runtime/useMeshcoreRuntime.ts b/src/renderer/runtime/useMeshcoreRuntime.ts index 841baf53a..af0e3143e 100644 --- a/src/renderer/runtime/useMeshcoreRuntime.ts +++ b/src/renderer/runtime/useMeshcoreRuntime.ts @@ -386,6 +386,7 @@ import { resolveMeshcoreRoomLoginHopsAway, } from '../lib/meshcoreUtils'; import { + awaitMeshcoreWaitingMessagesDrainIdle, logMeshcoreWaitingMessagesDrainError, markMeshcoreCompanionTx, scheduleMeshcoreWaitingMessagesDrain, @@ -411,6 +412,7 @@ import { type CliHistoryEntry, computeRepeaterCliHopCount, createRepeaterCommandService, + padRepeaterCliTimeoutForWaitingDrain, REPEATER_CLI_MAX_COMMAND_LENGTH, type RepeaterCommandService, } from '../lib/repeaterCommandService'; @@ -448,6 +450,7 @@ import { MESHCORE_WAITING_MESSAGES_AFTER_TX_DEFER_MS, MESHCORE_WAITING_MESSAGES_DRAIN_DEBOUNCE_MS, MESHCORE_WAITING_MESSAGES_POLL_MS, + MESHCORE_WAITING_MESSAGES_SILENT_TIMEOUT_MS, POWER_RESUME_MESHCORE_MESHTASTIC_SETTLE_MS, RF_SERIAL_OPEN_RETRY_DELAY_MS, } from '../lib/timeConstants'; @@ -5762,40 +5765,37 @@ export function useMeshcoreRuntime() { if (isMeshcoreRepeaterCliDangerCommand(trimmed) && !opts?.confirmedDanger) { throw new Error(serializeMeshcoreUserMessage('meshcore.errors.cliDangerNotConfirmed')); } - const pubKey = pubKeyMapRef.current.get(nodeId); - if (!pubKey) { - setMeshcoreCliErrors((prev) => { - const next = new Map(prev); - next.set(nodeId, MESHCORE_ERR_NODE_NOT_FOUND); - return next; - }); - throw new Error(MESHCORE_ERR_NODE_NOT_FOUND); - } - if (!resolveMeshcoreConn()) { - setMeshcoreCliErrors((prev) => { - const next = new Map(prev); - next.set(nodeId, MESHCORE_ERR_NOT_CONNECTED); - return next; - }); - throw new Error(MESHCORE_ERR_NOT_CONNECTED); - } - - setMeshcoreCliErrors((prev) => { - const next = new Map(prev); - next.delete(nodeId); - return next; - }); const service = repeaterCommandServiceRef.current ?? createRepeaterCommandService(); repeaterCommandServiceRef.current ??= service; + const drainBusyAtStart = waitingMessagesDrainBusyRef.current; + const drainIdle = await awaitMeshcoreWaitingMessagesDrainIdle( + () => waitingMessagesDrainBusyRef.current, + MESHCORE_WAITING_MESSAGES_SILENT_TIMEOUT_MS, + ); + + let cliTimeoutMs = calculateRepeaterCliTimeout(0, trimmed.length); try { - return await runMeshcoreRepeaterRpcOnce('cli', nodeId, async () => { + let cliResponsePromise: Promise | undefined; + let cliPendingToken: string | undefined; + let cliBaseTimeoutMs = cliTimeoutMs; + await runMeshcoreRepeaterRpcOnce('cli', nodeId, async () => { + const pubKey = await ensureNodePubKey(nodeId); + if (!pubKey) { + throw new Error(MESHCORE_ERR_NODE_NOT_FOUND); + } const conn = resolveMeshcoreConn(); if (!conn) { throw new Error(MESHCORE_ERR_NOT_CONNECTED); } + setMeshcoreCliErrors((prev) => { + const next = new Map(prev); + next.delete(nodeId); + return next; + }); + await awaitMeshcoreRepeaterPingSettleForNode(nodeId); await meshcoreTryRemoteServerLogin( conn, @@ -5811,11 +5811,20 @@ export function useMeshcoreRuntime() { node?.hops_away, trace != null ? meshcoreTracePathLenToHops(trace.pathLen) : null, ); - const timeoutMs = calculateRepeaterCliTimeout(hopCount, trimmed.length); + cliBaseTimeoutMs = calculateRepeaterCliTimeout(hopCount, trimmed.length); + const drainBusyNow = waitingMessagesDrainBusyRef.current; + const timeoutMs = padRepeaterCliTimeoutForWaitingDrain( + cliBaseTimeoutMs, + drainBusyAtStart || drainBusyNow || !drainIdle, + MESHCORE_WAITING_MESSAGES_SILENT_TIMEOUT_MS, + ); + cliTimeoutMs = timeoutMs; const { token, promise } = service.registerPendingCommand(trimmed, [], { timeoutMs, senderNodeId: nodeId, }); + cliPendingToken = token; + cliResponsePromise = promise; const commandWithToken = service.formatCommandWithToken(trimmed, token); addCliHistoryEntry(nodeId, { @@ -5824,6 +5833,17 @@ export function useMeshcoreRuntime() { timestamp: Date.now(), }); + if (trimmed.toLowerCase() === 'clock sync') { + try { + await conn.syncDeviceTime(); + } catch (e: unknown) { + console.debug( + '[useMeshcoreRuntime] companion syncDeviceTime before repeater clock sync ' + + errLikeToLogString(e), + ); + } + } + await repeaterRemoteRpcRef.current(async () => { await awaitMeshcoreRepeaterAdminRfIdle(); await waitForMeshcoreRadioSentAck( @@ -5836,20 +5856,33 @@ export function useMeshcoreRuntime() { markMeshcoreCompanionTx(); }); - const response = await promise; - addCliHistoryEntry(nodeId, { - type: 'received', - text: response, - timestamp: Date.now(), - }); - bumpMeshcoreNodeLastHeardFromRpc(nodeId); - return response; + const drainBusyAtSent = waitingMessagesDrainBusyRef.current; + const paddedAtSent = padRepeaterCliTimeoutForWaitingDrain( + cliBaseTimeoutMs, + drainBusyAtSent, + MESHCORE_WAITING_MESSAGES_SILENT_TIMEOUT_MS, + ); + if (cliPendingToken) { + service.extendPendingTimeout(cliPendingToken, paddedAtSent); + } + cliTimeoutMs = Math.max(cliTimeoutMs, paddedAtSent); }); + if (!cliResponsePromise) { + throw new Error(MESHCORE_ERR_REQUEST_FAILED); + } + const response = await cliResponsePromise; + addCliHistoryEntry(nodeId, { + type: 'received', + text: response, + timestamp: Date.now(), + }); + bumpMeshcoreNodeLastHeardFromRpc(nodeId); + return response; } catch (e: unknown) { const rawErr = e instanceof Error ? e.message : String(e); const errMsg = rawErr && rawErr !== 'undefined' ? rawErr : MESHCORE_ERR_REQUEST_FAILED; const friendlyErr = meshcoreStoredUserMessage( - meshcoreRepeaterRpcErrorMessage(errMsg, MESHCORE_TRACE_TIMEOUT_MS), + meshcoreRepeaterRpcErrorMessage(errMsg, cliTimeoutMs), ); setMeshcoreCliErrors((prev) => { const next = new Map(prev); @@ -5875,6 +5908,7 @@ export function useMeshcoreRuntime() { [ addCliHistoryEntry, bumpMeshcoreNodeLastHeardFromRpc, + ensureNodePubKey, meshcoreTraceResults, resolveMeshcoreConn, ], From 577b9d87dc3237e78adeb1c3b68c1f37a759f99f Mon Sep 17 00:00:00 2001 From: Joey Stanford Date: Fri, 14 Aug 2026 14:48:11 -0600 Subject: [PATCH 2/6] feat(meshcore): unify room ops on Repeaters & Rooms tab Move infrastructure controls for room servers onto the Repeaters panel (with rooms listed), add an admin-password facade that keeps prefixes separate, and keep Rooms as the BBS with a jump to ops. --- docs/agents/meshcore-repeaters.md | 6 +- docs/agents/meshcore-rooms.md | 3 +- src/renderer/App.tsx | 42 ++- .../MeshcoreRepeaterPasswordControls.test.tsx | 2 +- .../components/MeshcoreRoomAclControls.tsx | 74 +++++ .../components/NodeDetailModal.test.tsx | 10 +- src/renderer/components/NodeDetailModal.tsx | 100 +++--- .../components/RepeatersPanel.auth.test.tsx | 72 ++++- .../components/RepeatersPanel.test.tsx | 167 +++++++++- src/renderer/components/RepeatersPanel.tsx | 295 +++++++++++++----- src/renderer/components/RoomsPanel.test.tsx | 49 +-- src/renderer/components/RoomsPanel.tsx | 261 ++-------------- .../useMeshcoreRepeaterRemoteAuth.test.tsx | 44 ++- .../hooks/useMeshcoreRepeaterRemoteAuth.tsx | 82 +++-- .../lib/meshcoreInfraAdminSecrets.test.ts | 114 +++++++ src/renderer/lib/meshcoreInfraAdminSecrets.ts | 145 +++++++++ .../lib/meshcoreRepeaterCliDanger.test.ts | 4 + src/renderer/lib/meshcoreRepeaterCliDanger.ts | 5 +- .../lib/meshcoreRoomCredentialStorage.test.ts | 36 +++ .../lib/meshcoreRoomCredentialStorage.ts | 12 +- src/renderer/lib/meshcoreRoomSession.test.ts | 38 +++ src/renderer/lib/meshcoreRoomSession.ts | 16 +- src/renderer/locales/cs/translation.json | 22 +- src/renderer/locales/de/translation.json | 22 +- src/renderer/locales/en/translation.json | 70 ++--- src/renderer/locales/es/translation.json | 22 +- src/renderer/locales/fr/translation.json | 22 +- src/renderer/locales/id/translation.json | 22 +- src/renderer/locales/it/translation.json | 22 +- src/renderer/locales/ja/translation.json | 22 +- src/renderer/locales/ko/translation.json | 22 +- src/renderer/locales/nl/translation.json | 22 +- src/renderer/locales/pl/translation.json | 22 +- src/renderer/locales/pt-BR/translation.json | 22 +- src/renderer/locales/ru/translation.json | 22 +- src/renderer/locales/tr/translation.json | 22 +- src/renderer/locales/uk/translation.json | 22 +- src/renderer/locales/zh/translation.json | 22 +- src/renderer/runtime/useMeshcoreRuntime.ts | 24 +- vitest.config.mts | 1 + 40 files changed, 1305 insertions(+), 697 deletions(-) create mode 100644 src/renderer/components/MeshcoreRoomAclControls.tsx create mode 100644 src/renderer/lib/meshcoreInfraAdminSecrets.test.ts create mode 100644 src/renderer/lib/meshcoreInfraAdminSecrets.ts diff --git a/docs/agents/meshcore-repeaters.md b/docs/agents/meshcore-repeaters.md index 20d160476..67103f45e 100644 --- a/docs/agents/meshcore-repeaters.md +++ b/docs/agents/meshcore-repeaters.md @@ -2,6 +2,8 @@ Deep subsystem reference for AI assistants. Open this when a task touches MeshCore repeater admin RPCs, trace/ping, neighbors paging, CLI, or waiting-message drain. Hard rules live in [`AGENTS.md`](../../AGENTS.md). +**Repeaters & Rooms ops tab:** [`RepeatersPanel.tsx`](../../src/renderer/components/RepeatersPanel.tsx) lists **both** `hw_model === 'Repeater'` and `hw_model === 'Room'` (All / Repeaters / Rooms filter). Rooms BBS stays on the Rooms tab; Manage there jumps here via `pendingFocusNodeId`. Admin passwords use [`meshcoreInfraAdminSecrets.ts`](../../src/renderer/lib/meshcoreInfraAdminSecrets.ts) (two `app_settings` prefixes; room forget clears **admin only**). Room CLI goes through `sendRoomAdminCliCommand` (forwards `confirmedDanger`). Room-only CLI pills: `get acl`, `allow.read.only on|off` + ACL `setperm` form. + MeshCore firmware **serializes traceroutes** — one active trace cycle per RF link. mesh-client enforces: - **Trace queue** (`meshcoreRepeaterRpcInFlight.ts`): global ping queue; duplicate clicks coalesce per node. @@ -14,8 +16,8 @@ MeshCore firmware **serializes traceroutes** — one active trace cycle per RF l - **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). +- **Repeater CLI danger**: destructive commands (`meshcoreRepeaterCliDanger.ts`: `reboot|erase|clkreboot|factory|shutdown|poweroff`) require confirm modal in Repeaters panel; runtime rejects unconfirmed sends (`meshcore.errors.cliDangerNotConfirmed`). Commands longer than **512** characters (`REPEATER_CLI_MAX_COMMAND_LENGTH`) are rejected before send. Multi-hop CLI auto-pings once per session when no trace exists (`RepeatersPanel` → `onPing`); CLI aborts when ping does not produce a trace result. Safe quick pills include `clock`, `clock sync`, `clear stats`, `advert`, `advert.zerohop`, `board`, `stats-*`, `get role` (firmware CLI tokens as labels). +- **Per-node admin passwords:** `meshcoreInfraAdminSecrets.ts` facade over `meshcoreRepeaterCredentialStorage.ts` / `meshcoreRoomCredentialStorage.ts` (separate prefixes); ops modal via `useMeshcoreRepeaterRemoteAuth.tsx`; room admin-only persist allowed; ops Forget on a room clears **admin only** (guest/auto-login unchanged). Accordion lists both kinds. - **Waiting-message drain:** event 131 → `meshcoreWaitingMessagesDrain.ts` / `meshcoreProcessWaitingMessageItem.ts`; silent auto-drain vs manual **Sync now** (`MeshcoreWaitingMessagesHeaderIndicator.tsx` in the App header via `meshcoreWaitingMessagesStatusText.ts`; **queued backlog visible on any protocol tab**; **active sync spinner and paused/deferred** state only on the MeshCore tab); defers during TraceData/admin RPC. Silent bulk `getWaitingMessages` circuit-opens after **2** consecutive timeouts (`MESHCORE_WAITING_MESSAGES_SILENT_BULK_TIMEOUT_TRIP`) and skips bulk until reconnect/success; disconnect bumps the attempt id so a late timeout cannot trip the next connection. Repeater CLI waits for drain idle **before** `runMeshcoreRepeaterRpcOnce` (waiting inside the once slot deadlocks because in-flight defers drain). CLI_DATA waiting messages dispatch as CLI responses, not chat. If drain is busy at start or SENT, CLI timeout is padded by `MESHCORE_WAITING_MESSAGES_SILENT_TIMEOUT_MS` (45s), capped at `REPEATER_CLI_MAX_TIMEOUT_MS`. - **Repeater CLI clock:** `clock` / `clock sync` are firmware tokens. `clock sync` uses the companion/computer UTC unix timestamp; MeshCore firmware **refuses to set time backwards** (`ERR: clock cannot go backwards`). Do not auto-`clkreboot`; toast `repeatersPanel.cliClockCannotGoBackwards` and leave `clkreboot` behind the danger confirm. Optional companion `syncDeviceTime()` runs before repeater `clock sync`. - **Cross-traffic**: Room sync/auto-login defer while `meshcoreCompanionRepeaterRfBusy()`; waiting-messages drain defers during TraceData. diff --git a/docs/agents/meshcore-rooms.md b/docs/agents/meshcore-rooms.md index 0ae3f3315..35c4a1a37 100644 --- a/docs/agents/meshcore-rooms.md +++ b/docs/agents/meshcore-rooms.md @@ -2,7 +2,8 @@ Deep subsystem reference for AI assistants. Open this when a task touches MeshCore Rooms login/post, session RPCs, saved passwords, auto-sync scheduling, or room wire text. Hard rules live in [`AGENTS.md`](../../AGENTS.md). -- **UI:** `RoomsPanel.tsx` — login overlay, post composer (`ChatComposer`), admin CLI, auto-sync toggles; sidebar badge via `meshcoreRoomsUnread.ts` (`mesh-client:meshcoreRoomsUnread`). +- **UI:** `RoomsPanel.tsx` — login overlay, post composer (`ChatComposer`), auto-sync toggles, Members ACL refresh; **Manage** jumps to Repeaters & Rooms ops (CLI / setperm / infra). Sidebar badge via `meshcoreRoomsUnread.ts` (`mesh-client:meshcoreRoomsUnread`). +- **Ops (not on Rooms tab):** Ping / Status / Neighbors / Sensor / CLI / ACL setperm live on `RepeatersPanel` (mixed Repeater + Room table). Guest passwords stay in `meshcoreRoomCredentialStorage`; ops admin uses `meshcoreInfraAdminSecrets` (admin-only records allowed). - **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. diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index cc5bf4ce3..4a61169a4 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -647,6 +647,7 @@ function AppContent() { }); const [pendingDmTarget, setPendingDmTarget] = useState(null); const [pendingRoomTarget, setPendingRoomTarget] = useState(null); + const [pendingRepeaterFocusNodeId, setPendingRepeaterFocusNodeId] = useState(null); const [lastReadRevision, setLastReadRevision] = useState({ meshtastic: 0, meshcore: 0, @@ -2636,10 +2637,28 @@ function AppContent() { [tabsByProtocol.meshcore], ); + const handleOpenRepeaterOps = useCallback( + (nodeNum: number) => { + setPendingRepeaterFocusNodeId(nodeNum); + const filteredIndex = findFilteredTabIndexForPanel( + tabsByProtocol.meshcore, + MODULES_PANEL_INDEX, + ); + if (filteredIndex >= 0) { + setActiveTab(filteredIndex); + } + }, + [tabsByProtocol.meshcore], + ); + const handleRoomTargetConsumed = useCallback(() => { setPendingRoomTarget(null); }, []); + const handleRepeaterFocusConsumed = useCallback(() => { + setPendingRepeaterFocusNodeId(null); + }, []); + const handleLocationFilterChange = useCallback((f: LocationFilter) => { setLocationFilter(f); }, []); @@ -3821,7 +3840,21 @@ function AppContent() { onSelectRepeater={(node) => { setSelectedNodeId(node.node_id); }} - onSendCliCommand={meshcorePanelActions.sendRepeaterCliCommand} + onSendCliCommand={async (nodeId, command, opts) => { + const node = meshcoreUiNodes.get(nodeId); + if (node?.hw_model === 'Room') { + return meshcorePanelActions.sendRoomAdminCliCommand( + nodeId, + command, + opts, + ); + } + return meshcorePanelActions.sendRepeaterCliCommand( + nodeId, + command, + opts, + ); + }} meshcoreCliHistories={meshcoreRuntime.meshcoreCliHistories} meshcoreCliErrors={meshcoreRuntime.meshcoreCliErrors} onClearCliHistory={meshcorePanelActions.clearCliHistory} @@ -3829,6 +3862,9 @@ function AppContent() { meshcoreRepeaterRpcPending={ meshcoreRuntime.meshcoreRepeaterRpcPending } + onOpenRoom={handleOpenRoom} + pendingFocusNodeId={pendingRepeaterFocusNodeId} + onPendingFocusConsumed={handleRepeaterFocusConsumed} /> @@ -3978,9 +4014,7 @@ function AppContent() { onLeaveRoom={meshcorePanelActions.leaveRoom} onSendRoomPost={meshcorePanelActions.sendRoomPost} onSendRoomAdminCli={meshcorePanelActions.sendRoomAdminCliCommand} - meshcoreCliHistories={meshcoreRuntime.meshcoreCliHistories} - meshcoreCliErrors={meshcoreRuntime.meshcoreCliErrors} - onClearCliHistory={meshcorePanelActions.clearCliHistory} + onOpenRepeaterOps={handleOpenRepeaterOps} onMessageNode={handleMessageNode} onToggleFavorite={meshcorePanelActions.setNodeFavorited} scrollToTopRef={scrollToTopRoomsRef} diff --git a/src/renderer/components/MeshcoreRepeaterPasswordControls.test.tsx b/src/renderer/components/MeshcoreRepeaterPasswordControls.test.tsx index 96ef84a3a..d2202e9c6 100644 --- a/src/renderer/components/MeshcoreRepeaterPasswordControls.test.tsx +++ b/src/renderer/components/MeshcoreRepeaterPasswordControls.test.tsx @@ -61,7 +61,7 @@ describe('MeshcoreRepeaterPasswordControls', () => { />, ); - await user.click(screen.getByRole('button', { name: /forget saved repeater password/i })); + await user.click(screen.getByRole('button', { name: /forget saved admin password/i })); expect(forgetMeshcoreRepeaterSavedSecret).toHaveBeenCalledWith(0x200); expect(onSecretsChanged).toHaveBeenCalled(); expect(onStatusMessage).toHaveBeenCalledWith(expect.stringMatching(/removed/i)); diff --git a/src/renderer/components/MeshcoreRoomAclControls.tsx b/src/renderer/components/MeshcoreRoomAclControls.tsx new file mode 100644 index 000000000..8a490b811 --- /dev/null +++ b/src/renderer/components/MeshcoreRoomAclControls.tsx @@ -0,0 +1,74 @@ +import { type SyntheticEvent, useCallback, useState } from 'react'; +import { useTranslation } from 'react-i18next'; + +interface Props { + disabled?: boolean; + onApply: (pubkeyHex: string, level: number) => Promise; +} + +/** Room ACL setperm form — used on Repeaters & Rooms ops CLI row. */ +export function MeshcoreRoomAclControls({ disabled, onApply }: Props) { + const { t } = useTranslation(); + const [aclPubkey, setAclPubkey] = useState(''); + const [aclLevel, setAclLevel] = useState(2); + const [pending, setPending] = useState(false); + + const handleSubmit = useCallback( + async (e: SyntheticEvent) => { + e.preventDefault(); + const normalized = aclPubkey.trim().toLowerCase(); + if (!/^[0-9a-f]{64}$/.test(normalized)) return; + setPending(true); + try { + await onApply(normalized, aclLevel); + setAclPubkey(''); + } finally { + setPending(false); + } + }, + [aclLevel, aclPubkey, onApply], + ); + + return ( +
void handleSubmit(e)}> + + + +
+ ); +} diff --git a/src/renderer/components/NodeDetailModal.test.tsx b/src/renderer/components/NodeDetailModal.test.tsx index d2f8b64ad..eedb87582 100644 --- a/src/renderer/components/NodeDetailModal.test.tsx +++ b/src/renderer/components/NodeDetailModal.test.tsx @@ -293,8 +293,8 @@ describe('NodeDetailModal MeshCore actions', () => { await user.click(screen.getByRole('button', { name: '📊 Request Status' })); - expect(screen.getByText('Repeater admin password')).toBeInTheDocument(); - const authOverlay = screen.getByText('Repeater admin password').closest('.fixed'); + expect(screen.getByText('Admin password')).toBeInTheDocument(); + const authOverlay = screen.getByText('Admin password').closest('.fixed'); expect(authOverlay).toHaveStyle({ zIndex: String(Z_NESTED_AUTH_OVERLAY) }); const nodeModalOverlay = container.querySelector('.fixed'); @@ -456,7 +456,7 @@ describe('NodeDetailModal MeshCore actions', () => { renderMeshcoreModal({ onRequestRepeaterStatus }); await user.click(screen.getByRole('button', { name: '📊 Request Status' })); - await user.type(screen.getByLabelText('Repeater admin password (optional)'), 'repeater-secret'); + await user.type(screen.getByLabelText('Admin password (optional)'), 'repeater-secret'); await user.click(screen.getByRole('button', { name: 'Continue' })); expect(onRequestRepeaterStatus).toHaveBeenCalledWith(meshcoreRepeaterNode.node_id); @@ -469,7 +469,7 @@ describe('NodeDetailModal MeshCore actions', () => { await user.click(screen.getByRole('button', { name: '📊 Request Status' })); await user.click(screen.getByRole('checkbox')); - await user.type(screen.getByLabelText('Repeater admin password (optional)'), 'session-only'); + await user.type(screen.getByLabelText('Admin password (optional)'), 'session-only'); await user.click(screen.getByRole('button', { name: 'Continue' })); expect(onRequestRepeaterStatus).toHaveBeenCalledWith(meshcoreRepeaterNode.node_id); @@ -482,7 +482,7 @@ describe('NodeDetailModal MeshCore actions', () => { renderMeshcoreModal({ onRequestRepeaterStatus }); await user.click(screen.getByRole('button', { name: '📊 Request Status' })); - await user.type(screen.getByLabelText('Repeater admin password (optional)'), 'repeater-secret'); + await user.type(screen.getByLabelText('Admin password (optional)'), 'repeater-secret'); await user.click(screen.getByRole('button', { name: 'Continue' })); expect(onRequestRepeaterStatus).toHaveBeenCalledWith(meshcoreRepeaterNode.node_id); diff --git a/src/renderer/components/NodeDetailModal.tsx b/src/renderer/components/NodeDetailModal.tsx index fc946e64e..a4a888330 100644 --- a/src/renderer/components/NodeDetailModal.tsx +++ b/src/renderer/components/NodeDetailModal.tsx @@ -530,7 +530,7 @@ export default function NodeDetailModal({ } } const repeaterName = node?.long_name ?? `Repeater-${nodeId.toString(16)}`; - const auth = await ensureRepeaterAuth(nodeId, repeaterName); + const auth = await ensureRepeaterAuth(nodeId, repeaterName, hwModel); if (!auth.ok) { setActionStatus(t('nodeDetailModal.remoteAuthCancelled')); return false; @@ -2000,57 +2000,59 @@ export default function NodeDetailModal({ : t('nodeDetailModal.sensorTelemetryButton')} )} - {protocol === 'meshcore' && onRequestNeighbors && node.hw_model === 'Repeater' && ( - - )} + className="bg-secondary-dark min-w-[8rem] flex-1 rounded-lg px-3 py-2 text-sm font-medium text-gray-200 transition-colors hover:bg-gray-600 disabled:cursor-not-allowed disabled:opacity-40" + > + {neighborsPending + ? t('nodeDetailModal.requestingEllipsis') + : t('nodeDetailModal.getNeighbors')} + + )} {onOpenRoom && protocol === 'meshcore' && node.hw_model === 'Room' && ( + ))} + + { + setSearchQuery(e.target.value); + }} + placeholder={t('repeatersPanel.searchRepeatersPlaceholder')} + aria-label={t('repeatersPanel.searchRepeaters')} + className="bg-secondary-dark/80 focus:border-brand-green/50 max-w-[20rem] min-w-[8rem] flex-1 rounded-lg border border-gray-600/50 px-3 py-1.5 text-sm text-gray-200 focus:outline-none" + /> +

{t('repeatersPanel.columnsDataHint')}

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

{t('repeatersPanel.noRepeatersYet')}

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

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

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

- {RemoteAuthModal} {forgetConfirmNodeId != null && ( {t('chatPanel.starredMessages')} - + {onOpenRepeaterOps ? ( + + ) : null}
@@ -2766,145 +2684,6 @@ export default function RoomsPanel({ /> )}
- - {manageOpen && ( -
-
-

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

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

{cliError}

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

{t('roomsPanel.cliEmpty')}

- ) : ( - cliHistory.map((entry, idx) => ( -
- {entry.type === 'sent' ? '> ' : '< '} - {entry.text} -
- )) - )} -
-
- )} )} diff --git a/src/renderer/hooks/useMeshcoreRepeaterRemoteAuth.test.tsx b/src/renderer/hooks/useMeshcoreRepeaterRemoteAuth.test.tsx index 5adeb5f1d..a4daef1c8 100644 --- a/src/renderer/hooks/useMeshcoreRepeaterRemoteAuth.test.tsx +++ b/src/renderer/hooks/useMeshcoreRepeaterRemoteAuth.test.tsx @@ -3,12 +3,18 @@ import userEvent from '@testing-library/user-event'; import { useState } from 'react'; import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { mergeAppSetting } from '@/renderer/lib/appSettingsStorage'; +import { APP_SETTINGS_STORAGE_KEY, mergeAppSetting } from '@/renderer/lib/appSettingsStorage'; +import { clearAllRoomEphemeralAdminPasswords } from '@/renderer/lib/meshcoreInfraAdminSecrets'; import { meshcoreRepeaterCredentialSettingForNode } from '@/renderer/lib/meshcoreRepeaterCredentialStorage'; import { clearAllMeshcoreRepeaterEphemeralPasswords, setMeshcoreRepeaterEphemeralPassword, } from '@/renderer/lib/meshcoreRepeaterSession'; +import { + getMeshcoreRoomCredential, + meshcoreRoomCredentialSettingForNode, + setMeshcoreRoomCredential, +} from '@/renderer/lib/meshcoreRoomCredentialStorage'; import { useMeshcoreRepeaterRemoteAuth } from './useMeshcoreRepeaterRemoteAuth'; @@ -27,10 +33,12 @@ vi.mock('../components/Toast', () => ({ function RepeaterAuthProbe({ nodeId, repeaterName, + hwModel, onAuthed, }: { nodeId: number; repeaterName: string; + hwModel?: string; onAuthed?: () => void; }) { const { ensureRepeaterAuth, RemoteAuthModal } = useMeshcoreRepeaterRemoteAuth(); @@ -41,7 +49,7 @@ function RepeaterAuthProbe({ diff --git a/src/renderer/components/RoomsPanel.test.tsx b/src/renderer/components/RoomsPanel.test.tsx index 498d6dcae..f329c48fb 100644 --- a/src/renderer/components/RoomsPanel.test.tsx +++ b/src/renderer/components/RoomsPanel.test.tsx @@ -178,7 +178,7 @@ describe('RoomsPanel', () => { }); }); - it('forces admin relogin when managing from a read-only session', () => { + it('opens Repeater ops for a read-only session without showing Rooms CLI/ACL', () => { const room = makeRoom(0x100c, 'Admin Elevate Room'); const nodes = new Map([[room.node_id, room]]); meshcoreApplyRoomSession(room.node_id, { @@ -192,7 +192,7 @@ describe('RoomsPanel', () => { fireEvent.click(screen.getByText('roomsPanel.manageRoom')); expect(onOpenRepeaterOps).toHaveBeenCalledWith(room.node_id); - expect(screen.queryByText('roomsPanel.cliPlaceholder')).not.toBeInTheDocument(); + expect(screen.queryByPlaceholderText('roomsPanel.cliPlaceholder')).not.toBeInTheDocument(); expect(screen.queryByText('roomsPanel.aclPubkeyLabel')).not.toBeInTheDocument(); }); diff --git a/src/renderer/hooks/meshcore/meshcoreConnSideEffects.test.ts b/src/renderer/hooks/meshcore/meshcoreConnSideEffects.test.ts index 6cecb84ec..735a8efa0 100644 --- a/src/renderer/hooks/meshcore/meshcoreConnSideEffects.test.ts +++ b/src/renderer/hooks/meshcore/meshcoreConnSideEffects.test.ts @@ -661,6 +661,7 @@ describe('attachMeshcoreConnSideEffects', () => { holdSpy.mockReturnValue(false); await vi.runAllTimersAsync(); await pending; + expect(h.conn.getWaitingMessages).toHaveBeenCalledTimes(1); holdSpy.mockRestore(); }); @@ -709,6 +710,7 @@ describe('attachMeshcoreConnSideEffects', () => { await vi.runAllTimersAsync(); // Follow-up force drain uses incrementalOnly — syncNext, not a second bulk. expect(h.syncNextMessage).toHaveBeenCalled(); + expect(h.conn.getWaitingMessages).toHaveBeenCalledTimes(1); }); it('flushes waiting-message node changes to nodeStore without updating the runtime node mirror', async () => { diff --git a/src/renderer/hooks/meshcore/meshcoreWaitingMessagesSyncState.test.ts b/src/renderer/hooks/meshcore/meshcoreWaitingMessagesSyncState.test.ts index 3a3ff8c41..e5df8e65e 100644 --- a/src/renderer/hooks/meshcore/meshcoreWaitingMessagesSyncState.test.ts +++ b/src/renderer/hooks/meshcore/meshcoreWaitingMessagesSyncState.test.ts @@ -105,6 +105,7 @@ describe('meshcoreWaitingMessagesSyncState follow-up chaining', () => { setMeshcoreProcessWaitingMessagesInFlight(Promise.resolve()); requestMeshcoreWaitingMessagesFollowUp(); + requestMeshcoreWaitingMessagesForceFollowUp(true); resetMeshcoreProcessWaitingMessagesSync( setWaitingMessagesCount, @@ -116,8 +117,14 @@ describe('meshcoreWaitingMessagesSyncState follow-up chaining', () => { expect(getMeshcoreProcessWaitingMessagesInFlight()).toBeNull(); expect(takeMeshcoreWaitingMessagesFollowUp()).toBe(false); + expect(takeMeshcoreWaitingMessagesForceFollowUp()).toBeNull(); expect(getMeshcoreWaitingMessagesSilentFollowUpChainCount()).toBe(0); expect(setWaitingMessagesSilentDrainActive).toHaveBeenCalledWith(false); expect(setWaitingMessagesDrainDeferred).toHaveBeenCalledWith(false); + + // After reset, a new force follow-up must not inherit incrementalOnly from before reset. + setMeshcoreProcessWaitingMessagesInFlight(Promise.resolve()); + requestMeshcoreWaitingMessagesForceFollowUp(false); + expect(takeMeshcoreWaitingMessagesForceFollowUp()).toEqual({ incrementalOnly: false }); }); }); diff --git a/src/renderer/hooks/useMeshcoreRepeaterRemoteAuth.test.tsx b/src/renderer/hooks/useMeshcoreRepeaterRemoteAuth.test.tsx index a4daef1c8..342fcd7f8 100644 --- a/src/renderer/hooks/useMeshcoreRepeaterRemoteAuth.test.tsx +++ b/src/renderer/hooks/useMeshcoreRepeaterRemoteAuth.test.tsx @@ -2,7 +2,9 @@ import { fireEvent, render, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { useState } from 'react'; import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { axe } from 'vitest-axe'; +import { hydrateAxeThemeColors } from '@/renderer/lib/a11yTestHelpers'; import { APP_SETTINGS_STORAGE_KEY, mergeAppSetting } from '@/renderer/lib/appSettingsStorage'; import { clearAllRoomEphemeralAdminPasswords } from '@/renderer/lib/meshcoreInfraAdminSecrets'; import { meshcoreRepeaterCredentialSettingForNode } from '@/renderer/lib/meshcoreRepeaterCredentialStorage'; @@ -144,9 +146,13 @@ describe('useMeshcoreRepeaterRemoteAuth', () => { it('Remember for Room writes room credential adminPassword not repeater key', async () => { const user = userEvent.setup(); - render(); + const { container } = render( + , + ); await user.click(screen.getByText('request-auth')); await user.type(screen.getByLabelText('repeatersPanel.remoteAuthLabel'), 'room-secret'); + hydrateAxeThemeColors(container); + expect(await axe(container)).toHaveNoViolations(); await user.click(screen.getByText('repeatersPanel.remoteAuthContinue')); await waitFor(() => { diff --git a/src/renderer/lib/meshcoreRoomLoginRouteResolve.ts b/src/renderer/lib/meshcoreRoomLoginRouteResolve.ts index c3f1495af..2db865abb 100644 --- a/src/renderer/lib/meshcoreRoomLoginRouteResolve.ts +++ b/src/renderer/lib/meshcoreRoomLoginRouteResolve.ts @@ -1,4 +1,4 @@ -import { meshcorePubkeyPathPrefix } from '@/shared/meshcorePathHash'; +import { type MeshcorePathHashMode, meshcorePubkeyPathPrefix } from '@/shared/meshcorePathHash'; import { withTimeout } from '@/shared/withTimeout'; import type { MeshCoreContactRaw } from './meshcore/meshcoreHookTypes'; @@ -34,11 +34,12 @@ async function traceRouteForRoomLogin( pubKey: Uint8Array, seedPath: Uint8Array | undefined, radioContactPathLen: number | null, + companionPathHashMode: MeshcorePathHashMode | null | undefined, traceTimeoutMs: number, runSerialized: (fn: () => Promise) => Promise, ): Promise { if (!conn.sendCommandSendTracePath) return undefined; - const hashSize = meshcoreHashSizeForTraceSeed(radioContactPathLen, null); + const hashSize = meshcoreHashSizeForTraceSeed(radioContactPathLen, companionPathHashMode); let seed = seedPath && seedPath.length > 0 ? seedPath : meshcorePubkeyPathPrefix(pubKey, hashSize); if (seed.length === hashSize && seed.every((b) => b === 0) && pubKey[0] !== 0) { @@ -100,6 +101,7 @@ export async function resolveMeshcoreRoomLoginRouteBytes( /** When true, skip flood prime and active trace (background scheduler fast-fail). */ skipTrace?: boolean; traceTimeoutMs?: number; + companionPathHashMode?: MeshcorePathHashMode | null; runSerialized?: (fn: () => Promise) => Promise; }, ): Promise { @@ -158,6 +160,7 @@ export async function resolveMeshcoreRoomLoginRouteBytes( opts.pubKey, path, radioContactPathLen, + opts.companionPathHashMode, opts.traceTimeoutMs, opts.runSerialized, ); diff --git a/src/renderer/runtime/useMeshcoreRuntime.ts b/src/renderer/runtime/useMeshcoreRuntime.ts index d9542223e..47c612142 100644 --- a/src/renderer/runtime/useMeshcoreRuntime.ts +++ b/src/renderer/runtime/useMeshcoreRuntime.ts @@ -261,6 +261,7 @@ import { isMeshcoreRepeaterCliDangerCommand } from '../lib/meshcoreRepeaterCliDa import { beginMeshcoreCliReplyHold, endMeshcoreCliReplyHold, + meshcoreCliReplyHoldActive, meshcoreCompanionRepeaterRfBusy, resetMeshcoreRepeaterRpcInFlightOnDisconnect, runMeshcoreRepeaterRpcOnce, @@ -737,7 +738,6 @@ export function useMeshcoreRuntime() { /** Last-seen companion outPathLen per node (packed hash size survives intermittent getContacts misses). */ const radioContactPathLenByNodeRef = useRef>(new Map()); const pathHashModeRef = useRef(state.pathHashMode); - pathHashModeRef.current = state.pathHashMode; // nodeId → nickname (from JSON import or DB) const nicknameMapRef = useRef>(new Map()); /** Skip mount DB hydration commit when live ingest/import ran before async reload finishes. */ @@ -1063,6 +1063,10 @@ export function useMeshcoreRuntime() { myNodeNumRef.current = state.myNodeNum; }, [state.myNodeNum]); + useEffect(() => { + pathHashModeRef.current = state.pathHashMode; + }, [state.pathHashMode]); + // Start stats polling when configured (after contacts dump — not during initConn). useEffect(() => { if (state.status === 'configured') { @@ -5233,9 +5237,13 @@ export function useMeshcoreRuntime() { } catch { // catch-no-log-ok first trace rejected after cancel; direct retry may proceed } - // CLI preempt clears TraceData so waiting-message drain can deliver CLI replies. - // Do not escalate to a full-pubkey retry — that immediately re-blocks the radio. - if (meshcoreTraceCancelledForCliPreempt(firstTraceError)) { + // CLI preempt / active CLI reply hold clears TraceData so waiting-message drain + // can deliver CLI replies. Do not escalate to a full-pubkey retry — that + // immediately re-blocks the radio. + if ( + meshcoreTraceCancelledForCliPreempt(firstTraceError) || + meshcoreCliReplyHoldActive() + ) { throw firstTraceError; } const directRetryEligible = meshcoreTraceDirectRetryEligible( @@ -6106,6 +6114,9 @@ export function useMeshcoreRuntime() { allowPrime: schedulerFastPath ? false : fromMap == null || fromMap.length <= 1, skipTrace: schedulerFastPath, traceTimeoutMs: schedulerFastPath ? 0 : MESHCORE_TRACE_TIMEOUT_MS, + companionPathHashMode: isMeshcorePathHashMode(pathHashModeRef.current) + ? pathHashModeRef.current + : null, runSerialized: (fn) => repeaterRemoteRpcRef.current(fn), }), schedulerFastPath